@litterthanlit/hypher-cli 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/api.mjs +31 -6
- package/src/claude.mjs +22 -1
- package/src/commands/doctor.mjs +27 -6
- package/src/commands/hook.mjs +1 -1
- package/src/commands/init.mjs +2 -2
- package/src/commands/status.mjs +37 -1
- package/src/env.mjs +1 -1
- package/src/hookConfig.mjs +16 -7
- package/src/hooks/index.mjs +43 -1
- package/src/outbox.mjs +37 -1
- package/src/throttle.mjs +46 -0
package/README.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
Pilot · not yet recorded. The bridge between coding agents and Hypher project memory.
|
|
4
4
|
|
|
5
5
|
- `hypher init` connects this computer (one browser approval), installs a pinned copy under `~/.hypher/`, and adds Claude Code hooks and Codex hooks (Codex asks once to trust them: `/hooks`). It keeps M0a's `hypher` sign-in entry wherever it finds one.
|
|
6
|
-
- `hypher doctor` checks each piece and prints the fix for anything red.
|
|
6
|
+
- `hypher doctor` checks each piece and prints the fix for anything red. When Hypher is unreachable, or something between this computer and it answers instead (a proxy's `403 Host not in allowlist`, an HTML page, an empty body), it names the host it tried and the status and content type that came back, never the body.
|
|
7
|
+
- An answer that is not Hypher's own JSON counts as Hypher being unavailable, not as Hypher saying no: a queued checkpoint stays in the outbox until it uploads, and the session starts with "Hypher unavailable" (built, released with 0.2.1; not recorded). When Hypher is unavailable, not connected, or the repository is not linked, the `SessionStart` hook also puts that one line in front of you (`systemMessage`), not only in front of the model.
|
|
8
|
+
- **When checkpoints are written (0.2.1; built and rehearsed, not recorded).** In Claude Code a checkpoint (identity, tree hash, file names, never contents) is queued when a session starts, after the note is read; after a file edit (`Edit`, `Write`, `MultiEdit`, `NotebookEdit`), at most every 10 seconds; after a command (`Bash`), at most every 60 seconds; and at turn end, before compaction, and at exit as before. So a session killed mid-turn leaves a checkpoint at most about 10 seconds behind its last edit. The new `PostToolUse` hook reads only `cwd` and the tool's name, never the tool's input, and does not touch the network; checkpoints upload when the next session starts here. `hypher status` prints the newest checkpoint of this worktree: its event, the clock time (local and UTC), its age, and whether it is still queued or uploaded. Re-run `npx @litterthanlit/hypher-cli@latest init` to add the hook to a 0.2.0 install. Codex is unchanged (no `PostToolUse` hook, no `SessionStart` checkpoint): that is M4.
|
|
7
9
|
- `hypher resume` prints the note for this repository; `hypher seal` saves a handoff. `hypher resume --full` prints every item the note cut ("N more"); with `--json` it adds the whole decision ledger under `ledger`.
|
|
8
10
|
- `hypher seal --evidence "<command>=<exit code>"` (repeatable, split at the last `=`) reports a check you ran. Hypher binds each to the working tree at the save and labels it an agent report (only agent reports are accepted until hooks capture checks in Baton M3); the next note says whether that tree still matches. It never runs the command. Only the command text, exit code, tree hash, and time leave the machine, never output. Against a server that does not accept evidence yet, the handoff saves without it and says so.
|
|
9
11
|
- `hypher mcp` is the bridge for any MCP client: `resume`, `seal`, and `acknowledge`, with no project id, revision, or key to fill in. It also fills the repository and branch into a bare `read_ledger` call.
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -9,12 +9,21 @@ import { apiBase, CLI_VERSION, paths } from "./env.mjs";
|
|
|
9
9
|
import { readConfig } from "./config.mjs";
|
|
10
10
|
import { loadCredential } from "./credentials.mjs";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Hypher could not be reached, or what answered is not Hypher: a timeout, a 5xx, or any answer that is not
|
|
14
|
+
* Hypher's own JSON (a proxy's 403 in plain text, an HTML page, an empty body). `answer` says what came back,
|
|
15
|
+
* as a status and a content type, never a body, so `hypher doctor` can name it.
|
|
16
|
+
* @param {string} base
|
|
17
|
+
* @param {unknown} cause
|
|
18
|
+
* @param {{ status: number, contentType: string } | null} [answer]
|
|
19
|
+
*/
|
|
12
20
|
export class HypherUnavailable extends Error {
|
|
13
|
-
constructor(base, cause) {
|
|
21
|
+
constructor(base, cause, answer = null) {
|
|
14
22
|
super(`Hypher unavailable at ${base}`);
|
|
15
23
|
this.name = "HypherUnavailable";
|
|
16
24
|
this.base = base;
|
|
17
25
|
this.causeText = cause instanceof Error ? cause.message : String(cause ?? "");
|
|
26
|
+
this.answer = answer;
|
|
18
27
|
}
|
|
19
28
|
}
|
|
20
29
|
|
|
@@ -29,6 +38,16 @@ export class HypherRejected extends Error {
|
|
|
29
38
|
|
|
30
39
|
const MAX_LOG_BYTES = 20_000;
|
|
31
40
|
|
|
41
|
+
/** The parsed body when it is a JSON object (Hypher's shape for every answer), else null. */
|
|
42
|
+
function ownJson(text) {
|
|
43
|
+
try {
|
|
44
|
+
const value = JSON.parse(text);
|
|
45
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
function logSent(env, entry) {
|
|
33
52
|
try {
|
|
34
53
|
const file = paths(env).sentLog;
|
|
@@ -70,13 +89,19 @@ export function createClient({ env = process.env, apiKey, timeoutMs = 4_000 } =
|
|
|
70
89
|
} catch (error) {
|
|
71
90
|
throw new HypherUnavailable(base, error);
|
|
72
91
|
}
|
|
73
|
-
|
|
92
|
+
const contentType = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
93
|
+
const answer = { status: response.status, contentType };
|
|
94
|
+
let text;
|
|
74
95
|
try {
|
|
75
|
-
|
|
76
|
-
} catch {
|
|
77
|
-
|
|
96
|
+
text = await response.text();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw new HypherUnavailable(base, error, answer);
|
|
78
99
|
}
|
|
79
|
-
if (response.status >= 500) throw new HypherUnavailable(base, `HTTP ${response.status}
|
|
100
|
+
if (response.status >= 500) throw new HypherUnavailable(base, `HTTP ${response.status}`, answer);
|
|
101
|
+
const json = ownJson(text);
|
|
102
|
+
// The one rule: Hypher answers in JSON, every time, refusals included. Anything else came from something
|
|
103
|
+
// between this computer and Hypher (a proxy, a captive portal), so Hypher is unavailable, not saying no.
|
|
104
|
+
if (json === null) throw new HypherUnavailable(base, `HTTP ${response.status} that is not Hypher's answer`, answer);
|
|
80
105
|
return { status: response.status, ok: response.ok, json };
|
|
81
106
|
}
|
|
82
107
|
|
package/src/claude.mjs
CHANGED
|
@@ -3,18 +3,32 @@
|
|
|
3
3
|
* merges them into ~/.claude/settings.json without touching the builder's
|
|
4
4
|
* own entries, and `uninstall` takes out only these.
|
|
5
5
|
*/
|
|
6
|
-
import { hookCommand as taggedCommand, installedEvents, isTaggedCommand, mergeHooks, removeHooks, renderJson } from "./hookConfig.mjs";
|
|
6
|
+
import { hookCommand as taggedCommand, installedEvents, installedSpecs, isTaggedCommand, mergeHooks, removeHooks, renderJson } from "./hookConfig.mjs";
|
|
7
7
|
|
|
8
8
|
/** Every Hypher hook command carries this, so it can be found again. */
|
|
9
9
|
export const HOOK_TAG = "hook claude-code";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* PostToolUse is two entries, one per tool matcher: a file edit (a checkpoint at most every 10 seconds) and a
|
|
13
|
+
* command (at most every 60). The handler tells them apart by the payload's `tool_name`. The 5-second timeout is
|
|
14
|
+
* a ceiling; a throttled call answers in well under a quarter of a second. Codex has no PostToolUse hook (M4).
|
|
15
|
+
*/
|
|
16
|
+
export const EDIT_TOOLS_MATCHER = "Edit|Write|MultiEdit|NotebookEdit";
|
|
11
17
|
export const CLAUDE_HOOKS = [
|
|
12
18
|
{ event: "SessionStart", matcher: "startup|resume|clear|compact", timeout: 10 },
|
|
13
19
|
{ event: "Stop", timeout: 10 },
|
|
14
20
|
{ event: "PreCompact", timeout: 15 },
|
|
15
21
|
{ event: "SessionEnd", timeout: 15 },
|
|
22
|
+
{ event: "PostToolUse", matcher: EDIT_TOOLS_MATCHER, timeout: 5 },
|
|
23
|
+
{ event: "PostToolUse", matcher: "Bash", timeout: 5 },
|
|
16
24
|
];
|
|
17
25
|
|
|
26
|
+
/** A spec's name for a person: its event, and for an event with several entries, its matcher too. */
|
|
27
|
+
export function hookLabel(spec) {
|
|
28
|
+
const shared = CLAUDE_HOOKS.filter((other) => other.event === spec.event).length > 1;
|
|
29
|
+
return shared ? `${spec.event} (${spec.matcher})` : spec.event;
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
export function hookCommand(shim, event) {
|
|
19
33
|
return taggedCommand(shim, HOOK_TAG, event);
|
|
20
34
|
}
|
|
@@ -35,10 +49,17 @@ export function removeClaudeHooks(settings) {
|
|
|
35
49
|
return removeHooks(settings, HOOK_TAG);
|
|
36
50
|
}
|
|
37
51
|
|
|
52
|
+
/** The events Hypher has a hook for, each once. */
|
|
38
53
|
export function installedHookEvents(settings) {
|
|
39
54
|
return installedEvents(settings, CLAUDE_HOOKS, HOOK_TAG);
|
|
40
55
|
}
|
|
41
56
|
|
|
57
|
+
/** The hooks `init` would add that are not in these settings, by name. Empty when everything is there. */
|
|
58
|
+
export function missingHooks(settings) {
|
|
59
|
+
const present = new Set(installedSpecs(settings, CLAUDE_HOOKS, HOOK_TAG));
|
|
60
|
+
return CLAUDE_HOOKS.filter((spec) => !present.has(spec)).map(hookLabel);
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
export const renderSettings = renderJson;
|
|
43
64
|
|
|
44
65
|
/** Clean edited settings text; null when it is not JSON we can safely rewrite. */
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { CLI_VERSION, INTEGRATIONS_URL, inCodexSandbox, paths } from "../env.mjs
|
|
|
7
7
|
import { linkFor, readConfig } from "../config.mjs";
|
|
8
8
|
import { loadCredential } from "../credentials.mjs";
|
|
9
9
|
import { createClient, HypherRejected, HypherUnavailable } from "../api.mjs";
|
|
10
|
-
import { installedHookEvents,
|
|
10
|
+
import { installedHookEvents, missingHooks } from "../claude.mjs";
|
|
11
11
|
import { claudeHypherServer, detectTools, isSignInEntry, projectLevelHypherEntry } from "../tools.mjs";
|
|
12
12
|
import { listOutbox } from "../outbox.mjs";
|
|
13
13
|
import { readBytes, readJson } from "../fsutil.mjs";
|
|
@@ -19,6 +19,25 @@ function pad(text, width = 22) {
|
|
|
19
19
|
return text.length >= width ? `${text} ` : text + " ".repeat(width - text.length);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function hostOf(base) {
|
|
23
|
+
try {
|
|
24
|
+
return new URL(base).host;
|
|
25
|
+
} catch {
|
|
26
|
+
return base;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What was tried and what came back: status and content type, never a body. */
|
|
31
|
+
function unavailableDetail(base, error) {
|
|
32
|
+
const host = hostOf(base);
|
|
33
|
+
const answer = error.answer;
|
|
34
|
+
if (!answer) return `${base} (${error.causeText || "no answer"})`;
|
|
35
|
+
const type = answer.contentType || "no content type";
|
|
36
|
+
return answer.status >= 500
|
|
37
|
+
? `${host} answered HTTP ${answer.status} (${type})`
|
|
38
|
+
: `${host} answered HTTP ${answer.status} (${type}), which is not Hypher's answer`;
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
export async function run({ io }) {
|
|
23
42
|
const env = io.env;
|
|
24
43
|
const p = paths(env);
|
|
@@ -46,11 +65,13 @@ export async function run({ io }) {
|
|
|
46
65
|
|
|
47
66
|
const tools = detectTools(env);
|
|
48
67
|
if (tools.claude.found) {
|
|
49
|
-
const
|
|
68
|
+
const settings = readJson(p.claudeSettings, {}) ?? {};
|
|
69
|
+
const events = installedHookEvents(settings);
|
|
70
|
+
const missing = missingHooks(settings);
|
|
50
71
|
const entry = claudeHypherServer(env);
|
|
51
72
|
const mcp = entry ? (isSignInEntry(entry) ? "MCP hypher (sign-in)" : "MCP hypher (bridge)") : "no hypher MCP entry";
|
|
52
|
-
if (
|
|
53
|
-
else add("✗", `Claude Code ${tools.claude.version}`, `hooks missing: ${
|
|
73
|
+
if (missing.length === 0) add("✓", `Claude Code ${tools.claude.version}`, `hooks ${events.join(", ")} · ${mcp}`);
|
|
74
|
+
else add("✗", `Claude Code ${tools.claude.version}`, `hooks missing: ${missing.join(", ")}`, "run `hypher init`.");
|
|
54
75
|
} else {
|
|
55
76
|
add("–", "Claude Code", "not found");
|
|
56
77
|
}
|
|
@@ -115,8 +136,8 @@ export async function run({ io }) {
|
|
|
115
136
|
if (error instanceof HypherUnavailable && sandboxed) {
|
|
116
137
|
add("!", "Hypher reachable", `not reachable inside Codex's sandbox (${client.base})`);
|
|
117
138
|
} else if (error instanceof HypherUnavailable) {
|
|
118
|
-
add("✗", "Hypher unavailable",
|
|
119
|
-
"check your network, VPN, or proxy, then run
|
|
139
|
+
add("✗", "Hypher unavailable", unavailableDetail(client.base, error),
|
|
140
|
+
`${error.answer && error.answer.status < 500 ? `something between this computer and ${hostOf(client.base)} (a proxy, VPN, or firewall) is answering for Hypher: allow ${hostOf(client.base)}, then` : "check your network, VPN, or proxy, then"} run \`hypher doctor\` again. Sessions start without Hypher until then, and queued checkpoints are kept.`);
|
|
120
141
|
} else if (error instanceof HypherRejected) {
|
|
121
142
|
add("✗", "Hypher reachable", error.message, "run `hypher init` to connect this computer again.");
|
|
122
143
|
} else {
|
package/src/commands/hook.mjs
CHANGED
|
@@ -47,7 +47,7 @@ export async function run({ args, io }) {
|
|
|
47
47
|
} catch (error) {
|
|
48
48
|
logError(io.env, event, error);
|
|
49
49
|
if (event === "SessionStart") {
|
|
50
|
-
io.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: HYPHER_UNAVAILABLE_LINE } })}\n`);
|
|
50
|
+
io.stdout.write(`${JSON.stringify({ systemMessage: HYPHER_UNAVAILABLE_LINE, hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: HYPHER_UNAVAILABLE_LINE } })}\n`);
|
|
51
51
|
}
|
|
52
52
|
} finally {
|
|
53
53
|
// A client that keeps stdin open must not keep this hook alive.
|
package/src/commands/init.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { credentialPromptLine, credentialStore, loadCredential, saveCredential }
|
|
|
14
14
|
import { createClient, HypherUnavailable } from "../api.mjs";
|
|
15
15
|
import { runDeviceFlow } from "../device.mjs";
|
|
16
16
|
import { readManifest, recordEntry, writeManifest, writeTrackedFile } from "../manifest.mjs";
|
|
17
|
-
import { mergeClaudeHooks, renderSettings,
|
|
17
|
+
import { mergeClaudeHooks, renderSettings, installedHookEvents } from "../claude.mjs";
|
|
18
18
|
import { claudeHypherServer, claudeMcpAdd, detectTools, isSignInEntry } from "../tools.mjs";
|
|
19
19
|
import { readBytes, readJson } from "../fsutil.mjs";
|
|
20
20
|
import { CODEX_HOOKS, CODEX_TRUST_LINE, addBridge, codexHookTrust, codexHypherEntry, codexSupportsHooks, mergeCodexHooks, readCodexHooks } from "../codex.mjs";
|
|
@@ -194,7 +194,7 @@ export async function run({ flags, io }) {
|
|
|
194
194
|
const settings = readJson(p.claudeSettings, {}) ?? {};
|
|
195
195
|
const merged = mergeClaudeHooks(settings && typeof settings === "object" ? settings : {}, p.shim);
|
|
196
196
|
const change = writeTrackedFile(manifest, p.claudeSettings, renderSettings(merged), "claude-settings");
|
|
197
|
-
if (change !== "unchanged") changed.push([p.claudeSettings, `+ hooks ${
|
|
197
|
+
if (change !== "unchanged") changed.push([p.claudeSettings, `+ hooks ${installedHookEvents(merged).join(", ")}`]);
|
|
198
198
|
writeManifest(manifest, env);
|
|
199
199
|
if (!claudeEntry) {
|
|
200
200
|
const added = claudeMcpAdd(env, p.shim);
|
package/src/commands/status.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { readFileSync } from "node:fs";
|
|
|
6
6
|
import { CLI_VERSION, paths } from "../env.mjs";
|
|
7
7
|
import { readConfig, linkFor } from "../config.mjs";
|
|
8
8
|
import { loadCredential } from "../credentials.mjs";
|
|
9
|
-
import { listOutbox } from "../outbox.mjs";
|
|
9
|
+
import { listOutbox, newestCheckpoint } from "../outbox.mjs";
|
|
10
10
|
import { repoIdentity } from "../../vendor/hypher-git.mjs";
|
|
11
11
|
|
|
12
12
|
export function lastSent(env) {
|
|
@@ -18,6 +18,39 @@ export function lastSent(env) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
22
|
+
|
|
23
|
+
/** The computer's clock time, like 14:32:05, and the same instant in UTC as an ISO string. */
|
|
24
|
+
export function clockTimes(ms) {
|
|
25
|
+
const at = new Date(ms);
|
|
26
|
+
return { local: `${pad2(at.getHours())}:${pad2(at.getMinutes())}:${pad2(at.getSeconds())}`, utc: at.toISOString() };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** How long ago, in words a person reads at a glance. */
|
|
30
|
+
export function ageWords(ms) {
|
|
31
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
32
|
+
if (seconds < 90) return `${seconds} s ago`;
|
|
33
|
+
const minutes = Math.round(seconds / 60);
|
|
34
|
+
if (minutes < 90) return `${minutes} min ago`;
|
|
35
|
+
return `${Math.round(minutes / 60)} h ago`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The lines for the newest checkpoint of this worktree: its event, clock time, age, and whether it has left this computer. */
|
|
39
|
+
export function checkpointLines(found, now) {
|
|
40
|
+
if (!found) return [" Newest checkpoint: none yet for this worktree (one is written when a session starts and as files change)"];
|
|
41
|
+
const { body } = found;
|
|
42
|
+
const times = clockTimes(body.capturedAt);
|
|
43
|
+
const files = Array.isArray(body.changedFiles) ? body.changedFiles : [];
|
|
44
|
+
const where = found.state === "queued"
|
|
45
|
+
? "Queued on this computer, not uploaded yet: it uploads at the next session start in this repository."
|
|
46
|
+
: `Uploaded to Hypher at ${clockTimes(found.uploadedAt).local} local.`;
|
|
47
|
+
return [
|
|
48
|
+
` Newest checkpoint: ${body.event} · ${times.local} local (${times.utc} UTC) · ${ageWords(now - body.capturedAt)}`,
|
|
49
|
+
` ${where}`,
|
|
50
|
+
` ${files.length ? `files: ${files.slice(0, 12).join(", ")}${files.length > 12 ? ` (+${files.length - 12} more)` : ""}` : "no changed files"}`,
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
21
54
|
export async function run({ flags, io }) {
|
|
22
55
|
const env = io.env;
|
|
23
56
|
if (flags.last) {
|
|
@@ -34,5 +67,8 @@ export async function run({ flags, io }) {
|
|
|
34
67
|
io.out(` Connected: ${credential.apiKey ? `yes · key "${config?.keyName ?? "Hypher CLI"}" (${credential.source})` : "no"}`);
|
|
35
68
|
io.out(` Repository: ${identity.repository ?? "none"}${identity.repository ? (link ? ` → "${link.projectName}"` : " (not linked; run hypher link)") : ""}`);
|
|
36
69
|
io.out(` Outbox: ${listOutbox(env).length} checkpoint(s) waiting`);
|
|
70
|
+
if (identity.repository && identity.branch) {
|
|
71
|
+
for (const line of checkpointLines(newestCheckpoint(env, identity.repository, identity.branch), Date.now())) io.out(line);
|
|
72
|
+
}
|
|
37
73
|
return 0;
|
|
38
74
|
}
|
package/src/env.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import os from "node:os";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
|
|
9
9
|
// Keep equal to package.json; a test checks it.
|
|
10
|
-
export const CLI_VERSION = "0.2.
|
|
10
|
+
export const CLI_VERSION = "0.2.1";
|
|
11
11
|
export const DEFAULT_API_BASE = "https://www.hypher.app";
|
|
12
12
|
export const INTEGRATIONS_URL = "https://hypher.app/app/settings/integrations";
|
|
13
13
|
export const API_KEYS_URL = "https://hypher.app/app/settings/api-keys";
|
package/src/hookConfig.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* { hooks: { <Event>: [{ matcher?, hooks: [{ type: "command", command, timeout }] }] } }.
|
|
4
4
|
* Hypher's entries carry a tag in their command, so they can be found again,
|
|
5
5
|
* and are appended after the builder's own, so removing them never shifts
|
|
6
|
-
* the position of anything the builder added.
|
|
6
|
+
* the position of anything the builder added. An event can have several specs
|
|
7
|
+
* (Claude Code's PostToolUse has one per tool matcher); each is one group.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
/** @param {string} tag e.g. "hook claude-code" */
|
|
@@ -37,13 +38,15 @@ function withoutTagged(groups, tag) {
|
|
|
37
38
|
*/
|
|
38
39
|
export function mergeHooks(config, specs, shim, tag) {
|
|
39
40
|
const next = { ...config, hooks: { ...(config.hooks ?? {}) } };
|
|
41
|
+
// Hypher's old groups for an event all go first, so two specs of one event never remove each other.
|
|
42
|
+
for (const event of new Set(specs.map((spec) => spec.event))) {
|
|
43
|
+
next.hooks[event] = withoutTagged(next.hooks[event], tag);
|
|
44
|
+
}
|
|
40
45
|
for (const spec of specs) {
|
|
41
|
-
|
|
42
|
-
groups.push({
|
|
46
|
+
next.hooks[spec.event].push({
|
|
43
47
|
...(spec.matcher ? { matcher: spec.matcher } : {}),
|
|
44
48
|
hooks: [{ type: "command", command: hookCommand(shim, tag, spec.event), timeout: spec.timeout }],
|
|
45
49
|
});
|
|
46
|
-
next.hooks[spec.event] = groups;
|
|
47
50
|
}
|
|
48
51
|
return next;
|
|
49
52
|
}
|
|
@@ -61,11 +64,17 @@ export function removeHooks(config, tag) {
|
|
|
61
64
|
return next;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
/** The specs whose hook is in the file: a tagged command in a group of the spec's event (with the spec's matcher, when it has one). */
|
|
68
|
+
export function installedSpecs(config, specs, tag) {
|
|
65
69
|
return specs.filter((spec) =>
|
|
66
70
|
(config?.hooks?.[spec.event] ?? []).some((group) =>
|
|
67
|
-
(group?.
|
|
68
|
-
|
|
71
|
+
(!spec.matcher || group?.matcher === spec.matcher)
|
|
72
|
+
&& (group?.hooks ?? []).some((hook) => isTaggedCommand(hook?.command, tag))));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The events with at least one spec installed, each once, in the specs' order. */
|
|
76
|
+
export function installedEvents(config, specs, tag) {
|
|
77
|
+
return [...new Set(installedSpecs(config, specs, tag).map((spec) => spec.event))];
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
/** Where each tagged hook sits: [event, groupIndex, hookIndex]. Codex keys its trust by this position. */
|
package/src/hooks/index.mjs
CHANGED
|
@@ -5,11 +5,17 @@
|
|
|
5
5
|
* once for a seal when the tree changed. Hooks never acknowledge, never block
|
|
6
6
|
* twice in a row, and never block on Hypher being down. In a repository no
|
|
7
7
|
* human linked they record and upload nothing.
|
|
8
|
+
*
|
|
9
|
+
* Claude Code alone (Codex parity is M4) also records a checkpoint after the note is read at SessionStart, and
|
|
10
|
+
* after a tool ran (PostToolUse: a file edit at most every 10 seconds, a command at most every 60), so a session
|
|
11
|
+
* killed in the middle of a turn still leaves one. PostToolUse reads only `cwd` and `tool_name` from its payload:
|
|
12
|
+
* the rest is the tool's input, which can be file contents, and is never looked at.
|
|
8
13
|
*/
|
|
9
14
|
import { createClient } from "../api.mjs";
|
|
10
15
|
import { linkFor } from "../config.mjs";
|
|
11
16
|
import { enqueue, flushOutbox, shouldFlush } from "../outbox.mjs";
|
|
12
17
|
import { resumeHere } from "../session.mjs";
|
|
18
|
+
import { claimCheckpoint, toolKind } from "../throttle.mjs";
|
|
13
19
|
import { takeSnapshot } from "../snapshot.mjs";
|
|
14
20
|
import { branchState, readWorktreeState, updateWorktreeState, withBranchState } from "../state.mjs";
|
|
15
21
|
import { findSecret } from "../secrets.mjs";
|
|
@@ -67,6 +73,19 @@ function sealRequest(firstHandoff, ledgerOn = false) {
|
|
|
67
73
|
].join(" ");
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
/** The resume statuses whose one line is also shown to the person (`systemMessage`), not only given to the model. */
|
|
77
|
+
const PERSON_SEES = new Set(["unavailable", "not-connected", "unlinked"]);
|
|
78
|
+
|
|
79
|
+
/** The SessionStart checkpoint, from the snapshot the read already took. It never fails a session start. */
|
|
80
|
+
function recordStartCheckpoint(env, snapshot, agent) {
|
|
81
|
+
try {
|
|
82
|
+
if (!snapshot?.repository || !snapshot.tree || !linkFor(snapshot.repository, env)) return;
|
|
83
|
+
recordCheckpoint(env, snapshot, "SessionStart", Date.now(), agent);
|
|
84
|
+
} catch {
|
|
85
|
+
// The note is already read; a checkpoint that could not be written is not worth a failed start.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
70
89
|
async function sessionStart(agent, env, input) {
|
|
71
90
|
const result = await resumeHere({
|
|
72
91
|
env,
|
|
@@ -76,7 +95,12 @@ async function sessionStart(agent, env, input) {
|
|
|
76
95
|
timeoutMs: HOOK_TIMEOUT_MS,
|
|
77
96
|
});
|
|
78
97
|
if (result.status === "not-git" || !result.note) return null;
|
|
79
|
-
|
|
98
|
+
// After the note is read and rendered, so the note never names its own checkpoint. Queued, not uploaded:
|
|
99
|
+
// the next session on this computer sends it.
|
|
100
|
+
if (agent === "claude-code") recordStartCheckpoint(env, result.snapshot, agent);
|
|
101
|
+
const output = { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.note } };
|
|
102
|
+
// The person sees these three too: the model alone may never mention that Hypher is not there.
|
|
103
|
+
return PERSON_SEES.has(result.status) ? { systemMessage: result.note, ...output } : output;
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
async function stop(agent, env, input, now = Date.now()) {
|
|
@@ -106,6 +130,22 @@ async function stop(agent, env, input, now = Date.now()) {
|
|
|
106
130
|
return { decision: "block", reason: sealRequest(!state.proposal, state.ledger?.on === true) };
|
|
107
131
|
}
|
|
108
132
|
|
|
133
|
+
/**
|
|
134
|
+
* A tool just ran. Only Claude Code has this hook. The payload carries the tool's input, which can be file
|
|
135
|
+
* contents, so nothing but `cwd` and `tool_name` is read from it. A call inside its window answers from one
|
|
136
|
+
* small file, with no git call. Queued, not uploaded: a laptop's next session sends it.
|
|
137
|
+
*/
|
|
138
|
+
async function postToolUse(agent, env, input, now = Date.now()) {
|
|
139
|
+
if (agent !== "claude-code") return null;
|
|
140
|
+
const kind = toolKind(input.tool_name);
|
|
141
|
+
if (!kind) return null;
|
|
142
|
+
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
143
|
+
if (!claimCheckpoint(env, cwd, kind, now)) return null;
|
|
144
|
+
const found = linkedSnapshot(env, cwd);
|
|
145
|
+
if (found) recordCheckpoint(env, found.snapshot, "PostToolUse", now, agent);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
109
149
|
async function checkpointAndUpload(agent, env, input, event, now = Date.now()) {
|
|
110
150
|
const found = linkedSnapshot(env, input.cwd || process.cwd());
|
|
111
151
|
if (!found) return null;
|
|
@@ -129,6 +169,8 @@ export async function handleAgentHook(agent, event, input, env) {
|
|
|
129
169
|
return await sessionStart(agent, env, input);
|
|
130
170
|
case "Stop":
|
|
131
171
|
return await stop(agent, env, input);
|
|
172
|
+
case "PostToolUse":
|
|
173
|
+
return await postToolUse(agent, env, input);
|
|
132
174
|
case "PreCompact":
|
|
133
175
|
case "SessionEnd":
|
|
134
176
|
return await checkpointAndUpload(agent, env, input, event);
|
package/src/outbox.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { mkdirSync, readdirSync } from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { randomBytes } from "node:crypto";
|
|
10
10
|
import { paths } from "./env.mjs";
|
|
11
|
-
import { readJson, removeFile, writeJson } from "./fsutil.mjs";
|
|
11
|
+
import { readJson, removeFile, sha256, writeJson } from "./fsutil.mjs";
|
|
12
12
|
import { HypherUnavailable } from "./api.mjs";
|
|
13
13
|
|
|
14
14
|
export const FLUSH_AGE_MS = 5 * 60_000;
|
|
@@ -51,6 +51,41 @@ export function enqueue(input, { env = process.env, now = Date.now() } = {}) {
|
|
|
51
51
|
return item;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function uploadedFile(env, key) {
|
|
55
|
+
return path.join(paths(env).stateDir, "uploaded", `${sha256(String(key)).slice(0, 20)}.json`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The newest checkpoint Hypher accepted for a key, kept so `hypher status` can say it was uploaded. Never throws. */
|
|
59
|
+
function rememberUploaded(env, item, now) {
|
|
60
|
+
try {
|
|
61
|
+
const file = uploadedFile(env, item.key);
|
|
62
|
+
const before = readJson(file, null);
|
|
63
|
+
if (typeof before?.body?.capturedAt === "number" && before.body.capturedAt > item.body?.capturedAt) return;
|
|
64
|
+
writeJson(file, { key: item.key, uploadedAt: now, body: item.body }, { mode: 0o600 });
|
|
65
|
+
} catch {
|
|
66
|
+
// Only a convenience for `hypher status`.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The newest checkpoint on this computer for a repository and branch: waiting in the outbox, or the last one
|
|
72
|
+
* Hypher accepted. `null` when there is none.
|
|
73
|
+
* @returns {{ state: "queued" | "uploaded", body: any, queuedAt?: number, uploadedAt?: number } | null}
|
|
74
|
+
*/
|
|
75
|
+
export function newestCheckpoint(env, repository, branch) {
|
|
76
|
+
const key = `${String(repository).toLowerCase()}#${branch}`;
|
|
77
|
+
const queued = listOutbox(env)
|
|
78
|
+
.filter((entry) => entry.item.kind === "checkpoint" && entry.item.key === key)
|
|
79
|
+
.map((entry) => ({ state: "queued", body: entry.item.body, queuedAt: entry.item.queuedAt }))
|
|
80
|
+
.sort((a, b) => (b.body?.capturedAt ?? 0) - (a.body?.capturedAt ?? 0))[0];
|
|
81
|
+
const saved = readJson(uploadedFile(env, key), null);
|
|
82
|
+
const uploaded = typeof saved?.body?.capturedAt === "number"
|
|
83
|
+
? { state: "uploaded", body: saved.body, uploadedAt: saved.uploadedAt }
|
|
84
|
+
: null;
|
|
85
|
+
if (queued && (!uploaded || (queued.body?.capturedAt ?? 0) >= uploaded.body.capturedAt)) return queued;
|
|
86
|
+
return uploaded ?? queued ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
54
89
|
export function oldestAgeMs(env = process.env, now = Date.now()) {
|
|
55
90
|
const [oldest] = listOutbox(env);
|
|
56
91
|
return oldest ? now - oldest.item.queuedAt : 0;
|
|
@@ -75,6 +110,7 @@ export async function flushOutbox(client, { env = process.env, now = Date.now(),
|
|
|
75
110
|
try {
|
|
76
111
|
const response = await client.send("POST", "/api/cli/checkpoints", { body: item.body });
|
|
77
112
|
if (response.ok) {
|
|
113
|
+
rememberUploaded(env, item, now);
|
|
78
114
|
removeFile(file);
|
|
79
115
|
sent += 1;
|
|
80
116
|
continue;
|
package/src/throttle.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How often a PostToolUse hook may record a checkpoint (M3 slice 3). One small file per working directory
|
|
3
|
+
* holds the time of the last checkpoint for each kind of tool, so a call that is too soon answers from that
|
|
4
|
+
* file alone, with no git call. A file edit may record every 10 seconds, a command every 60, each counted
|
|
5
|
+
* on its own: a command does not wait for an edit, nor an edit for a command.
|
|
6
|
+
*/
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { paths } from "./env.mjs";
|
|
9
|
+
import { readJson, sha256, writeJson } from "./fsutil.mjs";
|
|
10
|
+
|
|
11
|
+
export const EDIT_WINDOW_MS = 10_000;
|
|
12
|
+
export const BASH_WINDOW_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
15
|
+
|
|
16
|
+
/** "edit" for a tool that changes files, "bash" for a command, else null (no checkpoint). */
|
|
17
|
+
export function toolKind(toolName) {
|
|
18
|
+
if (typeof toolName !== "string") return null;
|
|
19
|
+
if (EDIT_TOOLS.has(toolName)) return "edit";
|
|
20
|
+
return toolName === "Bash" ? "bash" : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const WINDOWS = { edit: EDIT_WINDOW_MS, bash: BASH_WINDOW_MS };
|
|
24
|
+
|
|
25
|
+
function throttleFile(env, cwd) {
|
|
26
|
+
return path.join(paths(env).stateDir, "throttle", `${sha256(String(cwd)).slice(0, 20)}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True, and counted, when a checkpoint of this kind may be recorded now; false when the last one is too
|
|
31
|
+
* recent. A last time in the future (a clock that moved back) does not block.
|
|
32
|
+
* @param {NodeJS.ProcessEnv} env
|
|
33
|
+
* @param {string} cwd
|
|
34
|
+
* @param {"edit" | "bash"} kind
|
|
35
|
+
* @param {number} now
|
|
36
|
+
*/
|
|
37
|
+
export function claimCheckpoint(env, cwd, kind, now) {
|
|
38
|
+
const file = throttleFile(env, cwd);
|
|
39
|
+
const saved = readJson(file, null);
|
|
40
|
+
const last = saved && typeof saved === "object" && !Array.isArray(saved) ? saved : {};
|
|
41
|
+
const at = last[kind];
|
|
42
|
+
if (typeof at === "number" && now >= at && now - at < WINDOWS[kind]) return false;
|
|
43
|
+
// Counted before the git work, so two hooks started together do not both record.
|
|
44
|
+
writeJson(file, { ...last, [kind]: now }, { mode: 0o600 });
|
|
45
|
+
return true;
|
|
46
|
+
}
|