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
@@ -1,7 +1,10 @@
1
- import { access, mkdir, open, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
1
+ import { access, mkdir, open, readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
2
2
  import { constants } from "node:fs";
3
- import { randomUUID } from "node:crypto";
4
- import { dirname, join, resolve, sep } from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { fileURLToPath } from "node:url";
5
8
  import { fail } from "./errors.js";
6
9
  /**
7
10
  * One table, so detection, hook installation and the setup API answer from the
@@ -12,10 +15,12 @@ const AGENTS = {
12
15
  codex: {
13
16
  label: "Codex CLI",
14
17
  root: (home) => join(home, ".codex"),
15
- // Codex configures itself in TOML, so its hooks cannot be merged in by this
16
- // build; `installAgentHooks` produces a reviewable fragment instead.
17
- hookFile: (home) => join(home, ".codex", "config.toml"),
18
- hookFileFormat: "toml",
18
+ // Codex reads lifecycle hooks from ~/.codex/hooks.json as well as inline
19
+ // config.toml tables. The dedicated JSON file is the safe integration
20
+ // surface: Sealkeep can merge it without parsing or rewriting the person's
21
+ // TOML settings, and Codex still applies its normal one-time trust review.
22
+ hookFile: (home) => join(home, ".codex", "hooks.json"),
23
+ hookFileFormat: "json",
19
24
  archivable: true,
20
25
  transcriptRoots: (home) => [join(home, ".codex", "sessions"), join(home, ".codex", "archived_sessions")]
21
26
  },
@@ -64,6 +69,7 @@ const AGENTS = {
64
69
  export const SETUP_AGENT_IDS = Object.keys(AGENTS);
65
70
  /** The subset with a session adapter. Mirrors `AgentId` at runtime for schema use. */
66
71
  export const ARCHIVING_AGENT_IDS = ["codex", "claude"];
72
+ export const HOOK_LIFECYCLE_VERSION = 3;
67
73
  /** Narrows a setup id to an archiving one, so callers prove the subset rather than casting to it. */
68
74
  export function isArchivingAgent(agent) {
69
75
  return ARCHIVING_AGENT_IDS.includes(agent);
@@ -93,19 +99,42 @@ export async function detectAgents(home = process.env.HOME ?? ".") {
93
99
  * the naive re-expansion, kept because "which /Users/… did this come from" is
94
100
  * still worth answering approximately.
95
101
  */
102
+ /**
103
+ * Which archiving agent wrote a transcript, judged by where it lives. A file
104
+ * under `~/.claude/projects` is Claude's; under `~/.codex/sessions` (or its
105
+ * archived sibling) it is Codex's. This is what lets a hand-sealed
106
+ * `sealkeep archive <file>` carry the same agent — and therefore the same
107
+ * project derivation — as the watcher would have given it, instead of the
108
+ * "custom" label that leaves the session untagged and invisible to every
109
+ * project-scoped recall.
110
+ */
111
+ export function agentForTranscriptPath(path, home = process.env.HOME ?? ".") {
112
+ const target = resolve(path);
113
+ for (const agent of ["codex", "claude"]) {
114
+ for (const root of AGENTS[agent].transcriptRoots(home)) {
115
+ const base = resolve(root);
116
+ if (target === base || target.startsWith(base + sep))
117
+ return agent;
118
+ }
119
+ }
120
+ return null;
121
+ }
96
122
  export function claudeProjectFromPath(path) {
97
123
  const marker = `${sep}.claude${sep}projects${sep}`;
98
124
  const start = path.indexOf(marker);
99
125
  if (start === -1)
100
- return { project: null, projectPath: null };
126
+ return { project: null, projectKey: null, projectPath: null };
101
127
  const rest = path.slice(start + marker.length).split(sep);
102
128
  // A .jsonl directly under projects/ is a stray file, not a project directory.
103
129
  if (rest.length < 2)
104
- return { project: null, projectPath: null };
130
+ return { project: null, projectKey: null, projectPath: null };
105
131
  const segments = rest[0].split("-").filter(Boolean);
106
132
  if (segments.length === 0)
107
- return { project: null, projectPath: null };
108
- return { project: segments[segments.length - 1], projectPath: rest[0].replace(/-/g, "/") };
133
+ return { project: null, projectKey: null, projectPath: null };
134
+ // The encoded directory is lossy (a literal dash and a path separator are
135
+ // indistinguishable), so it cannot safely become an access-control key.
136
+ // Real transcripts contain cwd and take the projectFromCwd path below.
137
+ return { project: segments[segments.length - 1], projectKey: null, projectPath: rest[0].replace(/-/g, "/") };
109
138
  }
110
139
  /**
111
140
  * A rollout's first line is its session-meta record, and `cwd` in it is the
@@ -118,13 +147,269 @@ export function claudeProjectFromPath(path) {
118
147
  */
119
148
  const FIRST_LINE_BYTES = 8192;
120
149
  const codexIdentityCache = new Map();
150
+ /**
151
+ * The munged directory name under ~/.claude/projects encodes "/" as "-", so a
152
+ * folder whose NAME contains a hyphen is indistinguishable from nesting:
153
+ * ~/tiny-ledger reads back as project "ledger". The transcript itself records
154
+ * the real cwd on nearly every line, so while the file is on disk the honest
155
+ * answer is one bounded read away — same cache discipline as the codex
156
+ * first-line read. The path arithmetic stays as the fallback for transcripts
157
+ * that carry no cwd at all. Caught live: one folder, two agents, and the
158
+ * shared project memory split in half.
159
+ */
160
+ const runGit = promisify(execFile);
161
+ // Project discovery runs while the dashboard is loading every transcript on
162
+ // the machine. A workstation can easily have hundreds of distinct recorded
163
+ // cwd values, and `execFile` has no backpressure of its own: launching one Git
164
+ // process per cwd at once starves the UI even though every individual command
165
+ // is small. Four keeps discovery moving without turning a metadata scan into a
166
+ // process storm.
167
+ const MAX_CONCURRENT_GIT_COMMANDS = 4;
168
+ let activeGitCommands = 0;
169
+ const waitingGitCommands = [];
170
+ async function acquireGitCommandSlot() {
171
+ if (activeGitCommands < MAX_CONCURRENT_GIT_COMMANDS) {
172
+ activeGitCommands += 1;
173
+ return;
174
+ }
175
+ await new Promise((ready) => waitingGitCommands.push(ready));
176
+ }
177
+ function releaseGitCommandSlot() {
178
+ const next = waitingGitCommands.shift();
179
+ if (next) {
180
+ // Transfer this occupied slot directly to the oldest waiter. Decrementing
181
+ // first would let a newly-arrived command jump the queue before `next`
182
+ // resumes on the microtask queue and could exceed the bound.
183
+ next();
184
+ }
185
+ else {
186
+ activeGitCommands -= 1;
187
+ }
188
+ }
189
+ async function runGitCommand(args) {
190
+ await acquireGitCommandSlot();
191
+ try {
192
+ const result = await runGit("git", args, { timeout: 2000, encoding: "utf8" });
193
+ return { stdout: String(result.stdout), stderr: String(result.stderr) };
194
+ }
195
+ finally {
196
+ releaseGitCommandSlot();
197
+ }
198
+ }
199
+ const projectIdentityCache = new Map();
200
+ const projectIdentityInFlight = new Map();
201
+ function digestProjectKey(prefix, value) {
202
+ return `${prefix}:${createHash("sha256").update(value).digest("hex")}`;
203
+ }
204
+ /** Canonicalises the common HTTPS/SSH/scp git spellings without credentials or query data. */
205
+ function canonicalGitRemote(raw, relativeTo) {
206
+ const input = raw.trim();
207
+ if (!input)
208
+ return null;
209
+ let host = "";
210
+ let path = "";
211
+ try {
212
+ if (input.includes("://")) {
213
+ const url = new URL(input);
214
+ if (url.protocol === "file:") {
215
+ host = "local";
216
+ path = fileURLToPath(url);
217
+ }
218
+ else {
219
+ // `hostname` deliberately omits the port. That collapsed two independent
220
+ // self-hosted Git servers at :8443 and :9443 into one access-control key.
221
+ const defaultPort = (url.protocol === "ssh:" && url.port === "22")
222
+ || (url.protocol === "https:" && url.port === "443")
223
+ || (url.protocol === "http:" && url.port === "80");
224
+ // Git hosts are ASCII/punycode. Locale-sensitive casing would let the
225
+ // same remote hash differently on machines with different locales.
226
+ host = `${url.hostname.toLowerCase()}${url.port && !defaultPort ? `:${url.port}` : ""}`;
227
+ path = url.pathname;
228
+ }
229
+ }
230
+ else {
231
+ // git@github.com:owner/repo.git (also accepts host:owner/repo.git).
232
+ const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/.exec(input);
233
+ if (scp) {
234
+ host = scp[1].toLowerCase();
235
+ path = scp[2];
236
+ }
237
+ else {
238
+ // Local remotes are still stable on this machine. They intentionally
239
+ // do not converge with a hosted URL because there is no proof that
240
+ // they name the same repository.
241
+ host = "local";
242
+ // Git interprets a relative local remote from the repository, not from
243
+ // whichever cwd happened to launch the long-running daemon.
244
+ path = isAbsolute(input) ? input : resolve(relativeTo, input);
245
+ }
246
+ }
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ const cleanPath = path.replace(/[?#].*$/, "").replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
252
+ const label = basename(cleanPath);
253
+ return host && cleanPath && label ? { host, path: cleanPath, label } : null;
254
+ }
255
+ async function gitProjectForCwd(cwd) {
256
+ if (!cwd)
257
+ return { identity: null, configPath: null };
258
+ try {
259
+ const { stdout: rootOut } = await runGitCommand(["-C", cwd, "rev-parse", "--show-toplevel"]);
260
+ const root = rootOut.trim();
261
+ if (!root)
262
+ return { identity: null, configPath: null };
263
+ const canonicalCwd = await realpath(cwd).catch(() => resolve(cwd));
264
+ const canonicalRoot = await realpath(root).catch(() => resolve(root));
265
+ const [remoteOut, commonOut] = await Promise.all([
266
+ runGitCommand(["-C", cwd, "remote", "get-url", "origin"]).then((result) => result.stdout, () => ""),
267
+ runGitCommand(["-C", cwd, "rev-parse", "--git-common-dir"]).then((result) => result.stdout.trim(), () => ""),
268
+ ]);
269
+ const commonDir = commonOut ? (isAbsolute(commonOut) ? commonOut : resolve(canonicalRoot, commonOut)) : "";
270
+ const configPath = commonDir ? join(commonDir, "config") : null;
271
+ const remote = canonicalGitRemote(remoteOut, canonicalRoot);
272
+ if (!remote)
273
+ return { identity: null, configPath };
274
+ const inside = relative(canonicalRoot, canonicalCwd).split(sep).join("/");
275
+ return {
276
+ configPath,
277
+ identity: {
278
+ project: remote.label,
279
+ projectKey: digestProjectKey("g1", `${remote.host}/${remote.path}\0${inside}`),
280
+ projectPath: cwd,
281
+ },
282
+ };
283
+ }
284
+ catch {
285
+ return { identity: null, configPath: null };
286
+ }
287
+ }
288
+ async function pathStamp(path) {
289
+ try {
290
+ const entry = await stat(path);
291
+ const object = `${entry.dev}:${entry.ino}`;
292
+ return { object, modifiedAtMs: entry.mtimeMs, isDirectory: entry.isDirectory() };
293
+ }
294
+ catch {
295
+ return null;
296
+ }
297
+ }
298
+ async function fileFingerprint(path) {
299
+ try {
300
+ const [entry, contents] = await Promise.all([stat(path), readFile(path)]);
301
+ return `${entry.dev}:${entry.ino}:${contents.length}:${createHash("sha256").update(contents).digest("hex")}`;
302
+ }
303
+ catch {
304
+ return null;
305
+ }
306
+ }
307
+ async function cachedProjectIdentity(cwd, current) {
308
+ const cached = projectIdentityCache.get(cwd);
309
+ if (!cached)
310
+ return null;
311
+ if (!current.isDirectory || current.object !== cached.cwdObject)
312
+ return null;
313
+ if (cached.gitConfigPath) {
314
+ const fingerprint = await fileFingerprint(cached.gitConfigPath);
315
+ if (fingerprint !== (cached.gitConfigFingerprint ?? null))
316
+ return null;
317
+ }
318
+ else if (current.modifiedAtMs !== cached.cwdModifiedAtMs) {
319
+ return null;
320
+ }
321
+ return { ...cached.identity };
322
+ }
323
+ async function resolveProjectIdentity(cwd, segments) {
324
+ // Transcripts routinely outlive the checkout they were recorded in. Git
325
+ // cannot add information for a cwd that no longer exists (or is a file), so
326
+ // do not spawn it. The absence is deliberately not put in the persistent
327
+ // cache: if the checkout appears later, the next scan detects it normally.
328
+ const initialStamp = await pathStamp(cwd);
329
+ if (!initialStamp?.isDirectory) {
330
+ return {
331
+ project: segments[segments.length - 1],
332
+ projectKey: digestProjectKey("p1", resolve(cwd)),
333
+ projectPath: cwd,
334
+ };
335
+ }
336
+ const cached = await cachedProjectIdentity(cwd, initialStamp);
337
+ if (cached)
338
+ return cached;
339
+ const git = await gitProjectForCwd(cwd);
340
+ const identity = git.identity ?? {
341
+ project: segments[segments.length - 1],
342
+ projectKey: digestProjectKey("p1", await realpath(cwd).catch(() => resolve(cwd))),
343
+ projectPath: cwd,
344
+ };
345
+ const cwdStamp = await pathStamp(cwd);
346
+ if (cwdStamp?.isDirectory) {
347
+ const configFingerprint = git.configPath ? await fileFingerprint(git.configPath) : null;
348
+ projectIdentityCache.set(cwd, {
349
+ identity,
350
+ cwdObject: cwdStamp.object,
351
+ cwdModifiedAtMs: cwdStamp.modifiedAtMs,
352
+ ...(git.configPath ? { gitConfigPath: git.configPath, gitConfigFingerprint: configFingerprint } : {}),
353
+ });
354
+ }
355
+ return { ...identity };
356
+ }
357
+ /** The project for a session's recorded cwd: the git remote's repo name, else the folder name. */
358
+ export async function projectFromCwd(cwd) {
359
+ const segments = cwd.split(/[\\/]/).filter(Boolean);
360
+ if (segments.length === 0)
361
+ return { project: null, projectKey: null, projectPath: null };
362
+ // A full transcript scan resolves many files from the same cwd at once. One
363
+ // immutable in-flight answer is enough; completed answers still use the
364
+ // freshness-aware cache above, and every caller receives its own object.
365
+ const running = projectIdentityInFlight.get(cwd);
366
+ if (running)
367
+ return { ...await running };
368
+ const resolution = resolveProjectIdentity(cwd, segments);
369
+ projectIdentityInFlight.set(cwd, resolution);
370
+ try {
371
+ return { ...await resolution };
372
+ }
373
+ finally {
374
+ if (projectIdentityInFlight.get(cwd) === resolution)
375
+ projectIdentityInFlight.delete(cwd);
376
+ }
377
+ }
378
+ const claudeIdentityCache = new Map();
379
+ export async function claudeProjectFromTranscript(path, modifiedAt) {
380
+ const cached = claudeIdentityCache.get(path);
381
+ let cwd = cached?.modifiedAt === modifiedAt ? cached.cwd : undefined;
382
+ if (cwd === undefined) {
383
+ let handle;
384
+ try {
385
+ handle = await open(path, "r");
386
+ const buffer = Buffer.alloc(FIRST_LINE_BYTES);
387
+ const { bytesRead } = await handle.read(buffer, 0, FIRST_LINE_BYTES, 0);
388
+ cwd = scannedCwd(buffer.subarray(0, bytesRead).toString("utf8"));
389
+ }
390
+ catch {
391
+ cwd = null; /* an unreadable head falls back to the path arithmetic */
392
+ }
393
+ finally {
394
+ await handle?.close().catch(() => { });
395
+ }
396
+ claudeIdentityCache.set(path, { modifiedAt, cwd });
397
+ }
398
+ // Cache only what is immutable in the transcript. Repository identity is
399
+ // intentionally revalidated: a missing checkout can appear later and an
400
+ // existing checkout can change origin without changing the transcript.
401
+ return cwd ? projectFromCwd(cwd) : claudeProjectFromPath(path);
402
+ }
121
403
  export async function codexProjectFromRollout(path, modifiedAt) {
122
404
  const cached = codexIdentityCache.get(path);
123
- if (cached && cached.modifiedAt === modifiedAt)
124
- return { project: cached.project, projectPath: cached.projectPath };
125
- const identity = await codexIdentityFromFirstLine(path);
126
- codexIdentityCache.set(path, { modifiedAt, ...identity });
127
- return identity;
405
+ let cwd = cached?.modifiedAt === modifiedAt ? cached.cwd : undefined;
406
+ if (cwd === undefined) {
407
+ cwd = await codexCwdFromFirstLine(path);
408
+ codexIdentityCache.set(path, { modifiedAt, cwd });
409
+ }
410
+ return cwd
411
+ ? projectFromCwd(cwd)
412
+ : { project: null, projectKey: null, projectPath: null };
128
413
  }
129
414
  /** `cwd` (or the older `cd`) on the record itself, or one level down in `payload`, where session-meta keeps it. */
130
415
  function recordedCwd(record, depth = 0) {
@@ -159,8 +444,7 @@ function scannedCwd(text) {
159
444
  return null;
160
445
  }
161
446
  }
162
- async function codexIdentityFromFirstLine(path) {
163
- const none = { project: null, projectPath: null };
447
+ async function codexCwdFromFirstLine(path) {
164
448
  let handle;
165
449
  try {
166
450
  handle = await open(path, "r");
@@ -177,15 +461,12 @@ async function codexIdentityFromFirstLine(path) {
177
461
  // bounded scan reads the head that was fetched and nothing more.
178
462
  cwd = scannedCwd(text);
179
463
  }
180
- if (!cwd)
181
- return none;
182
- const segments = cwd.split(/[\\/]/).filter(Boolean);
183
- return segments.length === 0 ? none : { project: segments[segments.length - 1], projectPath: cwd };
464
+ return cwd;
184
465
  }
185
466
  catch {
186
467
  // An unreadable first line is a session without a project, not an error:
187
468
  // the list must survive whatever a vendor format update does.
188
- return none;
469
+ return null;
189
470
  }
190
471
  finally {
191
472
  await handle?.close().catch(() => { });
@@ -193,24 +474,19 @@ async function codexIdentityFromFirstLine(path) {
193
474
  }
194
475
  /**
195
476
  * Fills project identity in place, after the limit cut — resolving only what
196
- * will be returned keeps the codex reads proportional to the page, not the
197
- * disk. Claude's identity is pure path arithmetic; codex needs the bounded
198
- * first-line read, capped at 16 in flight so hundreds of cold files neither
199
- * run serially nor open hundreds of descriptors at once.
477
+ * will be returned keeps the reads proportional to the page, not the disk.
478
+ * Both agents take a bounded head read (claude for the recorded cwd, codex
479
+ * for its first-line meta), capped at 16 in flight so hundreds of cold files
480
+ * neither run serially nor open hundreds of descriptors at once.
200
481
  */
201
482
  async function attachProjects(candidates) {
202
- const rollouts = [];
203
- for (const candidate of candidates) {
204
- if (candidate.agent === "claude")
205
- Object.assign(candidate, claudeProjectFromPath(candidate.path));
206
- else
207
- rollouts.push(candidate);
208
- }
209
483
  let next = 0;
210
- await Promise.all(Array.from({ length: Math.min(16, rollouts.length) }, async () => {
211
- while (next < rollouts.length) {
212
- const candidate = rollouts[next++];
213
- Object.assign(candidate, await codexProjectFromRollout(candidate.path, candidate.modifiedAt));
484
+ await Promise.all(Array.from({ length: Math.min(16, candidates.length) }, async () => {
485
+ while (next < candidates.length) {
486
+ const candidate = candidates[next++];
487
+ Object.assign(candidate, candidate.agent === "claude"
488
+ ? await claudeProjectFromTranscript(candidate.path, candidate.modifiedAt)
489
+ : await codexProjectFromRollout(candidate.path, candidate.modifiedAt));
214
490
  }
215
491
  }));
216
492
  }
@@ -222,13 +498,20 @@ async function attachProjects(candidates) {
222
498
  * arbitrary sessions that happened to be walked first. Only metadata is read,
223
499
  * so the cost is one stat per file.
224
500
  */
225
- async function walkJsonl(root, agent, output) {
501
+ async function walkJsonl(root, agent, output, exclude) {
502
+ // Apply the exclusion before readdir/stat and before project metadata is
503
+ // attached. "Never watch" therefore means the excluded transcript branch
504
+ // is not processed merely so it can be thrown away later.
505
+ if (exclude?.(root))
506
+ return;
226
507
  if (!(await directoryExists(root)))
227
508
  return;
228
509
  for (const entry of await readdir(root, { withFileTypes: true })) {
229
510
  const path = join(root, entry.name);
511
+ if (exclude?.(path))
512
+ continue;
230
513
  if (entry.isDirectory())
231
- await walkJsonl(path, agent, output);
514
+ await walkJsonl(path, agent, output, exclude);
232
515
  else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
233
516
  const file = await stat(path);
234
517
  output.push({ agent, path, bytes: file.size, modifiedAt: file.mtime.toISOString() });
@@ -240,13 +523,21 @@ async function walkJsonl(root, agent, output) {
240
523
  * read into a file is the codex rollout's first line, which is the session's
241
524
  * own metadata record — what was said in the session stays unread.
242
525
  */
243
- export async function findTranscripts(agent, home = process.env.HOME ?? ".", limit = 50) {
526
+ export async function findTranscripts(agent, home = process.env.HOME ?? ".", limit, options = {}) {
244
527
  const install = (await detectAgents(home)).find((item) => item.agent === agent);
245
528
  const files = [];
246
529
  for (const root of install.transcriptRoots)
247
- await walkJsonl(root, agent, files);
248
- const recent = files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)).slice(0, limit);
249
- await attachProjects(recent);
530
+ await walkJsonl(root, agent, files, options.exclude);
531
+ // Automatic preservation must be exhaustive: a fixed cap silently leaves old
532
+ // sessions behind on a long-lived workstation. Callers that intentionally
533
+ // render a bounded list can still pass an explicit limit.
534
+ const ordered = files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
535
+ const recent = limit === undefined ? ordered : ordered.slice(0, Math.max(0, limit));
536
+ // Watcher admission and enqueueing use only agent/path/size/mtime. Let that
537
+ // exhaustive background walk skip transcript-head JSON and Git identity;
538
+ // UI/project callers retain enrichment by default.
539
+ if (options.includeProjects !== false)
540
+ await attachProjects(recent);
250
541
  return recent;
251
542
  }
252
543
  /**
@@ -256,8 +547,12 @@ export async function findTranscripts(agent, home = process.env.HOME ?? ".", lim
256
547
  * it, so the command and the thing that recognises it can never disagree about
257
548
  * what a Sealkeep hook looks like.
258
549
  */
259
- export function hookCommand(agent, executable = "vaultline", dataDir = "~/.vaultline") {
260
- return `${executable} ${hookMarker(agent)} --data-dir ${dataDir}`;
550
+ export function hookCommand(agent, executable = "sealkeep", dataDir = "~/.sealkeep") {
551
+ return `${executable} ${hookMarker(agent)} --data-dir ${shellArgument(dataDir)} --lifecycle ${HOOK_LIFECYCLE_VERSION}`;
552
+ }
553
+ /** One shell argument, including paths with spaces and quotes. */
554
+ function shellArgument(value) {
555
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
261
556
  }
262
557
  /** The substring that identifies a Sealkeep hook for one agent, wherever it is written. */
263
558
  export function hookMarker(agent) {
@@ -268,29 +563,61 @@ export function hookMarker(agent) {
268
563
  * an agent's settings: users must review and merge it themselves. The hook only
269
564
  * enqueues local work, so it needs no recovery phrase and no network access.
270
565
  */
271
- export function hookConfig(agent, executable = "vaultline", dataDir = "~/.vaultline") {
566
+ export function hookConfig(agent, executable = "sealkeep", dataDir = "~/.sealkeep") {
272
567
  const command = hookCommand(agent, executable, dataDir);
273
568
  const entry = (timeout) => [{ hooks: [{ type: "command", command, timeout }] }];
274
- // The resume side of the pair: SessionStart checks whether the transcript
275
- // the agent is about to read left this disk for the vault, and puts it back
276
- // first — resume never learns the file was away. Generous timeout, because
277
- // an offloaded multi-GB session fetches from the bucket; the command itself
278
- // never exits nonzero, so a slow restore is the worst case, not a broken one.
279
- const rehydrate = `${executable} hook rehydrate --agent ${agent} --data-dir ${dataDir}`;
280
- const rehydrateEntry = [{ matcher: "resume", hooks: [{ type: "command", command: rehydrate, timeout: 300 }] }];
569
+ // Heavy recall, hosted reconciliation and publishing never sit in front of
570
+ // the coding agent. An async producer seals context locally; the next safe
571
+ // boundary consumes that small local cache. This intentionally makes fresh
572
+ // information one boundary eventual instead of making every prompt wait for
573
+ // storage/network latency.
574
+ const context = `${executable} hook context --agent ${agent} --data-dir ${shellArgument(dataDir)} --lifecycle ${HOOK_LIFECYCLE_VERSION}`;
575
+ const contextSync = `${executable} hook context-sync --agent ${agent} --data-dir ${shellArgument(dataDir)} --lifecycle ${HOOK_LIFECYCLE_VERSION}`;
576
+ const contextPromptHook = {
577
+ type: "command", command: context, timeout: 2,
578
+ ...(agent === "codex" ? { additionalContextLimit: 4000 } : {})
579
+ };
580
+ const contextLiveHook = {
581
+ type: "command", command: context, timeout: 2,
582
+ ...(agent === "codex" ? { additionalContextLimit: 2000 } : {})
583
+ };
584
+ const contextStart = [{
585
+ matcher: agent === "codex" ? "startup|resume|clear|compact" : "startup|resume|clear",
586
+ hooks: [{ type: "command", command: contextSync, timeout: 30, async: true }]
587
+ }];
588
+ const contextPrompt = [{ hooks: [
589
+ contextPromptHook,
590
+ { type: "command", command: contextSync, timeout: 30, async: true },
591
+ ] }];
592
+ const contextLive = [{
593
+ // Claude reports Bash/Edit/Write; Codex reports shell/shell_command or
594
+ // apply_patch depending on the client build. One config serves both.
595
+ matcher: agent === "codex" ? "shell|shell_command|exec_command|apply_patch" : "Bash|Edit|Write",
596
+ hooks: [
597
+ contextLiveHook,
598
+ { type: "command", command: contextSync, timeout: 30, async: true },
599
+ ]
600
+ }];
281
601
  if (agent === "codex")
282
602
  return {
283
- description: "Sealkeep records an archive intent locally, and puts archived transcripts back before a resume reads them. It holds no secret; rehydration uses this machine's keystore.",
603
+ description: "Sealkeep automatically recalls project memory, exchanges live team updates, and records archive intent locally. A reclaimed session stays as a lightweight native pointer and full transcript restore is explicit. Hooks use this machine's keystore and never embed a recovery phrase.",
284
604
  hooks: {
285
605
  SessionEnd: [{ hooks: [{ type: "command", command, timeout: 3, statusMessage: "Queuing encrypted archive" }] }],
286
606
  PostCompact: entry(3),
287
- SessionStart: rehydrateEntry
607
+ SessionStart: contextStart,
608
+ UserPromptSubmit: contextPrompt,
609
+ PostToolUse: contextLive
288
610
  },
289
- _vaultlineNote: "Run `sealkeep queue run` (with SEALKEEP_RECOVERY_PHRASE set) to encrypt queued sessions. If this Codex version has no SessionStart hook, the entry is ignored; `sealkeep recover <session-file>` is the manual door."
611
+ _sealkeepNote: "Codex reviews non-managed hooks once. After trust, project memory and teammate updates are injected automatically; the background Sealkeep service encrypts queued sessions."
290
612
  };
291
613
  return {
292
- hooks: { SessionEnd: entry(5), SessionStart: rehydrateEntry },
293
- _vaultlineNote: "Merge the hooks object into ~/.claude/settings.json. The hook stores no secret; run `sealkeep queue run` with SEALKEEP_RECOVERY_PHRASE to encrypt queued sessions. SessionStart(resume) puts archived transcripts back before the resume reads them."
614
+ hooks: {
615
+ SessionEnd: entry(5),
616
+ SessionStart: contextStart,
617
+ UserPromptSubmit: contextPrompt,
618
+ PostToolUse: contextLive
619
+ },
620
+ _sealkeepNote: "After installation, project memory and teammate updates are injected automatically; the background Sealkeep service encrypts queued sessions. Reclaimed transcripts remain lightweight native pointers; restoring their full bytes is an explicit action."
294
621
  };
295
622
  }
296
623
  /**
@@ -333,9 +660,365 @@ async function hookFileText(path) {
333
660
  return null;
334
661
  }
335
662
  }
663
+ /** Every command nested under an agent hook event, without assuming its grouping shape. */
664
+ function commandsIn(value) {
665
+ if (!value || typeof value !== "object")
666
+ return [];
667
+ if (Array.isArray(value))
668
+ return value.flatMap(commandsIn);
669
+ const row = value;
670
+ return [...(typeof row.command === "string" ? [row.command] : []), ...Object.values(row).flatMap(commandsIn)];
671
+ }
672
+ function lifecycleIdentity(command) {
673
+ const match = /\bhook (enqueue|rehydrate|context|context-sync) --agent (codex|claude)\b/.exec(command);
674
+ return match ? `${match[1]}:${match[2]}` : null;
675
+ }
676
+ /** Read the one data-directory argument from an owned hook command. This is
677
+ * intentionally narrower than a shell parser: generated single-quoted values
678
+ * and historical double-quoted/unquoted values are understood, while missing
679
+ * or repeated routing is not ownership proof for an automatic rewrite. */
680
+ function hookCommandDataDirs(command) {
681
+ const values = [];
682
+ const argument = /(?:^|\s)["']?--data-dir["']?(?:=|\s+)(?:'((?:[^']|'"'"')*)'|"((?:\\.|[^"\\])*)"|([^\s<]+))/gi;
683
+ for (const match of command.matchAll(argument)) {
684
+ if (match[1] !== undefined)
685
+ values.push(match[1].replaceAll(`'"'"'`, "'"));
686
+ else if (match[2] !== undefined) {
687
+ try {
688
+ const decoded = JSON.parse(`"${match[2]}"`);
689
+ values.push(typeof decoded === "string" ? decoded : match[2]);
690
+ }
691
+ catch {
692
+ values.push(match[2]);
693
+ }
694
+ }
695
+ else if (match[3] !== undefined)
696
+ values.push(match[3]);
697
+ }
698
+ return values;
699
+ }
700
+ function absoluteHookDataDir(value, home) {
701
+ const expanded = value === "~"
702
+ ? home
703
+ : value.startsWith("~/") || value.startsWith("~\\")
704
+ ? join(home, value.slice(2))
705
+ : value;
706
+ return expanded && !expanded.includes("\0") && isAbsolute(expanded) ? resolve(expanded) : null;
707
+ }
708
+ async function sameHookDataDir(left, right) {
709
+ const [canonicalLeft, canonicalRight] = await Promise.all([
710
+ realpath(left).catch(() => left),
711
+ realpath(right).catch(() => right),
712
+ ]);
713
+ return canonicalLeft === canonicalRight;
714
+ }
715
+ /** A daemon may refresh only its own prior hook routing. Presence of a generic
716
+ * Sealkeep marker is not consent for a different vault to take the hook over. */
717
+ /**
718
+ * The single data directory this agent's owned lifecycle commands route to.
719
+ * Null when there are no owned commands, when any command carries zero or
720
+ * several `--data-dir` values, or when the commands disagree — the same
721
+ * strictness as ownership proof, because a rewrite decision hangs on it.
722
+ */
723
+ function ownedHooksDataDir(contents, agent, home) {
724
+ let document;
725
+ try {
726
+ const parsed = JSON.parse(contents);
727
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
728
+ return null;
729
+ document = parsed;
730
+ }
731
+ catch {
732
+ return null;
733
+ }
734
+ const commands = commandsIn(document.hooks)
735
+ .filter((command) => lifecycleIdentity(command)?.endsWith(`:${agent}`));
736
+ if (commands.length === 0)
737
+ return null;
738
+ let agreed = null;
739
+ for (const command of commands) {
740
+ const values = hookCommandDataDirs(command);
741
+ if (values.length !== 1)
742
+ return null;
743
+ const routed = absoluteHookDataDir(values[0], home);
744
+ if (!routed)
745
+ return null;
746
+ if (agreed === null)
747
+ agreed = routed;
748
+ else if (agreed !== routed)
749
+ return null;
750
+ }
751
+ return agreed;
752
+ }
753
+ /** A directory Sealkeep has initialized as a vault, as opposed to a path a hook merely names. */
754
+ async function isInitializedVault(dataDir) {
755
+ return access(join(dataDir, "config.json")).then(() => true, () => false);
756
+ }
757
+ /**
758
+ * Whether a data directory is one this home would call its own: the
759
+ * environment-configured vault, or the default `~/.sealkeep` (and its legacy
760
+ * name). Anything else running a daemon — a test fixture, a drill vault — has
761
+ * no standing over the person's real agent hooks.
762
+ */
763
+ async function isHomesOwnVault(dataDir, home) {
764
+ const configured = process.env.SEALKEEP_DATA_DIR ?? process.env.VAULTLINE_DATA_DIR;
765
+ const candidates = [configured, join(home, ".sealkeep"), join(home, ".vaultline")]
766
+ .filter((candidate) => typeof candidate === "string" && candidate.length > 0);
767
+ for (const candidate of candidates) {
768
+ if (resolve(candidate) === resolve(dataDir))
769
+ return true;
770
+ if (await sameHookDataDir(resolve(candidate), resolve(dataDir)).catch(() => false))
771
+ return true;
772
+ }
773
+ return false;
774
+ }
775
+ async function ownedHooksTargetDataDir(contents, agent, dataDir, home) {
776
+ let document;
777
+ try {
778
+ const parsed = JSON.parse(contents);
779
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
780
+ return false;
781
+ document = parsed;
782
+ }
783
+ catch {
784
+ return false;
785
+ }
786
+ const commands = commandsIn(document.hooks)
787
+ .filter((command) => lifecycleIdentity(command)?.endsWith(`:${agent}`));
788
+ const target = absoluteHookDataDir(dataDir, home);
789
+ if (!target || commands.length === 0)
790
+ return false;
791
+ const ownership = await Promise.all(commands.map(async (command) => {
792
+ const values = hookCommandDataDirs(command);
793
+ if (values.length !== 1)
794
+ return false;
795
+ const existing = absoluteHookDataDir(values[0], home);
796
+ return existing ? sameHookDataDir(existing, target) : false;
797
+ }));
798
+ return ownership.every(Boolean);
799
+ }
800
+ /** Remove only owned command handlers, retaining their group's custom siblings. */
801
+ function withoutLifecycleCommands(value, identities) {
802
+ if (Array.isArray(value)) {
803
+ const kept = value.map((item) => withoutLifecycleCommands(item, identities)).filter((item) => item !== null);
804
+ return kept.length ? kept : null;
805
+ }
806
+ if (!value || typeof value !== "object")
807
+ return value;
808
+ const row = value;
809
+ if (typeof row.command === "string") {
810
+ const identity = lifecycleIdentity(row.command);
811
+ if (identity && identities.has(identity))
812
+ return null;
813
+ }
814
+ const next = { ...row };
815
+ if (Object.prototype.hasOwnProperty.call(row, "hooks")) {
816
+ const hooks = withoutLifecycleCommands(row.hooks, identities);
817
+ if (hooks === null)
818
+ return null;
819
+ next.hooks = hooks;
820
+ }
821
+ return next;
822
+ }
823
+ function lifecycleInstalled(agent, contents) {
824
+ if (!contents)
825
+ return false;
826
+ try {
827
+ const document = JSON.parse(contents);
828
+ const owned = commandsIn(document.hooks).filter((command) => lifecycleIdentity(command)?.endsWith(`:${agent}`));
829
+ if (owned.some((command) => !command.includes(`--lifecycle ${HOOK_LIFECYCLE_VERSION}`)))
830
+ return false;
831
+ const actionCount = (action) => owned.filter((command) => lifecycleIdentity(command) === `${action}:${agent}`).length;
832
+ if (actionCount("enqueue") !== (agent === "codex" ? 2 : 1)
833
+ || actionCount("rehydrate") !== 0
834
+ || actionCount("context") !== 2
835
+ || actionCount("context-sync") !== 3)
836
+ return false;
837
+ const eventHas = (event, action) => commandsIn(document.hooks?.[event]).some((command) => command.includes(`hook ${action} --agent ${agent}`)
838
+ && command.includes(`--lifecycle ${HOOK_LIFECYCLE_VERSION}`));
839
+ return eventHas("SessionEnd", "enqueue")
840
+ && eventHas("UserPromptSubmit", "context")
841
+ && eventHas("PostToolUse", "context")
842
+ && eventHas("SessionStart", "context-sync")
843
+ && eventHas("UserPromptSubmit", "context-sync")
844
+ && eventHas("PostToolUse", "context-sync")
845
+ && (agent !== "codex" || eventHas("PostCompact", "enqueue"));
846
+ }
847
+ catch {
848
+ return false;
849
+ }
850
+ }
851
+ /** Decode the two TOML string forms Codex writes around a hook-state key. */
852
+ function tomlStateKey(value) {
853
+ const trimmed = value.trim();
854
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
855
+ try {
856
+ return JSON.parse(trimmed);
857
+ }
858
+ catch {
859
+ return null;
860
+ }
861
+ }
862
+ if (trimmed.startsWith("'") && trimmed.endsWith("'"))
863
+ return trimmed.slice(1, -1);
864
+ return null;
865
+ }
866
+ /**
867
+ * Read only Codex's persisted per-hook consent state.
868
+ *
869
+ * This deliberately is not a general TOML parser or writer. Codex owns this
870
+ * file; Sealkeep recognises the exact `[hooks.state."..."]` tables Codex
871
+ * itself writes and never mutates one. Unknown TOML remains untouched and is
872
+ * treated as no proof of trust, which is the safe direction for a status
873
+ * report.
874
+ */
875
+ function codexHookStates(contents) {
876
+ const states = new Map();
877
+ if (!contents)
878
+ return states;
879
+ let current = null;
880
+ for (const line of contents.split(/\r?\n/)) {
881
+ const section = /^\s*\[hooks\.state\.(.+)\]\s*(?:#.*)?$/.exec(line);
882
+ if (section) {
883
+ current = tomlStateKey(section[1]);
884
+ if (current && !states.has(current))
885
+ states.set(current, {});
886
+ continue;
887
+ }
888
+ if (/^\s*\[/.test(line)) {
889
+ current = null;
890
+ continue;
891
+ }
892
+ if (!current)
893
+ continue;
894
+ const enabled = /^\s*enabled\s*=\s*(true|false)\s*(?:#.*)?$/.exec(line);
895
+ if (enabled) {
896
+ states.get(current).enabled = enabled[1] === "true";
897
+ continue;
898
+ }
899
+ const trusted = /^\s*trusted_hash\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*(?:#.*)?$/.exec(line);
900
+ if (trusted) {
901
+ const value = tomlStateKey(trusted[1]);
902
+ if (value !== null)
903
+ states.get(current).trustedHash = value;
904
+ }
905
+ }
906
+ return states;
907
+ }
908
+ function hookEventKey(event) {
909
+ return event.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
910
+ }
911
+ function canonicalJson(value) {
912
+ if (Array.isArray(value))
913
+ return value.map(canonicalJson);
914
+ if (!value || typeof value !== "object")
915
+ return value;
916
+ return Object.fromEntries(Object.keys(value).sort()
917
+ .map((key) => [key, canonicalJson(value[key])]));
918
+ }
919
+ /**
920
+ * Codex trusts a normalized hook identity, not the source JSON bytes. This is
921
+ * the same identity documented by Codex's open-source hook discovery code:
922
+ * event + matcher group + one normalized handler, recursively key-sorted and
923
+ * SHA-256 hashed. Keeping this exact lets us distinguish trusted from merely
924
+ * present without asking Codex to execute anything or writing its consent.
925
+ */
926
+ function codexHookHash(event, group, handler) {
927
+ const sessionEndTimeout = event === "SessionEnd" || event === "Interrupt";
928
+ const rawTimeout = typeof handler.timeout === "number" ? handler.timeout : (sessionEndTimeout ? 1 : 600);
929
+ const normalized = {
930
+ type: "command",
931
+ command: process.platform === "win32" && typeof handler.commandWindows === "string"
932
+ ? handler.commandWindows
933
+ : handler.command,
934
+ timeout: sessionEndTimeout ? Math.min(3, Math.max(1, rawTimeout)) : Math.max(1, rawTimeout),
935
+ async: handler.async === true,
936
+ };
937
+ if (typeof handler.statusMessage === "string")
938
+ normalized.statusMessage = handler.statusMessage;
939
+ const contextEvents = new Set(["PreToolUse", "PostToolUse", "SessionStart", "UserPromptSubmit", "SubagentStart"]);
940
+ if (contextEvents.has(event) && typeof handler.additionalContextLimit === "number" && handler.additionalContextLimit !== 2500) {
941
+ normalized.additionalContextLimit = handler.additionalContextLimit;
942
+ }
943
+ const identity = { event_name: hookEventKey(event), hooks: [normalized] };
944
+ const matcherEvents = new Set(["PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact", "SessionStart", "SessionEnd", "SubagentStart", "SubagentStop"]);
945
+ if (matcherEvents.has(event) && typeof group.matcher === "string")
946
+ identity.matcher = group.matcher;
947
+ return `sha256:${createHash("sha256").update(JSON.stringify(canonicalJson(identity))).digest("hex")}`;
948
+ }
949
+ /** The exact current Sealkeep handlers Codex will gate through `/hooks`. */
950
+ function codexLifecycleIssues(hookFile, hookContents, configContents) {
951
+ if (!hookContents)
952
+ return [];
953
+ let document;
954
+ try {
955
+ document = JSON.parse(hookContents);
956
+ }
957
+ catch {
958
+ return [];
959
+ }
960
+ const states = codexHookStates(configContents);
961
+ const issues = [];
962
+ for (const [event, value] of Object.entries(document.hooks ?? {})) {
963
+ if (!Array.isArray(value))
964
+ continue;
965
+ for (const [groupIndex, rawGroup] of value.entries()) {
966
+ if (!rawGroup || typeof rawGroup !== "object" || Array.isArray(rawGroup))
967
+ continue;
968
+ const group = rawGroup;
969
+ const handlers = Array.isArray(group.hooks) ? group.hooks : [];
970
+ for (const [handlerIndex, rawHandler] of handlers.entries()) {
971
+ if (!rawHandler || typeof rawHandler !== "object" || Array.isArray(rawHandler))
972
+ continue;
973
+ const handler = rawHandler;
974
+ if (handler.type !== "command" || typeof handler.command !== "string")
975
+ continue;
976
+ const identity = lifecycleIdentity(handler.command);
977
+ if (!identity?.endsWith(":codex"))
978
+ continue;
979
+ const action = identity.slice(0, identity.indexOf(":"));
980
+ const key = `${hookFile}:${hookEventKey(event)}:${groupIndex}:${handlerIndex}`;
981
+ const state = states.get(key);
982
+ const currentHash = codexHookHash(event, group, handler);
983
+ const reason = state?.enabled === false
984
+ ? "disabled"
985
+ : !state?.trustedHash
986
+ ? "untrusted"
987
+ : state.trustedHash !== currentHash
988
+ ? "modified"
989
+ : null;
990
+ if (reason)
991
+ issues.push({ event, action, reason });
992
+ }
993
+ }
994
+ }
995
+ return issues;
996
+ }
997
+ function hookIssueNote(issues) {
998
+ if (issues.length === 0)
999
+ return null;
1000
+ const named = (items) => [...new Set(items.map((issue) => issue.event))].join(", ");
1001
+ const disabled = issues.filter((issue) => issue.reason === "disabled");
1002
+ if (disabled.length > 0) {
1003
+ const review = issues.filter((issue) => issue.reason !== "disabled");
1004
+ const also = review.length > 0
1005
+ ? ` Codex is also withholding ${named(review)} until ${review.some((issue) => issue.reason === "modified") ? "the changed definitions are reviewed again" : "their one-time review"}.`
1006
+ : "";
1007
+ return `Codex has disabled Sealkeep's ${named(disabled)} hook${disabled.length === 1 ? "" : "s"}.${also} The background watcher still preserves session files automatically. Open /hooks in Codex to review or re-enable these lifecycle actions; Sealkeep will not change your choice.`;
1008
+ }
1009
+ const changed = issues.filter((issue) => issue.reason === "modified");
1010
+ const label = changed.length > 0 ? "changed and needs another review" : "is waiting for its one-time review";
1011
+ return `Sealkeep's Codex lifecycle ${label} (${named(issues)}). Until it is trusted in Codex /hooks, the background watcher still preserves session files, but these recall and live-context triggers do not run.`;
1012
+ }
336
1013
  function statusFor(detection) {
337
1014
  if (!detection.detected)
338
1015
  return { status: "not-installed", note: `No ${detection.root} on this machine.` };
1016
+ if (detection.hooksInstalled && detection.hookIssues.some((issue) => issue.reason === "disabled")) {
1017
+ return { status: "hooked-but-disabled", note: hookIssueNote(detection.hookIssues) };
1018
+ }
1019
+ if (detection.hooksInstalled && !detection.hooksActive) {
1020
+ return { status: "hooked-awaiting-review", note: hookIssueNote(detection.hookIssues) };
1021
+ }
339
1022
  if (detection.hooksInstalled)
340
1023
  return { status: "hooked", note: null };
341
1024
  if (!detection.hookFileWritable)
@@ -361,8 +1044,9 @@ function statusFor(detection) {
361
1044
  * Unlike `detectAgents`, which answers the archiving question and so lists only
362
1045
  * the agents with a session adapter, this answers the setup question: what is
363
1046
  * here, where its hooks live, whether ours are already among them, and whether
364
- * we could write there at all. It reads no transcript and no config value —
365
- * only whether one string appears in a config file.
1047
+ * we could write there at all. For Codex it also reads the non-secret hook
1048
+ * consent state that Codex itself persists, so a present-but-disabled command
1049
+ * is never presented as active automation.
366
1050
  */
367
1051
  /** Total size and count of one agent's transcripts. Reads sizes, never contents. */
368
1052
  async function measureSessions(roots) {
@@ -371,12 +1055,15 @@ async function measureSessions(roots) {
371
1055
  await walkJsonl(root, "codex", found);
372
1056
  return { sessionBytes: found.reduce((total, item) => total + item.bytes, 0), sessionCount: found.length };
373
1057
  }
374
- export async function detectSetupAgents(home = process.env.HOME ?? ".") {
1058
+ export async function detectSetupAgents(home = process.env.HOME ?? ".", dataDir) {
375
1059
  return Promise.all(SETUP_AGENT_IDS.map(async (agent) => {
376
1060
  const definition = AGENTS[agent];
377
1061
  const root = definition.root(home);
378
1062
  const hookFile = definition.hookFile(home);
379
- const [detected, contents, writable] = await Promise.all([directoryExists(root), hookFileText(hookFile), hookFileWritable(hookFile)]);
1063
+ const [detected, contents, writable, codexConfig] = await Promise.all([
1064
+ directoryExists(root), hookFileText(hookFile), hookFileWritable(hookFile),
1065
+ agent === "codex" ? hookFileText(join(home, ".codex", "config.toml")) : Promise.resolve(null)
1066
+ ]);
380
1067
  // What this agent is costing in disk right now. It is the reason anyone
381
1068
  // installs Sealkeep, and until it is on screen the problem is invisible —
382
1069
  // sessions accumulate in a directory nobody opens. Metadata only, one stat
@@ -385,12 +1072,31 @@ export async function detectSetupAgents(home = process.env.HOME ?? ".") {
385
1072
  const sessions = detected && definition.archivable
386
1073
  ? await measureSessions(definition.transcriptRoots(home))
387
1074
  : { sessionBytes: 0, sessionCount: 0 };
1075
+ const hooksInstalled = isArchivingAgent(agent) && lifecycleInstalled(agent, contents);
1076
+ const hookIssues = hooksInstalled && agent === "codex"
1077
+ ? codexLifecycleIssues(hookFile, contents, codexConfig)
1078
+ : [];
1079
+ // Presence is not routing. A complete lifecycle that feeds some other data
1080
+ // directory looks installed and active while doing nothing for this vault,
1081
+ // so the comparison is made explicitly — but only against a directory the
1082
+ // caller actually asked about; with none there is nothing to be wrong about.
1083
+ const hooksDataDir = contents && isArchivingAgent(agent) ? ownedHooksDataDir(contents, agent, home) : null;
1084
+ const hooksAimedElsewhere = hooksInstalled && dataDir !== undefined && contents !== null
1085
+ && !(await ownedHooksTargetDataDir(contents, agent, dataDir, home));
388
1086
  const base = {
389
1087
  agent, label: definition.label, detected, root, hookFile,
390
1088
  hookFileFormat: definition.hookFileFormat,
391
1089
  hookFileExists: contents !== null,
392
1090
  hookFileWritable: writable,
393
- hooksInstalled: contents !== null && contents.includes(hookMarker(agent)),
1091
+ // "Installed" means this build's complete lifecycle, not merely the
1092
+ // archive hook an older Sealkeep left behind. Otherwise existing users
1093
+ // would be reported hooked and never receive automatic recall/team
1094
+ // context on upgrade.
1095
+ hooksInstalled,
1096
+ hooksActive: hooksInstalled && hookIssues.length === 0,
1097
+ hooksDataDir,
1098
+ hooksAimedElsewhere,
1099
+ hookIssues,
394
1100
  archivable: definition.archivable,
395
1101
  ...sessions
396
1102
  };
@@ -399,7 +1105,7 @@ export async function detectSetupAgents(home = process.env.HOME ?? ".") {
399
1105
  }
400
1106
  /** Written beside the original before Sealkeep edits another program's config, so a bad merge is always undoable. */
401
1107
  async function backupHookFile(path, contents) {
402
- const backup = `${path}.vaultline-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
1108
+ const backup = `${path}.sealkeep-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
403
1109
  await writeFile(backup, contents, { mode: 0o600 });
404
1110
  return backup;
405
1111
  }
@@ -419,7 +1125,14 @@ function mergeHooks(existing, ours) {
419
1125
  if (!entries)
420
1126
  continue;
421
1127
  const current = Array.isArray(base[event]) ? [...base[event]] : [];
422
- base[event] = [...current, ...entries];
1128
+ const identities = new Set(commandsIn(entries).map(lifecycleIdentity).filter((identity) => identity !== null));
1129
+ const preserved = identities.size
1130
+ ? current.map((entry) => withoutLifecycleCommands(entry, identities)).filter((entry) => entry !== null)
1131
+ : current;
1132
+ // Re-append the current generated entries. Besides preventing duplicates,
1133
+ // this reconciles changed timeout/matcher/async fields from an older
1134
+ // lifecycle instead of treating a matching command string as current.
1135
+ base[event] = [...preserved, ...entries];
423
1136
  }
424
1137
  return base;
425
1138
  }
@@ -440,14 +1153,29 @@ async function writeJsonAtomic(path, value) {
440
1153
  */
441
1154
  export async function installAgentHooks(agents, options) {
442
1155
  const home = options.home ?? process.env.HOME ?? ".";
443
- const executable = options.executable ?? "vaultline";
444
- const detections = new Map((await detectSetupAgents(home)).map((detection) => [detection.agent, detection]));
1156
+ const executable = options.executable ?? "sealkeep";
1157
+ const detections = new Map((await detectSetupAgents(home, options.dataDir)).map((detection) => [detection.agent, detection]));
445
1158
  const outcomes = [];
446
1159
  for (const agent of agents) {
447
1160
  const detection = detections.get(agent);
448
- const base = { agent, hookFile: detection.hookFile, installed: false, alreadyInstalled: false, backupPath: null, fragmentPath: null, reason: null };
449
- if (detection.status === "hooked") {
450
- outcomes.push({ ...base, installed: true, alreadyInstalled: true });
1161
+ const base = {
1162
+ agent, hookFile: detection.hookFile, installed: false,
1163
+ alreadyInstalled: false, active: false, backupPath: null,
1164
+ fragmentPath: null, reason: null, attention: null
1165
+ };
1166
+ // Presence and execution are deliberately separate. In particular, never
1167
+ // overwrite Codex's `enabled = false` choice just because every Sealkeep
1168
+ // command is present; report the required /hooks action and leave consent
1169
+ // entirely with Codex and its user.
1170
+ // A complete lifecycle routed to a different vault is not "already
1171
+ // installed" for this one: it is the silent no-op an explicit install is
1172
+ // asked to fix. It falls through to the reconcile below, which replaces the
1173
+ // owned commands as one unit (never duplicating them) after a backup.
1174
+ if (detection.hooksInstalled && !detection.hooksAimedElsewhere) {
1175
+ outcomes.push({
1176
+ ...base, installed: true, alreadyInstalled: true,
1177
+ active: detection.hooksActive, attention: detection.note
1178
+ });
451
1179
  continue;
452
1180
  }
453
1181
  if (detection.status === "not-installed" || detection.status === "found-but-unwritable" || detection.status === "found-but-unsupported") {
@@ -483,10 +1211,127 @@ export async function installAgentHooks(agents, options) {
483
1211
  }
484
1212
  }
485
1213
  const document = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
486
- document.hooks = mergeHooks(document.hooks, hookConfig(agent, executable, options.dataDir).hooks);
1214
+ // Reconcile the lifecycle as one owned unit. A version can move an action
1215
+ // between events (v2 removed the blocking SessionStart context reader), so
1216
+ // per-event replacement would strand old commands in an event that no
1217
+ // longer has a same-named successor.
1218
+ const identities = new Set([`enqueue:${agent}`, `rehydrate:${agent}`, `context:${agent}`, `context-sync:${agent}`]);
1219
+ const existing = document.hooks && typeof document.hooks === "object" && !Array.isArray(document.hooks)
1220
+ ? { ...document.hooks }
1221
+ : {};
1222
+ for (const [event, entries] of Object.entries(existing)) {
1223
+ const kept = withoutLifecycleCommands(entries, identities);
1224
+ if (kept === null)
1225
+ delete existing[event];
1226
+ else
1227
+ existing[event] = kept;
1228
+ }
1229
+ document.hooks = mergeHooks(existing, hookConfig(agent, executable, options.dataDir).hooks);
487
1230
  const backupPath = raw && raw.trim() ? await backupHookFile(detection.hookFile, raw) : null;
488
1231
  await writeJsonAtomic(detection.hookFile, document);
489
- outcomes.push({ ...base, installed: true, backupPath });
1232
+ // A newly written Codex hook is intentionally pending its one-time review.
1233
+ // Read the resulting state rather than claiming that writing implies
1234
+ // execution. Claude's local hook file has no equivalent trust gate.
1235
+ const issues = agent === "codex"
1236
+ ? codexLifecycleIssues(detection.hookFile, JSON.stringify(document), await hookFileText(join(home, ".codex", "config.toml")))
1237
+ : [];
1238
+ outcomes.push({
1239
+ ...base, installed: true, active: issues.length === 0,
1240
+ attention: hookIssueNote(issues), backupPath
1241
+ });
1242
+ }
1243
+ return outcomes;
1244
+ }
1245
+ /**
1246
+ * Upgrades only hook files that already contain a Sealkeep archive hook.
1247
+ *
1248
+ * This is the package-upgrade path: an earlier explicit install is continuing
1249
+ * to consent to Sealkeep-owned lifecycle entries, but a machine that never
1250
+ * installed them is left completely untouched. The daemon calls this once at
1251
+ * startup, so upgrading the package does not require revisiting setup.
1252
+ */
1253
+ export async function upgradeOwnedAgentHooks(options) {
1254
+ const home = options.home ?? process.env.HOME ?? ".";
1255
+ // A daemon whose vault is not this home's own vault — a test fixture, a drill,
1256
+ // a rehearsal — has no standing to rewrite the person's real agent hooks,
1257
+ // whatever it finds there. Only an explicitly provided home opts in; that is
1258
+ // how an isolated harness says "these hooks are mine to manage". Without this,
1259
+ // every test that started a daemon re-pointed the developer's real hooks at a
1260
+ // temporary vault, and the fail-open hook contract hid it completely.
1261
+ if (options.home === undefined && !(await isHomesOwnVault(options.dataDir, home)))
1262
+ return [];
1263
+ const detections = await detectSetupAgents(home, options.dataDir);
1264
+ const owned = [];
1265
+ for (const detection of detections) {
1266
+ if (!isArchivingAgent(detection.agent))
1267
+ continue;
1268
+ if (detection.hooksInstalled) {
1269
+ // Complete and routed here: nothing to do. Complete but routed to a vault
1270
+ // that does not exist any more (a deleted fixture, a moved directory): the
1271
+ // hook is orphaned, injects and captures nothing, and nothing else will
1272
+ // ever say so — re-target it to the daemon that is actually running.
1273
+ // Complete but routed to another LIVE vault: that may be deliberate, so
1274
+ // doctor reports it and the person decides.
1275
+ if (detection.hooksAimedElsewhere && detection.hooksDataDir && !(await isInitializedVault(detection.hooksDataDir))) {
1276
+ owned.push(detection.agent);
1277
+ }
1278
+ continue;
1279
+ }
1280
+ const contents = await hookFileText(detection.hookFile);
1281
+ if (contents && await ownedHooksTargetDataDir(contents, detection.agent, options.dataDir, home)) {
1282
+ owned.push(detection.agent);
1283
+ }
1284
+ }
1285
+ return owned.length ? installAgentHooks(owned, options) : [];
1286
+ }
1287
+ /**
1288
+ * Removes Sealkeep-owned lifecycle handlers and nothing else.
1289
+ *
1290
+ * This is intentionally structural rather than a text replacement: if a user
1291
+ * placed their own handler beside Sealkeep's in one matcher group, their
1292
+ * handler and matcher survive. The original file is backed up before a write.
1293
+ */
1294
+ export async function removeOwnedAgentHooks(options) {
1295
+ const home = options.home ?? process.env.HOME ?? ".";
1296
+ const outcomes = [];
1297
+ for (const agent of ARCHIVING_AGENT_IDS) {
1298
+ const path = AGENTS[agent].hookFile(home);
1299
+ const raw = await hookFileText(path);
1300
+ const base = { agent, hookFile: path, removed: false, backupPath: null, reason: null };
1301
+ if (!raw) {
1302
+ outcomes.push(base);
1303
+ continue;
1304
+ }
1305
+ let document;
1306
+ try {
1307
+ const parsed = JSON.parse(raw);
1308
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1309
+ throw new Error("not an object");
1310
+ document = { ...parsed };
1311
+ }
1312
+ catch {
1313
+ outcomes.push({ ...base, reason: `${path} is not valid JSON, so Sealkeep will not rewrite it.` });
1314
+ continue;
1315
+ }
1316
+ if (!commandsIn(document).some((command) => lifecycleIdentity(command)?.endsWith(`:${agent}`))) {
1317
+ outcomes.push(base);
1318
+ continue;
1319
+ }
1320
+ const identities = new Set([`enqueue:${agent}`, `rehydrate:${agent}`, `context:${agent}`, `context-sync:${agent}`]);
1321
+ const hooks = document.hooks && typeof document.hooks === "object" && !Array.isArray(document.hooks)
1322
+ ? { ...document.hooks }
1323
+ : {};
1324
+ for (const [event, entries] of Object.entries(hooks)) {
1325
+ const kept = withoutLifecycleCommands(entries, identities);
1326
+ if (kept === null)
1327
+ delete hooks[event];
1328
+ else
1329
+ hooks[event] = kept;
1330
+ }
1331
+ document.hooks = hooks;
1332
+ const backupPath = await backupHookFile(path, raw);
1333
+ await writeJsonAtomic(path, document);
1334
+ outcomes.push({ ...base, removed: true, backupPath });
490
1335
  }
491
1336
  return outcomes;
492
1337
  }
@@ -501,6 +1346,18 @@ function firstString(payload, keys) {
501
1346
  }
502
1347
  return undefined;
503
1348
  }
1349
+ /** The lifecycle event a hook payload names; undefined when it names none or is not JSON. */
1350
+ export function hookEventNameOf(stdin) {
1351
+ try {
1352
+ const payload = JSON.parse(stdin);
1353
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
1354
+ return undefined;
1355
+ return firstString(payload, EVENT_KEYS);
1356
+ }
1357
+ catch {
1358
+ return undefined;
1359
+ }
1360
+ }
504
1361
  /**
505
1362
  * Accepts Codex `SessionEnd`/`PostCompact` and Claude `SessionEnd` payloads. Unknown
506
1363
  * vendor fields are ignored rather than parsed, so a schema change cannot break preservation.
@@ -531,3 +1388,34 @@ export async function hookEventFromStdin(agent, stdin) {
531
1388
  export async function transcriptPathFromHook(stdin, agent = "codex") {
532
1389
  return (await hookEventFromStdin(agent, stdin)).sourcePath;
533
1390
  }
1391
+ /**
1392
+ * The project an archive belongs to: what the seal recorded, or failing that
1393
+ * what its path implies.
1394
+ *
1395
+ * Deriving it from the path is guesswork that only works inside an agent's own
1396
+ * directories — a rehearsal of a company setup archived six sessions into a
1397
+ * shared project and every one of them came back "no project", so membership
1398
+ * could never bind and `rewrap --project` re-keyed nothing while reporting
1399
+ * that the archives "belong to another project". Records written since carry
1400
+ * the answer; older ones still fall back to the path.
1401
+ */
1402
+ export async function projectIdentityOfRecord(record) {
1403
+ if (record.source.projectKey) {
1404
+ return { project: record.source.project ?? null, projectKey: record.source.projectKey, projectPath: null };
1405
+ }
1406
+ try {
1407
+ const derived = record.source.agent === "claude"
1408
+ ? await claudeProjectFromTranscript(record.source.path, record.createdAt)
1409
+ : record.source.agent === "codex"
1410
+ ? await codexProjectFromRollout(record.source.path, record.createdAt)
1411
+ : null;
1412
+ if (derived)
1413
+ return { ...derived, project: record.source.project ?? derived.project };
1414
+ }
1415
+ catch { /* an unreadable project is not evidence of anything */ }
1416
+ return { project: record.source.project ?? null, projectKey: null, projectPath: null };
1417
+ }
1418
+ /** Backward-compatible display-label helper. Access-control callers use projectIdentityOfRecord. */
1419
+ export async function projectOfRecord(record) {
1420
+ return (await projectIdentityOfRecord(record)).project;
1421
+ }