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