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.
- package/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +252 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +53 -13
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +332 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5940 -297
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/dist/src/adapters.js
CHANGED
|
@@ -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
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
197
|
-
*
|
|
198
|
-
* first-line
|
|
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,
|
|
211
|
-
while (next <
|
|
212
|
-
const candidate =
|
|
213
|
-
Object.assign(candidate,
|
|
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 =
|
|
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
|
-
|
|
249
|
-
|
|
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 = "
|
|
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 = "
|
|
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
|
-
//
|
|
275
|
-
// the agent
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
const
|
|
280
|
-
const
|
|
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
|
|
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:
|
|
607
|
+
SessionStart: contextStart,
|
|
608
|
+
UserPromptSubmit: contextPrompt,
|
|
609
|
+
PostToolUse: contextLive
|
|
288
610
|
},
|
|
289
|
-
|
|
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: {
|
|
293
|
-
|
|
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.
|
|
365
|
-
*
|
|
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([
|
|
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
|
-
|
|
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}.
|
|
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
|
-
|
|
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 ?? "
|
|
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 = {
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|