sealkeep 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +223 -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 +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +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.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +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 +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/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,23 +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"],
|
|
206
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"],
|
|
207
219
|
["index build", "build the local content index that --content searches"],
|
|
220
|
+
["index migrate", "move the local index onto sealed, append-only segments"],
|
|
208
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"],
|
|
209
223
|
["storage targets", "several storages at once, with limits, pins, and priority"],
|
|
210
224
|
["mcp install", "let your agents search this history themselves"],
|
|
211
|
-
["
|
|
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"],
|
|
212
232
|
["recover <session-file>", "agent says a session file is missing? name it and it goes back where resume expects it"],
|
|
213
233
|
["tui", "the same view in the terminal"]
|
|
214
234
|
]),
|
|
215
235
|
heading("Storage and retention"),
|
|
216
236
|
keyValue([
|
|
217
|
-
["cloud login/status", "sign in
|
|
237
|
+
["cloud login/status", "sign in with --email and a --code from your account panel, or check usage"],
|
|
218
238
|
["cloud push <id>", "send one archive to managed storage"],
|
|
219
239
|
["cloud archives", "list what managed storage is holding"],
|
|
220
240
|
["cloud pull <ref> <dest>", "fetch one archive back out of managed storage"],
|
|
221
|
-
["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"],
|
|
222
242
|
["plans", "what the storage packages cost"],
|
|
223
243
|
["verify", "prove every stored archive can still be read back"],
|
|
224
244
|
["storage setup", "guided setup for S3, R2, GCS, or Google Drive"],
|
|
@@ -227,14 +247,17 @@ function usage() {
|
|
|
227
247
|
["storage credentials", "store provider keys in the OS keychain"],
|
|
228
248
|
["upload --all", "upload and verify pending archives"],
|
|
229
249
|
["retention policy", "decide when sources may be reclaimed"],
|
|
230
|
-
["retention apply", "dry run; add --confirm to
|
|
250
|
+
["retention apply", "dry run; add --confirm to permanently free verified local originals"],
|
|
231
251
|
["retention prune", "drop snapshots a newer archive already contains"],
|
|
232
|
-
["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"]
|
|
233
254
|
]),
|
|
234
255
|
heading("Keys and trust"),
|
|
235
256
|
keyValue([
|
|
236
257
|
["recovery seal", "store a sealed copy of your key with your account"],
|
|
237
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"],
|
|
238
261
|
["device keygen", "mint a device key"],
|
|
239
262
|
["recipients", "list, add, or remove who can open new archives"],
|
|
240
263
|
["rewrap", "apply the current recipient set to existing archives"],
|
|
@@ -253,12 +276,6 @@ function usage() {
|
|
|
253
276
|
}
|
|
254
277
|
async function main() {
|
|
255
278
|
const [command, ...args] = process.argv.slice(2);
|
|
256
|
-
// The product itself writes SEALKEEP_DATA_DIR into the agent configs that
|
|
257
|
-
// `mcp install` generates, and the MCP server honours it — so a person who
|
|
258
|
-
// exports it and then runs the CLI reasonably expects the same vault. It used
|
|
259
|
-
// to be ignored here, which meant the CLI quietly worked on a different vault
|
|
260
|
-
// than the agent did. An explicit --data-dir still wins over the variable.
|
|
261
|
-
const dataDir = take(args, "--data-dir", envVar("DATA_DIR") ?? defaultDataDir());
|
|
262
279
|
const json = args.includes("--json");
|
|
263
280
|
// --version is the first thing anyone types at an unfamiliar CLI, and it was
|
|
264
281
|
// answering "Unknown command". Read it from the manifest rather than hardcoding
|
|
@@ -290,9 +307,33 @@ async function main() {
|
|
|
290
307
|
print(usage());
|
|
291
308
|
return;
|
|
292
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();
|
|
293
334
|
/**
|
|
294
|
-
* A bare `
|
|
295
|
-
* `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
|
|
296
337
|
* wall of commands, so that case opens one.
|
|
297
338
|
*
|
|
298
339
|
* Three deliberate limits. An existing vault still prints help, because
|
|
@@ -329,15 +370,28 @@ async function main() {
|
|
|
329
370
|
const liveDetail = status.running && status.live.state === "never-started"
|
|
330
371
|
? "Installed. Waiting for its first check, which happens at login or within a minute."
|
|
331
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");
|
|
332
380
|
print(`\n${BRAND} ${status.running ? green("autopilot on") : dim("autopilot off")}\n`);
|
|
333
381
|
print(keyValue([
|
|
334
382
|
["Right now", `${status.running && status.live.state === "never-started" ? dim("waiting") : liveMark} ${dim(liveDetail)}`],
|
|
335
|
-
["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")],
|
|
336
|
-
["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],
|
|
337
388
|
["Unlocks itself", status.phraseAvailable ? `${mark.ok()} yes` : `${mark.warn()} no — sessions will queue but stay unsealed`],
|
|
338
389
|
["Policy", `${status.retention.policy} ${dim(`· older than ${status.retention.olderThanDays}d · ${status.retention.graceDays}d grace`)}`],
|
|
339
|
-
["
|
|
340
|
-
|
|
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`)}` : ""}`]
|
|
341
395
|
]));
|
|
342
396
|
print("");
|
|
343
397
|
return;
|
|
@@ -348,15 +402,32 @@ async function main() {
|
|
|
348
402
|
print(JSON.stringify(result, null, 2));
|
|
349
403
|
return;
|
|
350
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
|
+
}
|
|
351
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}`);
|
|
352
418
|
print(` ${dim("Every archive is still here. Nothing was deleted.")}\n`);
|
|
353
419
|
return;
|
|
354
420
|
}
|
|
355
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
|
+
}
|
|
356
427
|
const result = await enableAutopilot(dataDir, {
|
|
357
428
|
phrase: take(args, "--recovery-phrase"),
|
|
358
429
|
remember: !args.includes("--no-remember"),
|
|
359
|
-
reclaim:
|
|
430
|
+
reclaim: explicitReclaim ? true : explicitNoReclaim ? false : undefined,
|
|
360
431
|
olderThanDays: take(args, "--older-than-days") ? Number(take(args, "--older-than-days")) : undefined,
|
|
361
432
|
graceDays: take(args, "--grace-days") ? Number(take(args, "--grace-days")) : undefined,
|
|
362
433
|
home: take(args, "--home"),
|
|
@@ -374,19 +445,43 @@ async function main() {
|
|
|
374
445
|
print(heading(dryRun ? "What would happen" : "Autopilot is on"));
|
|
375
446
|
print(keyValue([
|
|
376
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")],
|
|
377
451
|
["Found", result.found.sessions ? `${result.found.sessions} sessions ${dim(`· ${bytes(result.found.bytes)}`)}` : dim("nothing yet")],
|
|
378
452
|
["Seals", result.remembered
|
|
379
453
|
? `automatically ${dim(`· phrase kept in ${result.remembered.backend}`)}`
|
|
380
454
|
: dryRun && !args.includes("--no-remember")
|
|
381
455
|
? `automatically ${dim("· the phrase would be kept in this machine's keystore")}`
|
|
382
456
|
: `${mark.warn()} needs SEALKEEP_RECOVERY_PHRASE`],
|
|
383
|
-
|
|
384
|
-
|
|
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")]
|
|
385
480
|
]));
|
|
386
481
|
for (const note of result.notes)
|
|
387
482
|
print(`\n ${mark.warn()} ${note}`);
|
|
388
483
|
if (!dryRun) {
|
|
389
|
-
print(`\n${
|
|
484
|
+
print(`\n${divider()}`);
|
|
390
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`);
|
|
391
486
|
}
|
|
392
487
|
else {
|
|
@@ -395,10 +490,11 @@ async function main() {
|
|
|
395
490
|
return;
|
|
396
491
|
}
|
|
397
492
|
if (command === "quickstart") {
|
|
398
|
-
const
|
|
399
|
-
const
|
|
493
|
+
const home = take(args, "--home");
|
|
494
|
+
const result = await setupVault(dataDir, take(args, "--recovery-phrase"), { home });
|
|
495
|
+
const installs = await detectAgents(home);
|
|
400
496
|
const detected = installs.filter((install) => install.detected);
|
|
401
|
-
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();
|
|
402
498
|
const totalBytes = found.reduce((total, candidate) => total + candidate.bytes, 0);
|
|
403
499
|
// What leaves this machine is decided here, before anything is queued — see sync-rules.ts.
|
|
404
500
|
const { rules, source } = await resolveSyncRules(dataDir);
|
|
@@ -433,10 +529,10 @@ async function main() {
|
|
|
433
529
|
print(heading("Next"));
|
|
434
530
|
print(steps([
|
|
435
531
|
`Encrypt what is queued: ${cmd(`SEALKEEP_RECOVERY_PHRASE="…" sealkeep queue run`)}`,
|
|
436
|
-
`Archive automatically: ${cmd("
|
|
532
|
+
`Archive automatically: ${cmd("sealkeep agents hook-config codex")} ${dim("(review, then merge)")}`,
|
|
437
533
|
`See it: ${cmd("sealkeep desktop")}`
|
|
438
534
|
]));
|
|
439
|
-
print(`\n${
|
|
535
|
+
print(`\n${divider()}\n${dim(" Everything below that line is ciphertext. Nothing above it leaves this machine.")}\n`);
|
|
440
536
|
return;
|
|
441
537
|
}
|
|
442
538
|
if (command === "start") {
|
|
@@ -486,7 +582,7 @@ async function main() {
|
|
|
486
582
|
mode: "free",
|
|
487
583
|
provider: take(args, "--provider", "s3"),
|
|
488
584
|
bucket: take(args, "--bucket") ?? fail("invalid_argument", "--bucket is required for the free tier"),
|
|
489
|
-
prefix: take(args, "--prefix",
|
|
585
|
+
prefix: take(args, "--prefix", STORAGE_PREFIX_DEFAULT),
|
|
490
586
|
region: take(args, "--region"), accountId: take(args, "--account-id"), project: take(args, "--project")
|
|
491
587
|
});
|
|
492
588
|
if (json) {
|
|
@@ -563,7 +659,7 @@ async function main() {
|
|
|
563
659
|
const cloud = await import("./cloud.js");
|
|
564
660
|
const [action] = positionals(args);
|
|
565
661
|
if (action === "login") {
|
|
566
|
-
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.");
|
|
567
663
|
// A one-time code from the panel is the only way in for an account that
|
|
568
664
|
// signed up with Google, and it works for password accounts too.
|
|
569
665
|
const code = take(args, "--code") ?? envVar("CLOUD_CODE");
|
|
@@ -605,18 +701,18 @@ async function main() {
|
|
|
605
701
|
if (!archiveId)
|
|
606
702
|
fail("invalid_argument", "Usage: sealkeep cloud push <archive-id>");
|
|
607
703
|
const result = await cloud.pushArchive(dataDir, archiveId).catch(async (error) => {
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
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)) {
|
|
614
710
|
const account = await cloud.accountStatus(dataDir).catch(() => null);
|
|
615
711
|
if (account) {
|
|
616
712
|
const { quotaExceededMessage } = await import("./packages.js");
|
|
617
713
|
// The shared message is written for a screen. This is a terminal, so
|
|
618
714
|
// it names the command that shows the packages.
|
|
619
|
-
fail("
|
|
715
|
+
fail("payment_required", `${quotaExceededMessage(account)} Run \`sealkeep plans\` to see them.`);
|
|
620
716
|
}
|
|
621
717
|
}
|
|
622
718
|
throw error;
|
|
@@ -656,14 +752,41 @@ async function main() {
|
|
|
656
752
|
if (!vaultRef)
|
|
657
753
|
fail("invalid_argument", "Usage: sealkeep cloud pull <vault-ref> <destination>");
|
|
658
754
|
const out = required(destination, "Destination is required");
|
|
659
|
-
|
|
660
|
-
|
|
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
|
+
}
|
|
661
783
|
if (json) {
|
|
662
|
-
print(JSON.stringify({ vaultRef, output: out, bytes:
|
|
784
|
+
print(JSON.stringify({ vaultRef, output: out, bytes: total, chunked: true, parts: written }, null, 2));
|
|
663
785
|
return;
|
|
664
786
|
}
|
|
665
|
-
print(`\n ${mark.ok()} Pulled ${bold(bytes(
|
|
666
|
-
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`));
|
|
667
790
|
return;
|
|
668
791
|
}
|
|
669
792
|
fail("invalid_argument", `Unknown cloud action: ${action}. Try login, logout, status, archives, push, or pull.`);
|
|
@@ -676,7 +799,7 @@ async function main() {
|
|
|
676
799
|
if (password !== undefined && (password.length < 8 || password.length > 1024)) {
|
|
677
800
|
fail("invalid_argument", "The vault password must be between 8 and 1024 characters.");
|
|
678
801
|
}
|
|
679
|
-
const result = await setupVault(dataDir, take(args, "--recovery-phrase"));
|
|
802
|
+
const result = await setupVault(dataDir, take(args, "--recovery-phrase"), { home: take(args, "--home") });
|
|
680
803
|
// Keep the phrase in this machine's keystore, the way the wizard does by
|
|
681
804
|
// default. Without it a vault made from the terminal looked finished and
|
|
682
805
|
// was not: the window's Archive button answered "this machine has no
|
|
@@ -710,13 +833,42 @@ async function main() {
|
|
|
710
833
|
}
|
|
711
834
|
}
|
|
712
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
|
+
}
|
|
713
858
|
if (json) {
|
|
714
|
-
print(JSON.stringify({ ...result, remembered, ...(password ? { passwordLock } : {}) }, null, 2));
|
|
859
|
+
print(JSON.stringify({ ...result, remembered, recoveryCodes, ...(recoveryKitWarning ? { recoveryKitWarning } : {}), ...(password ? { passwordLock } : {}) }, null, 2));
|
|
715
860
|
return;
|
|
716
861
|
}
|
|
717
862
|
print(`\n${BRAND}\n`);
|
|
718
863
|
if (result.recoveryPhrase) {
|
|
719
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`);
|
|
720
872
|
await pauseUntilWrittenDown();
|
|
721
873
|
print(remembered
|
|
722
874
|
? ` ${mark.ok()} This machine will unlock the vault on its own ${dim(`· phrase kept in ${remembered.backend}`)}`
|
|
@@ -755,6 +907,323 @@ async function main() {
|
|
|
755
907
|
print(`\n${BRAND}\n`, recoveryPhraseScreen(phrase), "");
|
|
756
908
|
return;
|
|
757
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
|
+
}
|
|
758
1227
|
if (command === "status") {
|
|
759
1228
|
const status = { ...(await vaultStatus(dataDir)), queue: await new ArchiveQueue(dataDir).stats() };
|
|
760
1229
|
if (json) {
|
|
@@ -776,9 +1245,9 @@ async function main() {
|
|
|
776
1245
|
const rows = [
|
|
777
1246
|
["Background", live.state === "running" ? `${green("running")} ${dim(live.detail.replace(/^Running\. /, ""))}` : live.state === "late" ? `${amber("late")} ${dim(live.detail)}` : dim(live.detail)],
|
|
778
1247
|
["Archives", `${bold(String(status.archiveCount))} ${dim(`· ${bytes(status.archivedBytes)} of history preserved`)}`],
|
|
779
|
-
["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` : ""}`],
|
|
780
1249
|
["Remote", status.remoteStorage
|
|
781
|
-
? `${status.remoteStorage.provider}
|
|
1250
|
+
? `${providerLabel(status.remoteStorage.provider)} ${dim(`· ${status.remoteStorage.bucket}/${status.remoteStorage.prefix}`)}`
|
|
782
1251
|
// A managed machine has no bucket of its own. Saying "none configured"
|
|
783
1252
|
// to someone whose archives are in our storage is the same falsehood
|
|
784
1253
|
// doctor was telling.
|
|
@@ -820,6 +1289,13 @@ async function main() {
|
|
|
820
1289
|
// to pass the flag. Demanding the flag unconditionally — which this line
|
|
821
1290
|
// used to do — made `archive` the one command a normal setup could not run.
|
|
822
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";
|
|
823
1299
|
if (args.includes("--stream")) {
|
|
824
1300
|
// Chunk → encrypt → send: the ciphertext never touches this disk. Needs
|
|
825
1301
|
// a configured own bucket; the seal is verified in the provider's own
|
|
@@ -845,19 +1321,10 @@ async function main() {
|
|
|
845
1321
|
// bucket self-recovering. --single-object keeps the old multipart path.
|
|
846
1322
|
if (!args.includes("--single-object")) {
|
|
847
1323
|
const { sealToChunkFolder } = await import("./chunk-store.js");
|
|
848
|
-
const agentName = take(args, "--agent", "custom") ?? "custom";
|
|
849
1324
|
const explicitTarget = take(args, "--target");
|
|
850
|
-
const {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
if (agentName === "claude")
|
|
854
|
-
return claudeProjectFromPath(source).project;
|
|
855
|
-
if (agentName === "codex")
|
|
856
|
-
return (await codexProjectFromRollout(source, new Date().toISOString())).project;
|
|
857
|
-
}
|
|
858
|
-
catch { /* no project is a folder called no-project, not an error */ }
|
|
859
|
-
return null;
|
|
860
|
-
})();
|
|
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;
|
|
861
1328
|
// Multi-target routing happens up here so a project pinned to Google
|
|
862
1329
|
// Drive takes the single-object road (Drive holds whole objects), and
|
|
863
1330
|
// everything else streams chunk folders to its routed target.
|
|
@@ -867,9 +1334,9 @@ async function main() {
|
|
|
867
1334
|
if (routed.provider === "gdrive") {
|
|
868
1335
|
const { archiveFile } = await import("./vault.js");
|
|
869
1336
|
const { uploadArchive } = await import("./upload.js");
|
|
870
|
-
const record = await archiveFile(dataDir, source, phraseForSeal, agentName, { project });
|
|
1337
|
+
const record = await archiveFile(dataDir, source, phraseForSeal, agentName, { project, projectKey, indexInline: true });
|
|
871
1338
|
const uploaded = await uploadArchive(dataDir, record.id, {
|
|
872
|
-
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 },
|
|
873
1340
|
targetId: routed.id
|
|
874
1341
|
});
|
|
875
1342
|
if (json) {
|
|
@@ -879,30 +1346,43 @@ async function main() {
|
|
|
879
1346
|
print(` ${mark.ok()} Archived ${bold(bytes(record.source.bytes))} to Google Drive ${dim(`(target "${routed.id}", single object)`)}`);
|
|
880
1347
|
return;
|
|
881
1348
|
}
|
|
882
|
-
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 });
|
|
883
1350
|
if (json) {
|
|
884
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));
|
|
885
1352
|
return;
|
|
886
1353
|
}
|
|
887
|
-
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)`)}`);
|
|
888
1355
|
if (outcome.reusedChunks > 0)
|
|
889
1356
|
print(` ${dim(`↻ resumed: ${outcome.reusedChunks} chunks were already in the bucket, verified instead of re-sent`)}`);
|
|
890
1357
|
print(` ${dim(`${bytes(outcome.storedBytes)} sealed and verified; this disk held at most ${bytes(outcome.heldAtMostBytes)} of it at any moment`)}`);
|
|
891
1358
|
return;
|
|
892
1359
|
}
|
|
893
|
-
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 });
|
|
894
1364
|
if (json) {
|
|
895
1365
|
print(JSON.stringify({ id: outcome.record.id, bytes: outcome.record.source.bytes, storedBytes: outcome.storedBytes, objectKey: outcome.objectKey, streamed: true }, null, 2));
|
|
896
1366
|
return;
|
|
897
1367
|
}
|
|
898
|
-
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}`)}`);
|
|
899
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`)}`);
|
|
900
1370
|
return;
|
|
901
1371
|
}
|
|
902
1372
|
// A project named here decides which members are wrapped in, so an
|
|
903
1373
|
// archive shared with a colleague is the one they were actually given.
|
|
904
|
-
|
|
905
|
-
|
|
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
|
+
});
|
|
906
1386
|
if (json) {
|
|
907
1387
|
print(JSON.stringify({ id: record.id, bytes: record.source.bytes, objectPath: record.objectPath, deduplicated: record.deduplicated }, null, 2));
|
|
908
1388
|
return;
|
|
@@ -910,6 +1390,20 @@ async function main() {
|
|
|
910
1390
|
print(record.deduplicated
|
|
911
1391
|
? ` ${mark.ok()} Already archived, unchanged since ${relativeTime(record.createdAt)} ${dim(`(${record.id})`)}`
|
|
912
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
|
+
}
|
|
913
1407
|
return;
|
|
914
1408
|
}
|
|
915
1409
|
/**
|
|
@@ -968,10 +1462,49 @@ async function main() {
|
|
|
968
1462
|
const limit = take(args, "--limit");
|
|
969
1463
|
if (!json)
|
|
970
1464
|
print(`\n${heading("Checking every archive is really there")}`);
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
+
}
|
|
975
1508
|
const broken = checks.filter((check) => !check.ok);
|
|
976
1509
|
if (json) {
|
|
977
1510
|
print(JSON.stringify({ checked: checks.length, broken: broken.length, checks }, null, 2));
|
|
@@ -980,8 +1513,11 @@ async function main() {
|
|
|
980
1513
|
print(dim(" Nothing stored yet.\n"));
|
|
981
1514
|
else if (broken.length)
|
|
982
1515
|
print(`\n ${mark.warn()} ${bold(String(broken.length))} of ${checks.length} could not be read back. Re-archive them.\n`);
|
|
983
|
-
else
|
|
984
|
-
|
|
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
|
+
}
|
|
985
1521
|
if (broken.length)
|
|
986
1522
|
process.exitCode = 1;
|
|
987
1523
|
return;
|
|
@@ -1039,28 +1575,287 @@ async function main() {
|
|
|
1039
1575
|
}
|
|
1040
1576
|
fail("invalid_argument", "Usage: sealkeep space members [project]");
|
|
1041
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
|
+
}
|
|
1042
1846
|
if (command === "projects") {
|
|
1043
1847
|
// Routing rules can pin a project to a destination, and the panel cannot
|
|
1044
1848
|
// offer a list: project names never reach the cloud — that is the point.
|
|
1045
1849
|
// So this machine, which does know them, prints the exact strings to paste.
|
|
1046
1850
|
const { listArchives } = await import("./vault.js");
|
|
1047
|
-
const {
|
|
1851
|
+
const { projectOfRecord } = await import("./adapters.js");
|
|
1048
1852
|
const records = await listArchives(dataDir);
|
|
1049
1853
|
const seen = new Map();
|
|
1050
1854
|
let unknown = 0;
|
|
1051
1855
|
let unknownBytes = 0;
|
|
1052
1856
|
for (const record of records) {
|
|
1053
1857
|
const agentName = record.source.agent;
|
|
1054
|
-
|
|
1055
|
-
try {
|
|
1056
|
-
if (agentName === "claude")
|
|
1057
|
-
project = claudeProjectFromPath(record.source.path).project;
|
|
1058
|
-
else if (agentName === "codex")
|
|
1059
|
-
project = (await codexProjectFromRollout(record.source.path, record.createdAt)).project;
|
|
1060
|
-
}
|
|
1061
|
-
catch {
|
|
1062
|
-
project = null;
|
|
1063
|
-
}
|
|
1858
|
+
const project = await projectOfRecord(record);
|
|
1064
1859
|
if (!project) {
|
|
1065
1860
|
unknown += 1;
|
|
1066
1861
|
unknownBytes += record.source.bytes ?? 0;
|
|
@@ -1095,16 +1890,26 @@ async function main() {
|
|
|
1095
1890
|
], "no project recorded yet"));
|
|
1096
1891
|
if (unknown > 0)
|
|
1097
1892
|
print(`\n ${dim(`${unknown} archive${unknown === 1 ? "" : "s"} recorded no project (${bytes(unknownBytes)}).`)}`);
|
|
1098
|
-
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`);
|
|
1099
1894
|
return;
|
|
1100
1895
|
}
|
|
1101
1896
|
if (command === "open") {
|
|
1102
1897
|
const [vaultRef, destination] = positionals(args);
|
|
1103
1898
|
if (!vaultRef || !destination)
|
|
1104
|
-
fail("invalid_argument", "Usage: sealkeep open <vault-ref> <destination> --recovery-phrase <phrase>");
|
|
1105
|
-
|
|
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");
|
|
1106
1911
|
const cloud = await import("./cloud.js");
|
|
1107
|
-
const outcome = await cloud.openFromCloud(dataDir, vaultRef, destination,
|
|
1912
|
+
const outcome = await cloud.openFromCloud(dataDir, vaultRef, destination, opener);
|
|
1108
1913
|
if (json) {
|
|
1109
1914
|
print(JSON.stringify(outcome, null, 2));
|
|
1110
1915
|
return;
|
|
@@ -1125,7 +1930,28 @@ async function main() {
|
|
|
1125
1930
|
* expects, so resume simply works again. Two different sessions matching
|
|
1126
1931
|
* one fragment is a question, not a guess: both are listed with ids.
|
|
1127
1932
|
*/
|
|
1128
|
-
|
|
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
|
+
}
|
|
1129
1955
|
if (!looksLikeId) {
|
|
1130
1956
|
const { resolve: resolvePath, basename } = await import("node:path");
|
|
1131
1957
|
const archives = (await listArchives(dataDir)).filter((record) => record.version === 2);
|
|
@@ -1163,13 +1989,22 @@ async function main() {
|
|
|
1163
1989
|
const secret = privateKey
|
|
1164
1990
|
? ""
|
|
1165
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");
|
|
1166
1993
|
const outcome = await restoreArchive(dataDir, id, secret, {
|
|
1167
1994
|
destination: native ? undefined : required(destination, "Destination is required unless --native is used"),
|
|
1168
1995
|
native, home: take(args, "--home"), overwrite: take(args, "--overwrite", "refuse"),
|
|
1996
|
+
...(dryRun ? { dryRun } : {}),
|
|
1169
1997
|
...(privateKey ? { privateKey } : {})
|
|
1170
1998
|
});
|
|
1171
1999
|
if (json) {
|
|
1172
|
-
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.")}`);
|
|
1173
2008
|
return;
|
|
1174
2009
|
}
|
|
1175
2010
|
print(` ${mark.ok()} Recovered ${bold(bytes(outcome.bytes))} to ${outcome.output}${outcome.native ? dim(" (original location)") : ""}`);
|
|
@@ -1212,36 +2047,143 @@ async function main() {
|
|
|
1212
2047
|
return;
|
|
1213
2048
|
}
|
|
1214
2049
|
if (action === "hook-config") {
|
|
1215
|
-
print(JSON.stringify(hookConfig(agentId(agent), take(args, "--executable", "
|
|
2050
|
+
print(JSON.stringify(hookConfig(agentId(agent), take(args, "--executable", "sealkeep"), dataDir), null, 2));
|
|
1216
2051
|
return;
|
|
1217
2052
|
}
|
|
1218
2053
|
fail("invalid_argument", "Usage: sealkeep agents <detect|sessions|hook-config> [agent]");
|
|
1219
2054
|
}
|
|
1220
2055
|
if (command === "hook") {
|
|
1221
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
|
+
}
|
|
1222
2110
|
if (action === "rehydrate") {
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
// outcome
|
|
1227
|
-
// 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.
|
|
1228
2115
|
const { rehydrateSession, rehydrateTargetFromPayload } = await import("./rehydrate.js");
|
|
2116
|
+
const { withoutArchiveRecordWarnings } = await import("./vault.js");
|
|
1229
2117
|
const target = rehydrateTargetFromPayload(await readStdin());
|
|
1230
2118
|
const phrase = await unlock(dataDir).catch(() => undefined);
|
|
1231
|
-
|
|
2119
|
+
await withoutArchiveRecordWarnings(() => rehydrateSession(dataDir, target, phrase).catch((error) => ({
|
|
1232
2120
|
rehydrated: false, reason: "restore-failed",
|
|
1233
2121
|
note: error instanceof Error ? error.message.split("\n")[0] : "rehydrate failed"
|
|
1234
|
-
}));
|
|
1235
|
-
|
|
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
|
+
}
|
|
1236
2152
|
return;
|
|
1237
2153
|
}
|
|
1238
2154
|
if (action !== "enqueue" && action !== "archive")
|
|
1239
|
-
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>");
|
|
1240
2156
|
const agent = agentId(take(args, "--agent"));
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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("{}");
|
|
1245
2187
|
return;
|
|
1246
2188
|
}
|
|
1247
2189
|
if (command === "queue") {
|
|
@@ -1282,6 +2224,17 @@ async function main() {
|
|
|
1282
2224
|
: ` ${mark.ok()} Encrypted ${bold(String(done.length))} session${done.length === 1 ? "" : "s"} ${dim(`· ${bytes(archived)} of history preserved`)}`);
|
|
1283
2225
|
for (const job of failed)
|
|
1284
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
|
+
}
|
|
1285
2238
|
return;
|
|
1286
2239
|
}
|
|
1287
2240
|
if (action === "retry") {
|
|
@@ -1308,6 +2261,220 @@ async function main() {
|
|
|
1308
2261
|
* writing a config fragment that runs `sealkeep mcp`, a command that did not
|
|
1309
2262
|
* exist until now: anyone who merged it got an agent that failed to start.
|
|
1310
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
|
+
}
|
|
1311
2478
|
if (command === "mcp") {
|
|
1312
2479
|
const [action] = positionals(args);
|
|
1313
2480
|
if (action === "install") {
|
|
@@ -1341,6 +2508,13 @@ async function main() {
|
|
|
1341
2508
|
fail("invalid_argument", "Usage: sealkeep mcp [run|install] [--agent <name>]");
|
|
1342
2509
|
// No banner, no colour: stdout is the protocol channel here, and anything
|
|
1343
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;
|
|
1344
2518
|
await import("./mcp.js");
|
|
1345
2519
|
return;
|
|
1346
2520
|
}
|
|
@@ -1382,12 +2556,19 @@ async function main() {
|
|
|
1382
2556
|
return;
|
|
1383
2557
|
}
|
|
1384
2558
|
if (command === "api") {
|
|
1385
|
-
//
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1388
|
-
|
|
1389
|
-
|
|
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://", ""))}`);
|
|
1390
2569
|
print(` ${dim(`Token file (do not share): ${shortPath(localApiTokenPath(dataDir), 60)}`)}`);
|
|
2570
|
+
if (handle.reused)
|
|
2571
|
+
return;
|
|
1391
2572
|
print(` ${dim("Ctrl-C to stop.")}\n`);
|
|
1392
2573
|
holdUntilStopped(handle);
|
|
1393
2574
|
return;
|
|
@@ -1447,8 +2628,8 @@ async function main() {
|
|
|
1447
2628
|
if (command === "search") {
|
|
1448
2629
|
const [query] = positionals(args);
|
|
1449
2630
|
const mode = args.includes("--content") ? "content" : "metadata";
|
|
1450
|
-
const hits = await search(dataDir, required(query, "Usage: sealkeep search <query> [--content] [--agent <name>]"), {
|
|
1451
|
-
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"),
|
|
1452
2633
|
phrase: mode === "content" ? required(await unlock(dataDir, take(args, "--recovery-phrase")), "Content search decrypts locally and needs --recovery-phrase") : undefined
|
|
1453
2634
|
});
|
|
1454
2635
|
if (json) {
|
|
@@ -1462,10 +2643,63 @@ async function main() {
|
|
|
1462
2643
|
}
|
|
1463
2644
|
for (const hit of hits) {
|
|
1464
2645
|
print(` ${bold(shortPath(hit.path, 60))}`);
|
|
1465
|
-
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
|
+
}
|
|
1466
2660
|
if (hit.matched === "content")
|
|
1467
2661
|
for (const snippet of hit.snippets)
|
|
1468
|
-
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")}`)}`);
|
|
1469
2703
|
print("");
|
|
1470
2704
|
}
|
|
1471
2705
|
return;
|
|
@@ -1474,7 +2708,21 @@ async function main() {
|
|
|
1474
2708
|
const [action] = positionals(args);
|
|
1475
2709
|
const phrase = async () => required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required");
|
|
1476
2710
|
if (action === "build") {
|
|
1477
|
-
|
|
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
|
+
});
|
|
1478
2726
|
if (json) {
|
|
1479
2727
|
print(JSON.stringify(built, null, 2));
|
|
1480
2728
|
return;
|
|
@@ -1483,6 +2731,20 @@ async function main() {
|
|
|
1483
2731
|
print(` ${dim("The index is encrypted at rest and never uploaded. Remove it with `sealkeep index drop`.")}`);
|
|
1484
2732
|
return;
|
|
1485
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
|
+
}
|
|
1486
2748
|
if (action === "drop") {
|
|
1487
2749
|
await dropContentIndex(dataDir);
|
|
1488
2750
|
print(` ${mark.ok()} Content index removed.`);
|
|
@@ -1490,12 +2752,35 @@ async function main() {
|
|
|
1490
2752
|
}
|
|
1491
2753
|
if (action === "status") {
|
|
1492
2754
|
const { indexCoverage } = await import("./search.js");
|
|
1493
|
-
|
|
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 } : {});
|
|
1494
2759
|
if (json) {
|
|
1495
2760
|
print(JSON.stringify(coverage, null, 2));
|
|
1496
2761
|
return;
|
|
1497
2762
|
}
|
|
1498
|
-
|
|
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
|
+
}
|
|
1499
2784
|
if (coverage.missing.length > 0) {
|
|
1500
2785
|
print(` ${dim(`${coverage.missing.length} not yet indexed — new seals index themselves; \`sealkeep index build\` adds the rest:`)}`);
|
|
1501
2786
|
for (const item of coverage.missing.slice(0, 5))
|
|
@@ -1506,7 +2791,7 @@ async function main() {
|
|
|
1506
2791
|
print("");
|
|
1507
2792
|
return;
|
|
1508
2793
|
}
|
|
1509
|
-
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>");
|
|
1510
2795
|
}
|
|
1511
2796
|
if (command === "audit") {
|
|
1512
2797
|
const [action] = positionals(args);
|
|
@@ -1531,7 +2816,59 @@ async function main() {
|
|
|
1531
2816
|
}
|
|
1532
2817
|
if (command === "recovery") {
|
|
1533
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
|
+
}
|
|
1534
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
|
+
}
|
|
1535
2872
|
if (action === "kit") {
|
|
1536
2873
|
// The kit is read after the machine is gone, so it must name the cloud
|
|
1537
2874
|
// account when there is one.
|
|
@@ -1616,7 +2953,7 @@ async function main() {
|
|
|
1616
2953
|
print("");
|
|
1617
2954
|
return;
|
|
1618
2955
|
}
|
|
1619
|
-
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>]");
|
|
1620
2957
|
}
|
|
1621
2958
|
if (command === "phrase" && positionals(args)[0] === "rotate") {
|
|
1622
2959
|
const { rotateVaultPhrase, rotatedPhrasePath } = await import("./rotate.js");
|
|
@@ -1764,11 +3101,12 @@ async function main() {
|
|
|
1764
3101
|
const [action, archiveId] = positionals(args);
|
|
1765
3102
|
if (action === "targets") {
|
|
1766
3103
|
const sub = positionals(args)[1];
|
|
1767
|
-
const { resolveTargets, targetUsage, setStorageTargets } = await import("./storage-targets.js");
|
|
3104
|
+
const { resolveTargets, storageTargetsForEditing, targetUsage, setStorageTargets } = await import("./storage-targets.js");
|
|
1768
3105
|
if (sub === "add") {
|
|
1769
3106
|
const provider = required(take(args, "--provider"), "--provider is required");
|
|
1770
3107
|
const id = take(args, "--id") ?? provider;
|
|
1771
|
-
const
|
|
3108
|
+
const edit = await storageTargetsForEditing(dataDir);
|
|
3109
|
+
const existing = edit.targets;
|
|
1772
3110
|
const bucketFlag = take(args, "--bucket");
|
|
1773
3111
|
const target = {
|
|
1774
3112
|
id, provider,
|
|
@@ -1782,15 +3120,23 @@ async function main() {
|
|
|
1782
3120
|
...(take(args, "--projects") ? { projects: take(args, "--projects").split(",").map((p) => p.trim()).filter(Boolean) } : {}),
|
|
1783
3121
|
...(take(args, "--priority") ? { priority: Number(take(args, "--priority")) } : {})
|
|
1784
3122
|
};
|
|
1785
|
-
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`.");
|
|
1786
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.")}`);
|
|
1787
3129
|
return;
|
|
1788
3130
|
}
|
|
1789
3131
|
if (sub === "remove") {
|
|
1790
3132
|
const id = required(positionals(args)[2], "Usage: sealkeep storage targets remove <id>");
|
|
1791
|
-
const
|
|
1792
|
-
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`.");
|
|
1793
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.")}`);
|
|
1794
3140
|
return;
|
|
1795
3141
|
}
|
|
1796
3142
|
const targets = await resolveTargets(dataDir);
|
|
@@ -1808,7 +3154,7 @@ async function main() {
|
|
|
1808
3154
|
const used = usage.get(t.id) ?? 0;
|
|
1809
3155
|
const cap = t.maxGb !== undefined ? ` of ${t.maxGb} GB` : "";
|
|
1810
3156
|
const pins = t.projects?.length ? ` · projects: ${t.projects.join(", ")}` : "";
|
|
1811
|
-
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)}`)}`);
|
|
1812
3158
|
}
|
|
1813
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`);
|
|
1814
3160
|
return;
|
|
@@ -1820,20 +3166,20 @@ async function main() {
|
|
|
1820
3166
|
return;
|
|
1821
3167
|
}
|
|
1822
3168
|
print(remote
|
|
1823
|
-
? ` ${bold(
|
|
3169
|
+
? ` ${bold(providerLabel(remote.provider))} ${dim(`${remote.bucket}/${remote.prefix}${remote.region ? ` · ${remote.region}` : ""}`)}`
|
|
1824
3170
|
: ` ${dim("No remote target. Set one with `sealkeep storage configure --provider r2 --bucket … --prefix …`")}`);
|
|
1825
3171
|
return;
|
|
1826
3172
|
}
|
|
1827
3173
|
if (action === "configure") {
|
|
1828
3174
|
const provider = required(take(args, "--provider"), "--provider is required");
|
|
1829
|
-
if (!Object.hasOwn(providers, provider))
|
|
1830
|
-
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");
|
|
1831
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") });
|
|
1832
3178
|
if (json) {
|
|
1833
3179
|
print(JSON.stringify(config.remoteStorage, null, 2));
|
|
1834
3180
|
return;
|
|
1835
3181
|
}
|
|
1836
|
-
print(` ${mark.ok()} Target saved: ${bold(
|
|
3182
|
+
print(` ${mark.ok()} Target saved: ${bold(providerLabel(config.remoteStorage.provider))} ${dim(`${config.remoteStorage.bucket}/${config.remoteStorage.prefix}`)}`);
|
|
1837
3183
|
print(` ${hint("Store credentials next: echo '{\"accessKeyId\":\"…\",\"secretAccessKey\":\"…\"}' | sealkeep storage credentials set")}`);
|
|
1838
3184
|
return;
|
|
1839
3185
|
}
|
|
@@ -1843,7 +3189,7 @@ async function main() {
|
|
|
1843
3189
|
const plan = setupPlan({
|
|
1844
3190
|
provider,
|
|
1845
3191
|
bucket: required(take(args, "--bucket") ?? config?.remoteStorage?.bucket, "--bucket is required"),
|
|
1846
|
-
prefix: take(args, "--prefix") ?? config?.remoteStorage?.prefix ??
|
|
3192
|
+
prefix: take(args, "--prefix") ?? config?.remoteStorage?.prefix ?? `${STORAGE_PREFIX_DEFAULT}/${config?.vaultId?.slice(0, 8) ?? "me"}`,
|
|
1847
3193
|
region: take(args, "--region"), accountId: take(args, "--account-id"), project: take(args, "--project")
|
|
1848
3194
|
});
|
|
1849
3195
|
if (json) {
|
|
@@ -1876,8 +3222,16 @@ async function main() {
|
|
|
1876
3222
|
// `archiveId` is just the second positional here: the provider to connect.
|
|
1877
3223
|
if (archiveId !== "gdrive")
|
|
1878
3224
|
fail("invalid_argument", "Usage: sealkeep storage connect gdrive [--config-id <id>] [--backend <name>]");
|
|
1879
|
-
const
|
|
3225
|
+
const config = await readConfig(dataDir);
|
|
3226
|
+
const configId = take(args, "--config-id") ?? config.vaultId;
|
|
1880
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
|
+
}
|
|
1881
3235
|
// The account's shared Google client first: no console visit, no env
|
|
1882
3236
|
// var — the consent screen is the whole setup. The PKCE desktop path
|
|
1883
3237
|
// stays for self-hosters who set SEALKEEP_GDRIVE_CLIENT_ID.
|
|
@@ -1897,7 +3251,10 @@ async function main() {
|
|
|
1897
3251
|
print(` ${dim("Waiting for the browser…")}`);
|
|
1898
3252
|
}
|
|
1899
3253
|
});
|
|
1900
|
-
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);
|
|
1901
3258
|
if (json) {
|
|
1902
3259
|
print(JSON.stringify({ provider: "gdrive", backend: stored.backend, configId }, null, 2));
|
|
1903
3260
|
return;
|
|
@@ -1920,7 +3277,19 @@ async function main() {
|
|
|
1920
3277
|
catch {
|
|
1921
3278
|
fail("invalid_argument", "Credentials on stdin must be JSON");
|
|
1922
3279
|
}
|
|
1923
|
-
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);
|
|
1924
3293
|
print(` ${mark.ok()} Stored in ${bold(stored.backend)} ${dim(`for ${configId.slice(0, 8)}`)}`);
|
|
1925
3294
|
return;
|
|
1926
3295
|
}
|
|
@@ -1979,7 +3348,7 @@ async function main() {
|
|
|
1979
3348
|
// stays, and on real transcripts that is about two thirds of what it
|
|
1980
3349
|
// replaced. This removes local archives the bucket has a verified copy of.
|
|
1981
3350
|
const { offloadArchives } = await import("./offload.js");
|
|
1982
|
-
const result = await offloadArchives(dataDir, { confirm: args.includes("--confirm") });
|
|
3351
|
+
const result = await offloadArchives(dataDir, { ...options, confirm: args.includes("--confirm") });
|
|
1983
3352
|
if (json) {
|
|
1984
3353
|
print(JSON.stringify(result, null, 2));
|
|
1985
3354
|
return;
|
|
@@ -2054,11 +3423,11 @@ async function main() {
|
|
|
2054
3423
|
}
|
|
2055
3424
|
return;
|
|
2056
3425
|
}
|
|
2057
|
-
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`)}`);
|
|
2058
3427
|
return;
|
|
2059
3428
|
}
|
|
2060
|
-
print(` ${mark.ok()}
|
|
2061
|
-
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.")}`);
|
|
2062
3431
|
for (const failure of result.failed)
|
|
2063
3432
|
print(` ${mark.warn()} ${dim(failure.id.slice(0, 8))} ${dim(failure.error)}`);
|
|
2064
3433
|
return;
|
|
@@ -2099,35 +3468,195 @@ async function main() {
|
|
|
2099
3468
|
...(idleDays === undefined ? {} : { sourceIdleDays: nullable(idleDays) }),
|
|
2100
3469
|
...(minBytes === undefined ? {} : { minSourceBytes: nullable(minBytes) })
|
|
2101
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
|
+
});
|
|
2102
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
|
+
}
|
|
2103
3487
|
return;
|
|
2104
3488
|
}
|
|
2105
3489
|
fail("invalid_argument", "Usage: sealkeep retention <preview|apply|prune|offload|approve|policy> [options]");
|
|
2106
3490
|
}
|
|
2107
3491
|
if (command === "daemon") {
|
|
2108
|
-
const
|
|
2109
|
-
const
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
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
|
+
},
|
|
2120
3542
|
});
|
|
2121
|
-
|
|
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;
|
|
2122
3638
|
print(`\n${BRAND} ${green("watching")}`);
|
|
2123
3639
|
print(keyValue([
|
|
2124
|
-
["Roots",
|
|
2125
|
-
["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")]] : [])
|
|
2126
3645
|
]));
|
|
2127
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
|
+
})();
|
|
2128
3651
|
for (const signal of ["SIGINT", "SIGTERM"])
|
|
2129
|
-
process.on(signal, () => { void
|
|
2130
|
-
|
|
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
|
+
}
|
|
2131
3660
|
return;
|
|
2132
3661
|
}
|
|
2133
3662
|
print(`${mark.fail()} Unknown command: ${command}\n${hint(`run ${cmd("sealkeep help")}`)}`);
|
|
@@ -2137,6 +3666,12 @@ const HINTS = {
|
|
|
2137
3666
|
vault_not_initialized: "run `sealkeep quickstart`",
|
|
2138
3667
|
recovery_phrase_missing: "pass --recovery-phrase, or set SEALKEEP_RECOVERY_PHRASE",
|
|
2139
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",
|
|
2140
3675
|
storage_not_configured: "run `sealkeep storage configure --provider … --bucket … --prefix …`",
|
|
2141
3676
|
signer_not_configured: "set SEALKEEP_ENABLE_SIGNER=1 and store credentials",
|
|
2142
3677
|
destination_exists: "add --overwrite backup to keep the existing file",
|