@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litterthanlit/hypher-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Hypher bridge for coding agents: hooks, a local outbox, and a truth check against the working tree. Pilot, not yet recorded.",
5
5
  "type": "module",
6
6
  "bin": {
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
- let json = null;
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
- json = await response.json();
76
- } catch {
77
- json = null;
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. */
@@ -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, CLAUDE_HOOKS } from "../claude.mjs";
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 events = installedHookEvents(readJson(p.claudeSettings, {}) ?? {});
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 (events.length === CLAUDE_HOOKS.length) add("✓", `Claude Code ${tools.claude.version}`, `hooks ${events.join(", ")} · ${mcp}`);
53
- else add("✗", `Claude Code ${tools.claude.version}`, `hooks missing: ${CLAUDE_HOOKS.map((hook) => hook.event).filter((event) => !events.includes(event)).join(", ")}`, "run `hypher init`.");
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", `${client.base} (${error.causeText || "no answer"})`,
119
- "check your network, VPN, or proxy, then run `hypher doctor` again. Sessions start without Hypher until then.");
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 {
@@ -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.
@@ -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, CLAUDE_HOOKS } from "../claude.mjs";
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 ${CLAUDE_HOOKS.map((hook) => hook.event).join(", ")}`]);
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);
@@ -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.0";
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";
@@ -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
- const groups = withoutTagged(next.hooks[spec.event], tag);
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
- export function installedEvents(config, specs, tag) {
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?.hooks ?? []).some((hook) => isTaggedCommand(hook?.command, tag))))
68
- .map((spec) => spec.event);
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. */
@@ -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
- return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.note } };
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;
@@ -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
+ }