@litterthanlit/hypher-cli 0.1.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/README.md +13 -0
- package/bin/hypher.mjs +4 -0
- package/package.json +25 -0
- package/src/agentsBlock.mjs +60 -0
- package/src/api.mjs +103 -0
- package/src/claude.mjs +51 -0
- package/src/cli.mjs +101 -0
- package/src/codex.mjs +305 -0
- package/src/commands/ack.mjs +38 -0
- package/src/commands/doctor.mjs +130 -0
- package/src/commands/hook.mjs +57 -0
- package/src/commands/init.mjs +252 -0
- package/src/commands/link.mjs +65 -0
- package/src/commands/mcp.mjs +304 -0
- package/src/commands/resume.mjs +25 -0
- package/src/commands/seal.mjs +85 -0
- package/src/commands/status.mjs +38 -0
- package/src/commands/uninstall.mjs +131 -0
- package/src/config.mjs +48 -0
- package/src/credentials.mjs +134 -0
- package/src/device.mjs +62 -0
- package/src/env.mjs +75 -0
- package/src/fsutil.mjs +70 -0
- package/src/hookConfig.mjs +86 -0
- package/src/hooks/index.mjs +134 -0
- package/src/manifest.mjs +126 -0
- package/src/outbox.mjs +103 -0
- package/src/repoBlock.mjs +28 -0
- package/src/secrets.mjs +2 -0
- package/src/session.mjs +211 -0
- package/src/snapshot.mjs +56 -0
- package/src/state.mjs +53 -0
- package/src/tools.mjs +60 -0
- package/vendor/handoffNote.mjs +142 -0
- package/vendor/handoffSeal.mjs +149 -0
- package/vendor/hypher-git.mjs +429 -0
- package/vendor/secretScan.mjs +47 -0
- package/vendor/structuredHandoff.mjs +796 -0
package/src/manifest.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The install manifest: every change `init` made, so `uninstall` removes
|
|
3
|
+
* exactly that. A file nothing else touched since goes back to its original
|
|
4
|
+
* bytes. A file changed since keeps those changes and loses only Hypher's
|
|
5
|
+
* entries. A backup is never restored over later edits.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, rmdirSync, readdirSync } from "node:fs";
|
|
8
|
+
import { paths } from "./env.mjs";
|
|
9
|
+
import { exists, readBytes, readJson, removeFile, sha256, writeAtomic, writeJson } from "./fsutil.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} FileEntry
|
|
13
|
+
* @property {"file"} kind
|
|
14
|
+
* @property {string} path
|
|
15
|
+
* @property {string} role What the change is, for the printout and for the cleaner.
|
|
16
|
+
* @property {string | null} originalBase64 Bytes before the first install, or null when the file did not exist.
|
|
17
|
+
* @property {string} afterSha256 Hash right after the latest install.
|
|
18
|
+
* @property {string[]} createdDirs Directories init created for this file, deepest first.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export function readManifest(env = process.env) {
|
|
22
|
+
const manifest = readJson(paths(env).manifest);
|
|
23
|
+
if (manifest && Array.isArray(manifest.entries)) return manifest;
|
|
24
|
+
return { version: 1, entries: [] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function writeManifest(manifest, env = process.env) {
|
|
28
|
+
writeJson(paths(env).manifest, manifest, { mode: 0o600 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Directories that `mkdir -p` would create for `dir`, deepest first. */
|
|
32
|
+
export function missingDirs(dir) {
|
|
33
|
+
const missing = [];
|
|
34
|
+
let current = dir;
|
|
35
|
+
while (current && !exists(current)) {
|
|
36
|
+
missing.push(current);
|
|
37
|
+
const parent = current.replace(/\/[^/]+\/?$/, "");
|
|
38
|
+
if (parent === current || !parent) break;
|
|
39
|
+
current = parent;
|
|
40
|
+
}
|
|
41
|
+
return missing;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write `next` to `file` and record it. The first install keeps the original
|
|
46
|
+
* bytes; later installs only move the after-hash.
|
|
47
|
+
* @param {{ entries: any[] }} manifest
|
|
48
|
+
* @param {string} file
|
|
49
|
+
* @param {string | Buffer} next
|
|
50
|
+
* @param {string} role
|
|
51
|
+
* @returns {"added" | "unchanged" | "updated"}
|
|
52
|
+
*/
|
|
53
|
+
export function writeTrackedFile(manifest, file, next, role) {
|
|
54
|
+
const before = readBytes(file);
|
|
55
|
+
const bytes = Buffer.isBuffer(next) ? next : Buffer.from(next, "utf8");
|
|
56
|
+
const existing = manifest.entries.find((entry) => entry.kind === "file" && entry.path === file);
|
|
57
|
+
if (before && before.equals(bytes)) {
|
|
58
|
+
if (existing) existing.afterSha256 = sha256(bytes);
|
|
59
|
+
return "unchanged";
|
|
60
|
+
}
|
|
61
|
+
const dir = file.replace(/\/[^/]+$/, "");
|
|
62
|
+
const createdDirs = missingDirs(dir);
|
|
63
|
+
mkdirSync(dir, { recursive: true });
|
|
64
|
+
writeAtomic(file, bytes);
|
|
65
|
+
if (existing) {
|
|
66
|
+
existing.afterSha256 = sha256(bytes);
|
|
67
|
+
return "updated";
|
|
68
|
+
}
|
|
69
|
+
manifest.entries.push({
|
|
70
|
+
kind: "file",
|
|
71
|
+
path: file,
|
|
72
|
+
role,
|
|
73
|
+
originalBase64: before ? before.toString("base64") : null,
|
|
74
|
+
afterSha256: sha256(bytes),
|
|
75
|
+
createdDirs,
|
|
76
|
+
});
|
|
77
|
+
return "added";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function recordEntry(manifest, entry) {
|
|
81
|
+
const same = manifest.entries.findIndex((item) => item.kind === entry.kind && item.id === entry.id);
|
|
82
|
+
if (same >= 0) manifest.entries[same] = { ...manifest.entries[same], ...entry };
|
|
83
|
+
else manifest.entries.push(entry);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function removeEmptyDirs(dirs) {
|
|
87
|
+
for (const dir of dirs ?? []) {
|
|
88
|
+
try {
|
|
89
|
+
if (readdirSync(dir).length === 0) rmdirSync(dir);
|
|
90
|
+
} catch {
|
|
91
|
+
// Gone already, or not empty: leave it.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Undo one tracked file.
|
|
98
|
+
* @param {FileEntry} entry
|
|
99
|
+
* @param {(text: string) => string | null} clean Removes Hypher's entries from edited content; null deletes nothing.
|
|
100
|
+
* @returns {"restored" | "removed" | "cleaned" | "missing"}
|
|
101
|
+
*/
|
|
102
|
+
export function undoTrackedFile(entry, clean) {
|
|
103
|
+
const current = readBytes(entry.path);
|
|
104
|
+
if (!current) {
|
|
105
|
+
removeEmptyDirs(entry.createdDirs);
|
|
106
|
+
return "missing";
|
|
107
|
+
}
|
|
108
|
+
if (sha256(current) === entry.afterSha256) {
|
|
109
|
+
if (entry.originalBase64 === null) {
|
|
110
|
+
removeFile(entry.path);
|
|
111
|
+
removeEmptyDirs(entry.createdDirs);
|
|
112
|
+
return "removed";
|
|
113
|
+
}
|
|
114
|
+
writeAtomic(entry.path, Buffer.from(entry.originalBase64, "base64"));
|
|
115
|
+
return "restored";
|
|
116
|
+
}
|
|
117
|
+
const cleaned = clean(current.toString("utf8"));
|
|
118
|
+
// A file init created, with nothing left once Hypher's entries are out, goes away.
|
|
119
|
+
if (cleaned === "" && entry.originalBase64 === null) {
|
|
120
|
+
removeFile(entry.path);
|
|
121
|
+
removeEmptyDirs(entry.createdDirs);
|
|
122
|
+
return "removed";
|
|
123
|
+
}
|
|
124
|
+
if (cleaned !== null && cleaned !== current.toString("utf8")) writeAtomic(entry.path, cleaned);
|
|
125
|
+
return "cleaned";
|
|
126
|
+
}
|
package/src/outbox.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbox: a durable queue of checkpoints waiting to upload. It is not a
|
|
3
|
+
* store. Each item is dropped once Hypher accepts it; a newer checkpoint for
|
|
4
|
+
* the same repository and branch replaces an unsent older one but keeps its
|
|
5
|
+
* queue time, so the five-minute upload rule still fires.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, readdirSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { paths } from "./env.mjs";
|
|
11
|
+
import { readJson, removeFile, writeJson } from "./fsutil.mjs";
|
|
12
|
+
import { HypherUnavailable } from "./api.mjs";
|
|
13
|
+
|
|
14
|
+
export const FLUSH_AGE_MS = 5 * 60_000;
|
|
15
|
+
const MAX_ATTEMPTS = 20;
|
|
16
|
+
|
|
17
|
+
function dir(env) {
|
|
18
|
+
return paths(env).outboxDir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @returns {{ file: string, item: any }[]} oldest first */
|
|
22
|
+
export function listOutbox(env = process.env) {
|
|
23
|
+
let names = [];
|
|
24
|
+
try {
|
|
25
|
+
names = readdirSync(dir(env)).filter((name) => name.endsWith(".json"));
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return names
|
|
30
|
+
.map((name) => ({ file: path.join(dir(env), name), item: readJson(path.join(dir(env), name)) }))
|
|
31
|
+
.filter((entry) => entry.item && typeof entry.item === "object")
|
|
32
|
+
.sort((a, b) => a.item.queuedAt - b.item.queuedAt || a.file.localeCompare(b.file));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{ kind: "checkpoint", key: string, body: object }} input
|
|
37
|
+
* @param {{ env?: NodeJS.ProcessEnv, now?: number }} [options]
|
|
38
|
+
*/
|
|
39
|
+
export function enqueue(input, { env = process.env, now = Date.now() } = {}) {
|
|
40
|
+
mkdirSync(dir(env), { recursive: true });
|
|
41
|
+
let queuedAt = now;
|
|
42
|
+
for (const entry of listOutbox(env)) {
|
|
43
|
+
if (entry.item.kind === input.kind && entry.item.key === input.key) {
|
|
44
|
+
queuedAt = Math.min(queuedAt, entry.item.queuedAt);
|
|
45
|
+
removeFile(entry.file);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const id = `${now}-${randomBytes(4).toString("hex")}`;
|
|
49
|
+
const item = { id, kind: input.kind, key: input.key, body: input.body, queuedAt, attempts: 0, nextAttemptAt: 0 };
|
|
50
|
+
writeJson(path.join(dir(env), `${id}.json`), item, { mode: 0o600 });
|
|
51
|
+
return item;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function oldestAgeMs(env = process.env, now = Date.now()) {
|
|
55
|
+
const [oldest] = listOutbox(env);
|
|
56
|
+
return oldest ? now - oldest.item.queuedAt : 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function shouldFlush(env = process.env, now = Date.now()) {
|
|
60
|
+
return oldestAgeMs(env, now) >= FLUSH_AGE_MS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Upload what is due. Stops at the first network failure; retries back off.
|
|
65
|
+
* @param {{ send: Function }} client
|
|
66
|
+
* @param {{ env?: NodeJS.ProcessEnv, now?: number, force?: boolean }} [options]
|
|
67
|
+
* @returns {Promise<{ sent: number, dropped: number, remaining: number, unavailable: boolean }>}
|
|
68
|
+
*/
|
|
69
|
+
export async function flushOutbox(client, { env = process.env, now = Date.now(), force = false } = {}) {
|
|
70
|
+
let sent = 0;
|
|
71
|
+
let dropped = 0;
|
|
72
|
+
let unavailable = false;
|
|
73
|
+
for (const { file, item } of listOutbox(env)) {
|
|
74
|
+
if (!force && item.nextAttemptAt > now) continue;
|
|
75
|
+
try {
|
|
76
|
+
const response = await client.send("POST", "/api/cli/checkpoints", { body: item.body });
|
|
77
|
+
if (response.ok) {
|
|
78
|
+
removeFile(file);
|
|
79
|
+
sent += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 401 && response.status !== 429) {
|
|
83
|
+
// The server will never take this one; keep the queue moving.
|
|
84
|
+
removeFile(file);
|
|
85
|
+
dropped += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
throw new HypherUnavailable(client.base ?? "", `HTTP ${response.status}`);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!(error instanceof HypherUnavailable)) throw error;
|
|
91
|
+
unavailable = true;
|
|
92
|
+
const attempts = item.attempts + 1;
|
|
93
|
+
if (attempts >= MAX_ATTEMPTS) {
|
|
94
|
+
removeFile(file);
|
|
95
|
+
dropped += 1;
|
|
96
|
+
} else {
|
|
97
|
+
writeJson(file, { ...item, attempts, nextAttemptAt: now + Math.min(30 * 60_000, 15_000 * 2 ** attempts) }, { mode: 0o600 });
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { sent, dropped, remaining: listOutbox(env).length, unavailable };
|
|
103
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repository's AGENTS.md: `init` and `link` add the marked Hypher block,
|
|
3
|
+
* creating the file if needed, unless the file already tells agents to use
|
|
4
|
+
* Hypher. It goes in the manifest, so `uninstall` takes out exactly it.
|
|
5
|
+
*/
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { readBytes } from "./fsutil.mjs";
|
|
8
|
+
import { writeTrackedFile } from "./manifest.mjs";
|
|
9
|
+
import { addBlock, agentsBlock, alreadyUsesHypher } from "./agentsBlock.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{ entries: any[] }} manifest
|
|
13
|
+
* @param {string} root the repository root
|
|
14
|
+
* @returns {{ file: string, change: "added" | "updated" | "unchanged" | "skipped" }}
|
|
15
|
+
*/
|
|
16
|
+
export function addRepositoryBlock(manifest, root) {
|
|
17
|
+
const file = path.join(root, "AGENTS.md");
|
|
18
|
+
const text = readBytes(file)?.toString("utf8") ?? "";
|
|
19
|
+
if (alreadyUsesHypher(text)) return { file, change: "skipped" };
|
|
20
|
+
return { file, change: writeTrackedFile(manifest, file, addBlock(text, agentsBlock("repository")), "agents-md") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How to show a path: ./relative inside the working directory, ~/ under HOME, else absolute. */
|
|
24
|
+
export function displayPath(file, { cwd, home }) {
|
|
25
|
+
if (cwd && (file === cwd || file.startsWith(`${cwd}/`))) return `./${path.relative(cwd, file)}`;
|
|
26
|
+
if (home && file.startsWith(`${home}/`)) return `~/${file.slice(home.length + 1)}`;
|
|
27
|
+
return file;
|
|
28
|
+
}
|
package/src/secrets.mjs
ADDED
package/src/session.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume and seal: the two things every door does. Hooks, `hypher resume`,
|
|
3
|
+
* `hypher seal`, and (later) the MCP tools all come through here.
|
|
4
|
+
*/
|
|
5
|
+
import { createClient, HypherRejected, HypherUnavailable } from "./api.mjs";
|
|
6
|
+
import { linkFor } from "./config.mjs";
|
|
7
|
+
import { canonicalJson, sha256 } from "./fsutil.mjs";
|
|
8
|
+
import { flushOutbox } from "./outbox.mjs";
|
|
9
|
+
import { handoffTreeOf, takeSnapshot } from "./snapshot.mjs";
|
|
10
|
+
import { readWorktreeState, updateWorktreeState, withBranchState } from "./state.mjs";
|
|
11
|
+
import { findSecretInValue } from "./secrets.mjs";
|
|
12
|
+
import { keepHandoffTree, truthCheck } from "../vendor/hypher-git.mjs";
|
|
13
|
+
import {
|
|
14
|
+
HYPHER_UNAVAILABLE_LINE,
|
|
15
|
+
renderHandoffNote,
|
|
16
|
+
renderNoHandoffNote,
|
|
17
|
+
renderUnlinkedLine,
|
|
18
|
+
} from "../vendor/handoffNote.mjs";
|
|
19
|
+
import { buildSealProposal } from "../vendor/handoffSeal.mjs";
|
|
20
|
+
import { parseHandoffProposal } from "../vendor/structuredHandoff.mjs";
|
|
21
|
+
|
|
22
|
+
export const ACK_HINT = "run `hypher ack`";
|
|
23
|
+
const NOT_CONNECTED_LINE = "Hypher is not connected on this computer; starting without it. Run `hypher init` in a terminal.";
|
|
24
|
+
|
|
25
|
+
/** The agent running this command, from its environment. */
|
|
26
|
+
export function detectAgent(env, fallback) {
|
|
27
|
+
if (env.HYPHER_AGENT) return String(env.HYPHER_AGENT).slice(0, 40);
|
|
28
|
+
if (env.CLAUDECODE === "1" || env.CLAUDE_CODE_ENTRYPOINT) return "claude-code";
|
|
29
|
+
if (env.CODEX_SANDBOX || env.CODEX_MANAGED_BY_NPM || env.CODEX_THREAD_ID) return "codex";
|
|
30
|
+
return fallback ?? "agent";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function checkpointFrom(body) {
|
|
34
|
+
const checkpoint = body?.checkpoint;
|
|
35
|
+
if (!checkpoint || typeof checkpoint.capturedAt !== "number") return null;
|
|
36
|
+
return {
|
|
37
|
+
capturedAt: checkpoint.capturedAt,
|
|
38
|
+
source: String(checkpoint.source ?? "agent"),
|
|
39
|
+
event: String(checkpoint.event ?? "checkpoint"),
|
|
40
|
+
branch: String(checkpoint.branch ?? ""),
|
|
41
|
+
commit: String(checkpoint.commit ?? ""),
|
|
42
|
+
changedFiles: Array.isArray(checkpoint.changedFiles) ? checkpoint.changedFiles.map(String) : [],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load the handoff for the repository at cwd, check it against the working
|
|
48
|
+
* tree, record a prepared receipt, and render the note. Never throws for a
|
|
49
|
+
* missing link, credential, or network; returns a status and one line instead.
|
|
50
|
+
* @param {{ env: NodeJS.ProcessEnv, cwd: string, destination: string, sessionId?: string, now?: number, timeoutMs?: number }} options
|
|
51
|
+
*/
|
|
52
|
+
export async function resumeHere({ env, cwd, destination, sessionId, now = Date.now(), timeoutMs = 2_500 }) {
|
|
53
|
+
const snapshot = takeSnapshot(cwd);
|
|
54
|
+
if (!snapshot) return { status: "not-git", note: "" };
|
|
55
|
+
if (!snapshot.repository) {
|
|
56
|
+
return { status: "no-remote", note: "Hypher: this repository has no GitHub remote, so Hypher has no note for it." };
|
|
57
|
+
}
|
|
58
|
+
const link = linkFor(snapshot.repository, env);
|
|
59
|
+
// A repository no human linked uploads nothing at all.
|
|
60
|
+
if (!link) return { status: "unlinked", note: renderUnlinkedLine(snapshot.repository), snapshot };
|
|
61
|
+
const client = createClient({ env, timeoutMs });
|
|
62
|
+
if (!client.apiKey) return { status: "not-connected", note: NOT_CONNECTED_LINE, snapshot };
|
|
63
|
+
|
|
64
|
+
let prepared;
|
|
65
|
+
let checkpoint = null;
|
|
66
|
+
try {
|
|
67
|
+
// A killed session's last checkpoint goes up first, so this note can name it.
|
|
68
|
+
const flushed = await flushOutbox(client, { env, now, force: true });
|
|
69
|
+
if (flushed.unavailable) return { status: "unavailable", note: HYPHER_UNAVAILABLE_LINE, snapshot };
|
|
70
|
+
const [resume, latest] = await Promise.all([
|
|
71
|
+
client.tool("prepare_handoff", { projectId: link.projectId, destination, currentRepo: snapshot.currentRepo }),
|
|
72
|
+
client.send("GET", `/api/cli/checkpoints?repository=${encodeURIComponent(snapshot.repository)}&branch=${encodeURIComponent(snapshot.branch)}`)
|
|
73
|
+
.then((response) => (response.ok ? response.json : null)),
|
|
74
|
+
]);
|
|
75
|
+
prepared = resume.structured;
|
|
76
|
+
checkpoint = checkpointFrom(latest);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof HypherUnavailable) return { status: "unavailable", note: HYPHER_UNAVAILABLE_LINE, snapshot };
|
|
79
|
+
if (error instanceof HypherRejected) return { status: "rejected", note: `Hypher: ${error.message}`, snapshot };
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const base = {
|
|
84
|
+
repository: snapshot.repository,
|
|
85
|
+
projectId: link.projectId,
|
|
86
|
+
destination,
|
|
87
|
+
sessionId,
|
|
88
|
+
resumedAt: now,
|
|
89
|
+
};
|
|
90
|
+
if (prepared?.ok !== true) {
|
|
91
|
+
if (prepared?.code === "no-handoff") {
|
|
92
|
+
const shown = checkpoint && checkpoint.branch === snapshot.branch ? checkpoint : null;
|
|
93
|
+
updateWorktreeState(env, snapshot.root, (state) => withBranchState({ ...state, ...base, baseRevision: 0, proposal: null, receiptId: null, receiptRevision: null },
|
|
94
|
+
snapshot.branch, { sealTree: snapshot.tree }));
|
|
95
|
+
return { status: "no-handoff", note: renderNoHandoffNote({ repository: snapshot.repository, projectName: link.projectName, now, checkpoint: shown }), snapshot, checkpoint: shown };
|
|
96
|
+
}
|
|
97
|
+
return { status: "rejected", note: `Hypher: ${prepared?.error ?? "could not load the handoff"}. Run \`hypher doctor\`.`, snapshot };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const parsed = parseHandoffProposal(prepared.proposal, { allowPlaceholders: true });
|
|
101
|
+
if (!parsed.ok) return { status: "rejected", note: "Hypher returned a handoff this CLI cannot read. Update with `hypher init`.", snapshot };
|
|
102
|
+
const proposal = parsed.value;
|
|
103
|
+
const verdict = truthCheck(snapshot.root, proposal.repo);
|
|
104
|
+
const savedAt = typeof prepared.savedAt === "number" ? prepared.savedAt : undefined;
|
|
105
|
+
const shownCheckpoint = checkpoint && (!savedAt || checkpoint.capturedAt > savedAt) ? checkpoint : null;
|
|
106
|
+
const note = renderHandoffNote({
|
|
107
|
+
proposal,
|
|
108
|
+
revision: prepared.revision,
|
|
109
|
+
now,
|
|
110
|
+
from: prepared.sourceAgent,
|
|
111
|
+
savedAt,
|
|
112
|
+
verdict,
|
|
113
|
+
checkpoint: shownCheckpoint,
|
|
114
|
+
acknowledge: ACK_HINT,
|
|
115
|
+
});
|
|
116
|
+
updateWorktreeState(env, snapshot.root, (state) => withBranchState({
|
|
117
|
+
...state,
|
|
118
|
+
...base,
|
|
119
|
+
baseRevision: prepared.revision,
|
|
120
|
+
proposal,
|
|
121
|
+
receiptId: prepared.receiptId ?? null,
|
|
122
|
+
// A later save moves baseRevision; the receipt stays tied to the revision that was read.
|
|
123
|
+
receiptRevision: prepared.revision,
|
|
124
|
+
}, snapshot.branch, { sealTree: snapshot.tree }));
|
|
125
|
+
return {
|
|
126
|
+
status: "ready",
|
|
127
|
+
note,
|
|
128
|
+
verdict,
|
|
129
|
+
revision: prepared.revision,
|
|
130
|
+
receiptId: prepared.receiptId,
|
|
131
|
+
projectId: link.projectId,
|
|
132
|
+
checkpoint: shownCheckpoint,
|
|
133
|
+
snapshot,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Save a handoff from what the agent knows: decisions that changed and why,
|
|
139
|
+
* and the next action. Based on the revision this session was handed.
|
|
140
|
+
* @param {{ env: NodeJS.ProcessEnv, cwd: string, seal: object, agent: string, now?: number }} options
|
|
141
|
+
*/
|
|
142
|
+
export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
|
|
143
|
+
const snapshot = takeSnapshot(cwd);
|
|
144
|
+
if (!snapshot) return { ok: false, code: "not-git", message: "Run this inside a git repository." };
|
|
145
|
+
if (!snapshot.repository) return { ok: false, code: "no-remote", message: "This repository has no GitHub remote, so Hypher cannot save for it." };
|
|
146
|
+
const link = linkFor(snapshot.repository, env);
|
|
147
|
+
if (!link) return { ok: false, code: "unlinked", message: renderUnlinkedLine(snapshot.repository) };
|
|
148
|
+
|
|
149
|
+
// Agent text is scanned before anything is built or sent.
|
|
150
|
+
const secret = findSecretInValue(seal);
|
|
151
|
+
if (secret) {
|
|
152
|
+
return { ok: false, code: "secret", message: `Refused: ${secret.field} looks like ${secret.shape}. Nothing was sent. Remove it and seal again.` };
|
|
153
|
+
}
|
|
154
|
+
const state = readWorktreeState(env, snapshot.root);
|
|
155
|
+
if (!state || state.projectId !== link.projectId || typeof state.baseRevision !== "number") {
|
|
156
|
+
return { ok: false, code: "no-base", message: "Run `hypher resume` first, so this save is based on the handoff you read." };
|
|
157
|
+
}
|
|
158
|
+
const prior = state.proposal ? parseHandoffProposal(state.proposal, { allowPlaceholders: true }) : null;
|
|
159
|
+
const iso = new Date(now).toISOString().slice(0, 16).replace("T", " ");
|
|
160
|
+
const built = buildSealProposal({
|
|
161
|
+
prior: prior?.ok ? prior.value : null,
|
|
162
|
+
seal,
|
|
163
|
+
repo: snapshot.currentRepo,
|
|
164
|
+
sessionRef: `${agent} session on ${snapshot.branch}, ${iso} UTC`,
|
|
165
|
+
});
|
|
166
|
+
if (!built.ok) return { ok: false, code: "invalid", message: built.error };
|
|
167
|
+
|
|
168
|
+
const client = createClient({ env, timeoutMs: 10_000 });
|
|
169
|
+
if (!client.apiKey) return { ok: false, code: "not-connected", message: "This computer is not connected. Run `hypher init`." };
|
|
170
|
+
const request = {
|
|
171
|
+
kind: "handoff",
|
|
172
|
+
source: agent,
|
|
173
|
+
projectId: link.projectId,
|
|
174
|
+
proposal: built.proposal,
|
|
175
|
+
expectedBaseRevision: state.baseRevision,
|
|
176
|
+
};
|
|
177
|
+
// Same base and same request: same key, so a retry dedupes. A change that
|
|
178
|
+
// returns to earlier content has a newer base, so it is not a duplicate.
|
|
179
|
+
const idempotencyKey = `seal-${sha256(`${state.baseRevision}\n${canonicalJson(request)}`).slice(0, 40)}`;
|
|
180
|
+
let result;
|
|
181
|
+
try {
|
|
182
|
+
result = (await client.tool("post_agent_event", { ...request, idempotencyKey })).structured;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof HypherUnavailable) return { ok: false, code: "unavailable", message: "Hypher unavailable; nothing was saved. Run `hypher doctor`, then seal again." };
|
|
185
|
+
if (error instanceof HypherRejected) return { ok: false, code: "rejected", message: error.message };
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
if (!result?.ok) {
|
|
189
|
+
if (result?.code === "stale-revision") {
|
|
190
|
+
return { ok: false, code: "stale", message: `Hypher has a newer handoff (revision ${result.headRevision}) than the one this session read (revision ${state.baseRevision}). Run \`hypher resume\`, read it, and seal again.` };
|
|
191
|
+
}
|
|
192
|
+
return { ok: false, code: result?.code ?? "rejected", message: `Hypher refused the handoff: ${result?.error ?? "unknown error"}` };
|
|
193
|
+
}
|
|
194
|
+
const tree = snapshot.tree;
|
|
195
|
+
if (tree) keepHandoffTree(snapshot.root, { branch: snapshot.branch, tree, commit: snapshot.commit });
|
|
196
|
+
updateWorktreeState(env, snapshot.root, (current) => withBranchState({
|
|
197
|
+
...current,
|
|
198
|
+
baseRevision: result.revision,
|
|
199
|
+
proposal: built.proposal,
|
|
200
|
+
agent,
|
|
201
|
+
}, snapshot.branch, { sealTree: tree }));
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
code: result.code,
|
|
205
|
+
revision: result.revision,
|
|
206
|
+
projectName: link.projectName,
|
|
207
|
+
message: `Saved handoff revision ${result.revision} for "${link.projectName}". Memory did not transfer code.`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { handoffTreeOf };
|
package/src/snapshot.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this machine may say about a working tree: repository identity,
|
|
3
|
+
* branch, commit, dirty state, a tree hash, and file names. Never contents.
|
|
4
|
+
*/
|
|
5
|
+
import { git, repoIdentity, worktreeTreeHash } from "../vendor/hypher-git.mjs";
|
|
6
|
+
|
|
7
|
+
/** Changed file names, one per file (untracked directories expanded), from `git status -z`. */
|
|
8
|
+
export function changedFileNames(root) {
|
|
9
|
+
const out = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { trim: false });
|
|
10
|
+
if (!out) return [];
|
|
11
|
+
const parts = out.split("\0");
|
|
12
|
+
const names = [];
|
|
13
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
14
|
+
const entry = parts[index];
|
|
15
|
+
if (entry.length < 4) continue;
|
|
16
|
+
names.push(entry.slice(3));
|
|
17
|
+
// A rename or copy is followed by its original path; the new name is enough.
|
|
18
|
+
if (entry[0] === "R" || entry[0] === "C") index += 1;
|
|
19
|
+
}
|
|
20
|
+
return names;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} cwd
|
|
25
|
+
* @returns {null | { root: string, repository: string | null, branch: string, commit: string, dirty: boolean,
|
|
26
|
+
* tree: string | null, changedFiles: string[], currentRepo: object }}
|
|
27
|
+
*/
|
|
28
|
+
export function takeSnapshot(cwd) {
|
|
29
|
+
const identity = repoIdentity(cwd);
|
|
30
|
+
if (!identity.root || !identity.commit || !identity.branch) return null;
|
|
31
|
+
const tree = worktreeTreeHash(cwd);
|
|
32
|
+
const changedFiles = identity.dirty ? changedFileNames(identity.root) : [];
|
|
33
|
+
const currentRepo = {
|
|
34
|
+
...(identity.repository ? { repository: identity.repository } : {}),
|
|
35
|
+
branch: identity.branch,
|
|
36
|
+
commit: identity.commit,
|
|
37
|
+
dirty: identity.dirty,
|
|
38
|
+
...(identity.dirty && tree ? { dirtyFingerprint: `tree:${tree}` } : {}),
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
root: identity.root,
|
|
42
|
+
repository: identity.repository,
|
|
43
|
+
branch: identity.branch,
|
|
44
|
+
commit: identity.commit,
|
|
45
|
+
dirty: identity.dirty,
|
|
46
|
+
tree,
|
|
47
|
+
changedFiles,
|
|
48
|
+
currentRepo,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The tree a handoff recorded: its fingerprint when dirty, else its commit's tree. */
|
|
53
|
+
export function handoffTreeOf(repo) {
|
|
54
|
+
if (typeof repo?.dirtyFingerprint === "string" && repo.dirtyFingerprint.startsWith("tree:")) return repo.dirtyFingerprint.slice(5);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local session state. Per worktree: the revision this session was handed
|
|
3
|
+
* (every save is based on it), the receipt to acknowledge, and the proposal to
|
|
4
|
+
* carry forward. Per worktree and branch: the tree at the last seal and when
|
|
5
|
+
* Hypher last asked for one.
|
|
6
|
+
*/
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { paths } from "./env.mjs";
|
|
9
|
+
import { readJson, sha256, writeJson } from "./fsutil.mjs";
|
|
10
|
+
|
|
11
|
+
function worktreeFile(env, root) {
|
|
12
|
+
return path.join(paths(env).stateDir, "worktrees", `${sha256(String(root)).slice(0, 20)}.json`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} WorktreeState
|
|
17
|
+
* @property {string} root
|
|
18
|
+
* @property {string} repository
|
|
19
|
+
* @property {string} projectId
|
|
20
|
+
* @property {number} baseRevision 0 when no handoff existed.
|
|
21
|
+
* @property {any} [proposal] The proposal at baseRevision.
|
|
22
|
+
* @property {string} [receiptId]
|
|
23
|
+
* @property {number} [receiptRevision] The revision the receipt was prepared for.
|
|
24
|
+
* @property {string} [destination]
|
|
25
|
+
* @property {string} [agent]
|
|
26
|
+
* @property {string} [sessionId]
|
|
27
|
+
* @property {number} [resumedAt]
|
|
28
|
+
* @property {Record<string, { sealTree?: string, askedAt?: number, askedTree?: string, blockedLast?: boolean }>} [branches]
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** @returns {WorktreeState | null} */
|
|
32
|
+
export function readWorktreeState(env, root) {
|
|
33
|
+
return root ? readJson(worktreeFile(env, root)) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writeWorktreeState(env, root, state) {
|
|
37
|
+
writeJson(worktreeFile(env, root), { ...state, root }, { mode: 0o600 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function updateWorktreeState(env, root, patch) {
|
|
41
|
+
const current = readWorktreeState(env, root) ?? {};
|
|
42
|
+
const next = typeof patch === "function" ? patch(current) : { ...current, ...patch };
|
|
43
|
+
writeWorktreeState(env, root, next);
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function branchState(state, branch) {
|
|
48
|
+
return state?.branches?.[branch] ?? {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function withBranchState(state, branch, patch) {
|
|
52
|
+
return { ...state, branches: { ...(state.branches ?? {}), [branch]: { ...branchState(state, branch), ...patch } } };
|
|
53
|
+
}
|
package/src/tools.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the agent tools on this machine. Only config commands (`--version`,
|
|
3
|
+
* `mcp add`, `mcp remove`) ever run; never a model turn.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { paths } from "./env.mjs";
|
|
9
|
+
import { readJson } from "./fsutil.mjs";
|
|
10
|
+
|
|
11
|
+
function run(bin, args, env, timeout = 15_000) {
|
|
12
|
+
const result = spawnSync(bin, args, { env, encoding: "utf8", timeout, stdio: ["ignore", "pipe", "pipe"] });
|
|
13
|
+
return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "", error: result.error };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function version(bin, env) {
|
|
17
|
+
const result = run(bin, ["--version"], env);
|
|
18
|
+
if (result.error || result.status !== 0) return null;
|
|
19
|
+
return result.stdout.match(/\d+\.\d+\.\d+[\w.-]*/)?.[0] ?? (result.stdout.trim().slice(0, 40) || "unknown");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {NodeJS.ProcessEnv} env */
|
|
23
|
+
export function detectTools(env = process.env) {
|
|
24
|
+
const p = paths(env);
|
|
25
|
+
const claude = version("claude", env);
|
|
26
|
+
const codex = version("codex", env);
|
|
27
|
+
const cursor = version("cursor", env) ?? (existsSync(path.join(p.home, ".cursor")) ? "installed" : null);
|
|
28
|
+
return {
|
|
29
|
+
claude: { found: Boolean(claude), version: claude },
|
|
30
|
+
codex: { found: Boolean(codex), version: codex },
|
|
31
|
+
cursor: { found: Boolean(cursor), version: cursor },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The user-scope `hypher` MCP entry Claude Code has, if any. */
|
|
36
|
+
export function claudeHypherServer(env = process.env) {
|
|
37
|
+
const config = readJson(paths(env).claudeJson, {});
|
|
38
|
+
return config?.mcpServers?.hypher ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when the entry is M0a's hosted sign-in (an http server), not the local bridge. */
|
|
42
|
+
export function isSignInEntry(entry) {
|
|
43
|
+
return Boolean(entry && (entry.type === "http" || entry.type === "sse" || typeof entry.url === "string"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function claudeMcpAdd(env, shim) {
|
|
47
|
+
return run("claude", ["mcp", "add", "--scope", "user", "hypher", "--", shim, "mcp"], env);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function claudeMcpRemove(env) {
|
|
51
|
+
return run("claude", ["mcp", "remove", "hypher", "--scope", "user"], env);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A project-level `hypher` entry in this repository overrides the user-level one inside it. */
|
|
55
|
+
export function projectLevelHypherEntry(repoRoot) {
|
|
56
|
+
if (!repoRoot) return null;
|
|
57
|
+
const file = path.join(repoRoot, ".mcp.json");
|
|
58
|
+
const entry = readJson(file, {})?.mcpServers?.hypher;
|
|
59
|
+
return entry ? { file, entry } : null;
|
|
60
|
+
}
|