harnery 0.8.0 → 0.10.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 +2 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +8 -0
- package/dist/commands/agents.d.ts +24 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +244 -13
- package/dist/commands/backup.d.ts +5 -4
- package/dist/commands/backup.d.ts.map +1 -1
- package/dist/commands/backup.js +15 -14
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +27 -1
- package/dist/commands/claude-desktop.d.ts +19 -0
- package/dist/commands/claude-desktop.d.ts.map +1 -0
- package/dist/commands/claude-desktop.js +168 -0
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +149 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +37 -0
- package/dist/commands/eml.d.ts +32 -0
- package/dist/commands/eml.d.ts.map +1 -1
- package/dist/commands/eml.js +16 -3
- package/dist/commands/grep.d.ts +35 -2
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +427 -139
- package/dist/commands/harness.d.ts +11 -0
- package/dist/commands/harness.d.ts.map +1 -0
- package/dist/commands/harness.js +118 -0
- package/dist/commands/init.d.ts +15 -5
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +125 -14
- package/dist/commands/presence.d.ts +9 -4
- package/dist/commands/presence.d.ts.map +1 -1
- package/dist/commands/presence.js +88 -5
- package/dist/commands/relay.d.ts +9 -0
- package/dist/commands/relay.d.ts.map +1 -0
- package/dist/commands/relay.js +143 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +5 -0
- package/dist/commands/workflow.d.ts +4 -0
- package/dist/commands/workflow.d.ts.map +1 -0
- package/dist/commands/workflow.js +82 -0
- package/dist/core/agents/canonical-emit.d.ts +15 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +41 -3
- package/dist/core/agents/cli.js +14 -5
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +44 -3
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +26 -1
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +26 -1
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +4 -37
- package/dist/core/agents/rules/stop-hook.d.ts +8 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +8 -0
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +16 -43
- package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +24 -4
- package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +24 -7
- package/dist/core/agents/state/names.d.ts +38 -3
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +46 -7
- package/dist/core/agents/state/pidmap.d.ts +5 -1
- package/dist/core/agents/state/pidmap.d.ts.map +1 -1
- package/dist/core/agents/state/pidmap.js +58 -2
- package/dist/core/agents/state/stale-sweep.d.ts +3 -2
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
- package/dist/core/agents/state/stale-sweep.js +6 -6
- package/dist/core/config.d.ts +77 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +210 -25
- package/dist/core/context/index.d.ts +144 -0
- package/dist/core/context/index.d.ts.map +1 -0
- package/dist/core/context/index.js +380 -0
- package/dist/core/harnesses/bench.d.ts +29 -0
- package/dist/core/harnesses/bench.d.ts.map +1 -0
- package/dist/core/harnesses/bench.js +151 -0
- package/dist/core/harnesses/index.d.ts +9 -0
- package/dist/core/harnesses/index.d.ts.map +1 -0
- package/dist/core/harnesses/index.js +4 -0
- package/dist/core/harnesses/profiles.d.ts +62 -0
- package/dist/core/harnesses/profiles.d.ts.map +1 -0
- package/dist/core/harnesses/profiles.js +115 -0
- package/dist/core/harnesses/registry.d.ts +15 -0
- package/dist/core/harnesses/registry.d.ts.map +1 -0
- package/dist/core/harnesses/registry.js +113 -0
- package/dist/core/harnesses/types.d.ts +64 -0
- package/dist/core/harnesses/types.d.ts.map +1 -0
- package/dist/core/harnesses/types.js +20 -0
- package/dist/core/hooks/cli.js +309 -66
- package/dist/core/hooks/events/schema.d.ts +42 -1
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.d.ts +7 -0
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +4 -0
- package/dist/core/hooks/harness/parse.d.ts +1 -1
- package/dist/core/hooks/harness/parse.d.ts.map +1 -1
- package/dist/core/hooks/harness/parse.js +4 -0
- package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
- package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
- package/dist/core/hooks/resolve/coord-root.js +28 -6
- package/dist/core/presence/blob.d.ts +40 -0
- package/dist/core/presence/blob.d.ts.map +1 -0
- package/dist/core/presence/blob.js +91 -0
- package/dist/core/presence/git.d.ts +68 -0
- package/dist/core/presence/git.d.ts.map +1 -0
- package/dist/core/presence/git.js +173 -0
- package/dist/core/presence/index.d.ts +74 -0
- package/dist/core/presence/index.d.ts.map +1 -0
- package/dist/core/presence/index.js +224 -0
- package/dist/core/presence/relay-client.d.ts +39 -0
- package/dist/core/presence/relay-client.d.ts.map +1 -0
- package/dist/core/presence/relay-client.js +295 -0
- package/dist/core/presence/relay-protocol.d.ts +94 -0
- package/dist/core/presence/relay-protocol.d.ts.map +1 -0
- package/dist/core/presence/relay-protocol.js +169 -0
- package/dist/core/workflow/billing.d.ts +48 -0
- package/dist/core/workflow/billing.d.ts.map +1 -0
- package/dist/core/workflow/billing.js +108 -0
- package/dist/core/workflow/child-env.d.ts +30 -0
- package/dist/core/workflow/child-env.d.ts.map +1 -0
- package/dist/core/workflow/child-env.js +43 -0
- package/dist/core/workflow/engine.d.ts +21 -0
- package/dist/core/workflow/engine.d.ts.map +1 -0
- package/dist/core/workflow/engine.js +349 -0
- package/dist/core/workflow/harnesses.d.ts +17 -0
- package/dist/core/workflow/harnesses.d.ts.map +1 -0
- package/dist/core/workflow/harnesses.js +21 -0
- package/dist/core/workflow/spawn-claude.d.ts +25 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
- package/dist/core/workflow/spawn-claude.js +103 -0
- package/dist/core/workflow/spawn-codex.d.ts +22 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
- package/dist/core/workflow/spawn-codex.js +88 -0
- package/dist/core/workflow/spawn-cursor.d.ts +29 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
- package/dist/core/workflow/spawn-cursor.js +91 -0
- package/dist/core/workflow/types.d.ts +152 -0
- package/dist/core/workflow/types.d.ts.map +1 -0
- package/dist/core/workflow/types.js +9 -0
- package/dist/core/workflow/validate.d.ts +15 -0
- package/dist/core/workflow/validate.d.ts.map +1 -0
- package/dist/core/workflow/validate.js +70 -0
- package/dist/lib/browser/client.d.ts +16 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +22 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +1 -0
- package/dist/lib/browser/launch-args.d.ts +21 -0
- package/dist/lib/browser/launch-args.d.ts.map +1 -0
- package/dist/lib/browser/launch-args.js +32 -0
- package/dist/lib/claude-desktop.d.ts +105 -0
- package/dist/lib/claude-desktop.d.ts.map +1 -0
- package/dist/lib/claude-desktop.js +217 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +5 -2
- package/dist/lib/identities/assume.d.ts +51 -0
- package/dist/lib/identities/assume.d.ts.map +1 -0
- package/dist/lib/identities/assume.js +274 -0
- package/dist/lib/identities/index.d.ts +7 -7
- package/dist/lib/identities/index.d.ts.map +1 -1
- package/dist/lib/identities/index.js +24 -24
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +15 -2
- package/package.json +12 -1
- package/schemas/config.schema.json +88 -19
- package/src/commander.ts +8 -0
- package/src/commands/agents.ts +280 -14
- package/src/commands/backup.ts +17 -16
- package/src/commands/browse.ts +32 -0
- package/src/commands/claude-desktop.ts +215 -0
- package/src/commands/context.ts +174 -1
- package/src/commands/doctor.ts +44 -0
- package/src/commands/eml.ts +22 -4
- package/src/commands/grep.ts +535 -142
- package/src/commands/harness.ts +147 -0
- package/src/commands/init.ts +138 -17
- package/src/commands/presence.ts +111 -5
- package/src/commands/relay.ts +160 -0
- package/src/commands/sync.ts +4 -0
- package/src/commands/workflow.ts +123 -0
- package/src/core/agents/canonical-emit.ts +41 -3
- package/src/core/agents/cli.ts +15 -6
- package/src/core/agents/render/prompt-context.ts +48 -3
- package/src/core/agents/render/session-context.ts +27 -1
- package/src/core/agents/rules/claim-conflict.ts +26 -1
- package/src/core/agents/rules/commit-conflict.ts +4 -33
- package/src/core/agents/rules/stop-hook.ts +17 -0
- package/src/core/agents/session-events.ts +15 -39
- package/src/core/agents/state/heartbeat-projector.ts +23 -2
- package/src/core/agents/state/heartbeat-writer.ts +33 -7
- package/src/core/agents/state/names.ts +68 -9
- package/src/core/agents/state/pidmap.ts +59 -2
- package/src/core/agents/state/stale-sweep.ts +6 -11
- package/src/core/config.ts +271 -24
- package/src/core/context/index.ts +575 -0
- package/src/core/harnesses/bench.ts +214 -0
- package/src/core/harnesses/index.ts +31 -0
- package/src/core/harnesses/profiles.ts +141 -0
- package/src/core/harnesses/registry.ts +140 -0
- package/src/core/harnesses/types.ts +90 -0
- package/src/core/hooks/cli.ts +361 -65
- package/src/core/hooks/events/schema.ts +72 -0
- package/src/core/hooks/harness/events.ts +11 -0
- package/src/core/hooks/harness/parse.ts +7 -1
- package/src/core/hooks/resolve/coord-root.ts +28 -6
- package/src/core/presence/blob.ts +125 -0
- package/src/core/presence/git.ts +191 -0
- package/src/core/presence/index.ts +274 -0
- package/src/core/presence/relay-client.ts +322 -0
- package/src/core/presence/relay-protocol.ts +246 -0
- package/src/core/workflow/billing.ts +152 -0
- package/src/core/workflow/child-env.ts +47 -0
- package/src/core/workflow/engine.ts +403 -0
- package/src/core/workflow/harnesses.ts +33 -0
- package/src/core/workflow/spawn-claude.ts +118 -0
- package/src/core/workflow/spawn-codex.ts +93 -0
- package/src/core/workflow/spawn-cursor.ts +108 -0
- package/src/core/workflow/types.ts +160 -0
- package/src/core/workflow/validate.ts +75 -0
- package/src/lib/browser/client.ts +37 -0
- package/src/lib/browser/index.ts +1 -0
- package/src/lib/browser/launch-args.ts +33 -0
- package/src/lib/claude-desktop.ts +293 -0
- package/src/lib/docs-lint.ts +5 -2
- package/src/lib/identities/assume.ts +363 -0
- package/src/lib/identities/index.ts +28 -24
- package/src/lib/instructions/templates.ts +16 -2
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-machine presence (ADR 0016), phase 1: the git-refs transport.
|
|
3
|
+
*
|
|
4
|
+
* publishPresence(): serialize this machine's live sessions into a presence
|
|
5
|
+
* blob and force-push it to `refs/harnery/presence/<machine>` on origin —
|
|
6
|
+
* change-batched (only when the blob materially changed) with a keepalive
|
|
7
|
+
* (~5 min) so peers can distinguish "no change" from "machine gone".
|
|
8
|
+
*
|
|
9
|
+
* fetchPresence(): throttled fetch of every peer machine's presence ref.
|
|
10
|
+
*
|
|
11
|
+
* readRemoteMachines(): parse the locally-known refs into render-ready rows
|
|
12
|
+
* (self machine excluded, stale blobs dropped).
|
|
13
|
+
*
|
|
14
|
+
* All entry points are fail-silent and gated on `presenceEnabled()` — presence
|
|
15
|
+
* must never break a hook, a render, or a repo with no origin.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, utimesSync, writeFileSync, } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { coordEnv } from "../../lib/env.js";
|
|
20
|
+
import { resolveMachineLabel } from "../../lib/machine.js";
|
|
21
|
+
import { presenceEnabled } from "../config.js";
|
|
22
|
+
import { buildPresenceBlob } from "./blob.js";
|
|
23
|
+
import { fetchPresenceRefs, hasOrigin, pushPresenceRef, readPresenceRefs, sanitizeRefComponent, writePresenceCommit, } from "./git.js";
|
|
24
|
+
export { buildPresenceBlob } from "./blob.js";
|
|
25
|
+
export { PRESENCE_REF_PREFIX, parseForEachRefOutput, sanitizeRefComponent } from "./git.js";
|
|
26
|
+
export { ensureRelayDaemon, relayDaemonStatus, runRelayDaemon } from "./relay-client.js";
|
|
27
|
+
/** Re-push at least this often while sessions are live, so peers can treat a
|
|
28
|
+
* blob older than ~3x this as "machine offline" rather than "no change". */
|
|
29
|
+
const KEEPALIVE_SECS = 300;
|
|
30
|
+
/** Never push more often than this, however chatty the triggers. */
|
|
31
|
+
const MIN_PUSH_INTERVAL_SECS = 15;
|
|
32
|
+
/** Default fetch throttle (hook cadence fires far more often than this). */
|
|
33
|
+
const FETCH_INTERVAL_SECS = 60;
|
|
34
|
+
/** A blob whose published_at is older than this is rendered as gone. */
|
|
35
|
+
const REMOTE_STALE_SECS = 900;
|
|
36
|
+
function presenceDir(coordRoot) {
|
|
37
|
+
return join(coordRoot, ".harnery", "presence");
|
|
38
|
+
}
|
|
39
|
+
function statePath(coordRoot) {
|
|
40
|
+
return join(presenceDir(coordRoot), "publish-state.json");
|
|
41
|
+
}
|
|
42
|
+
function fetchStampPath(coordRoot) {
|
|
43
|
+
return join(presenceDir(coordRoot), "fetch-stamp");
|
|
44
|
+
}
|
|
45
|
+
function readState(coordRoot) {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(statePath(coordRoot), "utf8"));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function writeState(coordRoot, state) {
|
|
54
|
+
try {
|
|
55
|
+
const p = statePath(coordRoot);
|
|
56
|
+
mkdirSync(presenceDir(coordRoot), { recursive: true });
|
|
57
|
+
const tmp = `${p}.tmp.${process.pid}`;
|
|
58
|
+
writeFileSync(tmp, JSON.stringify(state), "utf8");
|
|
59
|
+
renameSync(tmp, p);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* best-effort */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Publish this machine's presence blob to origin if it changed (or the
|
|
67
|
+
* keepalive elapsed). `sync: true` pushes in-process and reports errors (CLI
|
|
68
|
+
* path); default spawns the push detached (hook path).
|
|
69
|
+
*/
|
|
70
|
+
export function publishPresence(coordRoot, opts = {}) {
|
|
71
|
+
try {
|
|
72
|
+
if (!presenceEnabled(coordRoot))
|
|
73
|
+
return { status: "skipped", reason: "disabled" };
|
|
74
|
+
if (!hasOrigin(coordRoot))
|
|
75
|
+
return { status: "skipped", reason: "no origin remote" };
|
|
76
|
+
const { blob, basisHash, json } = buildPresenceBlob(coordRoot);
|
|
77
|
+
const state = readState(coordRoot);
|
|
78
|
+
const pushedAtMs = Date.parse(state.pushed_at ?? "");
|
|
79
|
+
const sincePush = Number.isFinite(pushedAtMs)
|
|
80
|
+
? (Date.now() - pushedAtMs) / 1000
|
|
81
|
+
: Number.POSITIVE_INFINITY;
|
|
82
|
+
if (!opts.force) {
|
|
83
|
+
// Nothing live and the last publish already said so → stay quiet.
|
|
84
|
+
if (blob.agents.length === 0 && (state.agents ?? 0) === 0) {
|
|
85
|
+
return { status: "skipped", reason: "no live sessions" };
|
|
86
|
+
}
|
|
87
|
+
const unchanged = state.basis_hash === basisHash;
|
|
88
|
+
if (unchanged && sincePush < KEEPALIVE_SECS) {
|
|
89
|
+
return { status: "skipped", reason: "unchanged (within keepalive)" };
|
|
90
|
+
}
|
|
91
|
+
if (!unchanged && sincePush < MIN_PUSH_INTERVAL_SECS) {
|
|
92
|
+
return { status: "skipped", reason: "min push interval" };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const sha = writePresenceCommit(coordRoot, json);
|
|
96
|
+
if (!sha)
|
|
97
|
+
return { status: "error", error: "commit-tree failed" };
|
|
98
|
+
const push = pushPresenceRef(coordRoot, blob.machine, sha, { sync: opts.sync });
|
|
99
|
+
if (!push.ok)
|
|
100
|
+
return { status: "error", error: push.error ?? "push failed" };
|
|
101
|
+
// Optimistic on the detached path: a failed background push self-heals at
|
|
102
|
+
// the next keepalive re-push.
|
|
103
|
+
writeState(coordRoot, {
|
|
104
|
+
basis_hash: basisHash,
|
|
105
|
+
pushed_at: new Date().toISOString(),
|
|
106
|
+
agents: blob.agents.length,
|
|
107
|
+
});
|
|
108
|
+
return { status: "published", machine: blob.machine, agents: blob.agents.length, sha };
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Throttled fetch of peer presence refs from origin. */
|
|
115
|
+
export function fetchPresence(coordRoot, opts = {}) {
|
|
116
|
+
try {
|
|
117
|
+
if (!presenceEnabled(coordRoot))
|
|
118
|
+
return { status: "skipped", reason: "disabled" };
|
|
119
|
+
if (!hasOrigin(coordRoot))
|
|
120
|
+
return { status: "skipped", reason: "no origin remote" };
|
|
121
|
+
const stamp = fetchStampPath(coordRoot);
|
|
122
|
+
if (!opts.force) {
|
|
123
|
+
const interval = Number.parseInt(coordEnv("PRESENCE_FETCH_INTERVAL") ?? String(FETCH_INTERVAL_SECS), 10);
|
|
124
|
+
try {
|
|
125
|
+
const age = (Date.now() - statSync(stamp).mtimeMs) / 1000;
|
|
126
|
+
if (age < interval)
|
|
127
|
+
return { status: "skipped", reason: "within fetch interval" };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* no stamp yet → fetch */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Touch the stamp BEFORE spawning so concurrent hooks don't stampede.
|
|
134
|
+
try {
|
|
135
|
+
mkdirSync(presenceDir(coordRoot), { recursive: true });
|
|
136
|
+
if (existsSync(stamp)) {
|
|
137
|
+
const now = new Date();
|
|
138
|
+
utimesSync(stamp, now, now);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
writeFileSync(stamp, "", "utf8");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* best-effort */
|
|
146
|
+
}
|
|
147
|
+
const r = fetchPresenceRefs(coordRoot, { sync: opts.sync });
|
|
148
|
+
if (!r.ok)
|
|
149
|
+
return { status: "error", error: r.error ?? "fetch failed" };
|
|
150
|
+
return { status: "fetched" };
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Render-ready view of every OTHER machine's presence, merged from BOTH
|
|
158
|
+
* transports: the locally-fetched git refs (the floor) and the relay
|
|
159
|
+
* daemon's cache files at `.harnery/presence/remote/` (the live upgrade).
|
|
160
|
+
* Freshest source per machine wins. Self machine excluded; blobs older than
|
|
161
|
+
* the stale window or with zero agents dropped.
|
|
162
|
+
*/
|
|
163
|
+
export function readRemoteMachines(coordRoot, opts = {}) {
|
|
164
|
+
try {
|
|
165
|
+
if (!presenceEnabled(coordRoot))
|
|
166
|
+
return [];
|
|
167
|
+
const selfMachine = sanitizeRefComponent(resolveMachineLabel());
|
|
168
|
+
// Collect candidate blobs from both transports, freshest per machine.
|
|
169
|
+
const byMachine = new Map();
|
|
170
|
+
const consider = (machine, blob) => {
|
|
171
|
+
if (blob?.v !== 1 || !Array.isArray(blob.agents))
|
|
172
|
+
return;
|
|
173
|
+
const publishedMs = Date.parse(blob.published_at ?? "");
|
|
174
|
+
if (!Number.isFinite(publishedMs))
|
|
175
|
+
return;
|
|
176
|
+
const prev = byMachine.get(machine);
|
|
177
|
+
if (prev && Date.parse(prev.published_at) >= publishedMs)
|
|
178
|
+
return;
|
|
179
|
+
byMachine.set(machine, blob);
|
|
180
|
+
};
|
|
181
|
+
for (const { machine, message } of readPresenceRefs(coordRoot)) {
|
|
182
|
+
try {
|
|
183
|
+
consider(machine, JSON.parse(message));
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* skip malformed */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const relayDir = join(coordRoot, ".harnery", "presence", "remote");
|
|
190
|
+
if (existsSync(relayDir)) {
|
|
191
|
+
for (const f of readdirSync(relayDir)) {
|
|
192
|
+
if (!f.endsWith(".json"))
|
|
193
|
+
continue;
|
|
194
|
+
try {
|
|
195
|
+
consider(f.replace(/\.json$/, ""), JSON.parse(readFileSync(join(relayDir, f), "utf8")));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
/* skip malformed */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const out = [];
|
|
203
|
+
for (const [machine, blob] of byMachine) {
|
|
204
|
+
if (!opts.includeSelf && machine === selfMachine)
|
|
205
|
+
continue;
|
|
206
|
+
const ageSecs = Math.max(0, Math.floor((Date.now() - Date.parse(blob.published_at)) / 1000));
|
|
207
|
+
if (!opts.includeStale && ageSecs > REMOTE_STALE_SECS)
|
|
208
|
+
continue;
|
|
209
|
+
if (blob.agents.length === 0)
|
|
210
|
+
continue;
|
|
211
|
+
out.push({
|
|
212
|
+
machine,
|
|
213
|
+
published_at: blob.published_at,
|
|
214
|
+
age_secs: ageSecs,
|
|
215
|
+
agents: blob.agents,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
out.sort((a, b) => a.machine.localeCompare(b.machine));
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay client transport for cross-machine presence (ADR 0016, phase 2c).
|
|
3
|
+
*
|
|
4
|
+
* The hooks are short-lived processes, so a live WebSocket needs a home: a
|
|
5
|
+
* small per-repo-per-machine DAEMON that the hooks lazy-start
|
|
6
|
+
* (`ensureRelayDaemon`) and that exits itself when the machine goes idle.
|
|
7
|
+
* The daemon:
|
|
8
|
+
*
|
|
9
|
+
* - derives the room credentials from the repo identity (root-commit SHA +
|
|
10
|
+
* origin URL + optional committed `.harnery/presence-salt`)
|
|
11
|
+
* - holds the socket open with auto-reconnect + jittered backoff
|
|
12
|
+
* - watches `.harnery/active/` and publishes the encrypted presence blob on
|
|
13
|
+
* change (debounced), plus a 60s keepalive so peers' staleness math works
|
|
14
|
+
* - decrypts received peer frames and writes them to
|
|
15
|
+
* `.harnery/presence/remote/<machine>.json`, where `readRemoteMachines`
|
|
16
|
+
* merges them with the git-refs floor (freshest source per machine wins)
|
|
17
|
+
* - exits after ~5 minutes with no live local sessions (hooks restart it
|
|
18
|
+
* when sessions return), and exits if its pid file is superseded
|
|
19
|
+
*
|
|
20
|
+
* Everything fails silent; the git-refs transport is always the floor.
|
|
21
|
+
* Requires a global WebSocket (Bun, Node >= 21, Workers) — on older Nodes the
|
|
22
|
+
* daemon refuses to start and presence stays on git-refs.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Lazy-start the relay daemon for this repo if a relay is configured and no
|
|
26
|
+
* live daemon already owns the pid file. Called from the hook path — must
|
|
27
|
+
* return fast and never throw.
|
|
28
|
+
*/
|
|
29
|
+
export declare function ensureRelayDaemon(coordRoot: string): void;
|
|
30
|
+
/** Foreground daemon loop (spawned detached by ensureRelayDaemon). */
|
|
31
|
+
export declare function runRelayDaemon(coordRoot: string): Promise<number>;
|
|
32
|
+
/** Daemon status for `presence peers`/doctor surfaces. */
|
|
33
|
+
export declare function relayDaemonStatus(coordRoot: string): {
|
|
34
|
+
running: boolean;
|
|
35
|
+
pid?: number;
|
|
36
|
+
url?: string;
|
|
37
|
+
started_at?: string;
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=relay-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-client.d.ts","sourceRoot":"","sources":["../../../src/core/presence/relay-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAwFH;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAwBzD;AAED,sEAAsE;AACtE,wBAAsB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAqKvE;AAED,0DAA0D;AAC1D,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG;IACpD,OAAO,EAAE,OAAO,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAMA"}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay client transport for cross-machine presence (ADR 0016, phase 2c).
|
|
3
|
+
*
|
|
4
|
+
* The hooks are short-lived processes, so a live WebSocket needs a home: a
|
|
5
|
+
* small per-repo-per-machine DAEMON that the hooks lazy-start
|
|
6
|
+
* (`ensureRelayDaemon`) and that exits itself when the machine goes idle.
|
|
7
|
+
* The daemon:
|
|
8
|
+
*
|
|
9
|
+
* - derives the room credentials from the repo identity (root-commit SHA +
|
|
10
|
+
* origin URL + optional committed `.harnery/presence-salt`)
|
|
11
|
+
* - holds the socket open with auto-reconnect + jittered backoff
|
|
12
|
+
* - watches `.harnery/active/` and publishes the encrypted presence blob on
|
|
13
|
+
* change (debounced), plus a 60s keepalive so peers' staleness math works
|
|
14
|
+
* - decrypts received peer frames and writes them to
|
|
15
|
+
* `.harnery/presence/remote/<machine>.json`, where `readRemoteMachines`
|
|
16
|
+
* merges them with the git-refs floor (freshest source per machine wins)
|
|
17
|
+
* - exits after ~5 minutes with no live local sessions (hooks restart it
|
|
18
|
+
* when sessions return), and exits if its pid file is superseded
|
|
19
|
+
*
|
|
20
|
+
* Everything fails silent; the git-refs transport is always the floor.
|
|
21
|
+
* Requires a global WebSocket (Bun, Node >= 21, Workers) — on older Nodes the
|
|
22
|
+
* daemon refuses to start and presence stays on git-refs.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, watch, writeFileSync, } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { resolveMachineLabel } from "../../lib/machine.js";
|
|
28
|
+
import { presenceRelayUrl } from "../config.js";
|
|
29
|
+
import { buildPresenceBlob } from "./blob.js";
|
|
30
|
+
import { originUrl, rootCommitSha, sanitizeRefComponent } from "./git.js";
|
|
31
|
+
import { computeSenderId, decryptPayload, deriveRoomCredentials, encryptPayload, parseRelayFrame, } from "./relay-protocol.js";
|
|
32
|
+
const KEEPALIVE_MS = 60_000;
|
|
33
|
+
const WATCH_DEBOUNCE_MS = 500;
|
|
34
|
+
const IDLE_EXIT_MS = 5 * 60_000;
|
|
35
|
+
const BACKOFF_MIN_MS = 1_000;
|
|
36
|
+
const BACKOFF_MAX_MS = 60_000;
|
|
37
|
+
function presenceDir(coordRoot) {
|
|
38
|
+
return join(coordRoot, ".harnery", "presence");
|
|
39
|
+
}
|
|
40
|
+
function remoteCacheDir(coordRoot) {
|
|
41
|
+
return join(presenceDir(coordRoot), "remote");
|
|
42
|
+
}
|
|
43
|
+
function pidFilePath(coordRoot) {
|
|
44
|
+
return join(presenceDir(coordRoot), "relay-daemon.json");
|
|
45
|
+
}
|
|
46
|
+
function logFilePath(coordRoot) {
|
|
47
|
+
return join(presenceDir(coordRoot), "relay-daemon.log");
|
|
48
|
+
}
|
|
49
|
+
function readPidRecord(coordRoot) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(pidFilePath(coordRoot), "utf8"));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function pidAlive(pid) {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 0);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** The committed rotatable room salt, when the repo minted one. */
|
|
67
|
+
function readRoomSalt(coordRoot) {
|
|
68
|
+
try {
|
|
69
|
+
const s = readFileSync(join(coordRoot, ".harnery", "presence-salt"), "utf8").trim();
|
|
70
|
+
return s || undefined;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function atomicWriteJson(path, value) {
|
|
77
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
78
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(value), "utf8");
|
|
80
|
+
renameSync(tmp, path);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Lazy-start the relay daemon for this repo if a relay is configured and no
|
|
84
|
+
* live daemon already owns the pid file. Called from the hook path — must
|
|
85
|
+
* return fast and never throw.
|
|
86
|
+
*/
|
|
87
|
+
export function ensureRelayDaemon(coordRoot) {
|
|
88
|
+
try {
|
|
89
|
+
const url = presenceRelayUrl(coordRoot);
|
|
90
|
+
if (!url)
|
|
91
|
+
return;
|
|
92
|
+
const rec = readPidRecord(coordRoot);
|
|
93
|
+
if (rec && rec.url === url && pidAlive(rec.pid))
|
|
94
|
+
return;
|
|
95
|
+
// Resolve the harn bin at the package root (works from src/ under Bun and
|
|
96
|
+
// dist/ under Node — both sit two levels below the root).
|
|
97
|
+
const harnBin = new URL("../../../bin/harn", import.meta.url).pathname;
|
|
98
|
+
if (!existsSync(harnBin))
|
|
99
|
+
return;
|
|
100
|
+
mkdirSync(presenceDir(coordRoot), { recursive: true });
|
|
101
|
+
const log = openSync(logFilePath(coordRoot), "a");
|
|
102
|
+
const child = spawn(harnBin, ["presence", "relay-daemon"], {
|
|
103
|
+
cwd: coordRoot,
|
|
104
|
+
detached: true,
|
|
105
|
+
stdio: ["ignore", log, log],
|
|
106
|
+
env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
|
|
107
|
+
});
|
|
108
|
+
child.unref();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* best-effort */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Foreground daemon loop (spawned detached by ensureRelayDaemon). */
|
|
115
|
+
export async function runRelayDaemon(coordRoot) {
|
|
116
|
+
const url = presenceRelayUrl(coordRoot);
|
|
117
|
+
if (!url) {
|
|
118
|
+
console.error("relay-daemon: no relay configured (presence.relay)");
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
if (typeof WebSocket === "undefined") {
|
|
122
|
+
console.error("relay-daemon: no global WebSocket in this runtime (need Bun or Node >= 21)");
|
|
123
|
+
return 2;
|
|
124
|
+
}
|
|
125
|
+
const sha = rootCommitSha(coordRoot);
|
|
126
|
+
const origin = originUrl(coordRoot);
|
|
127
|
+
if (!sha || !origin) {
|
|
128
|
+
console.error("relay-daemon: repo has no commits or no origin remote");
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
// Take (or take over) the pid file. A superseded daemon notices and exits.
|
|
132
|
+
const myRecord = {
|
|
133
|
+
pid: process.pid,
|
|
134
|
+
url,
|
|
135
|
+
started_at: new Date().toISOString(),
|
|
136
|
+
};
|
|
137
|
+
atomicWriteJson(pidFilePath(coordRoot), myRecord);
|
|
138
|
+
const creds = await deriveRoomCredentials({
|
|
139
|
+
rootCommitSha: sha,
|
|
140
|
+
originUrl: origin,
|
|
141
|
+
salt: readRoomSalt(coordRoot),
|
|
142
|
+
});
|
|
143
|
+
const machine = sanitizeRefComponent(resolveMachineLabel());
|
|
144
|
+
const senderId = await computeSenderId(creds, machine);
|
|
145
|
+
const wsUrl = `${url.replace(/\/+$/, "")}/v1/room/${creds.roomId}`;
|
|
146
|
+
let ws = null;
|
|
147
|
+
let backoff = BACKOFF_MIN_MS;
|
|
148
|
+
let lastBasisHash = "";
|
|
149
|
+
let lastSentAt = 0;
|
|
150
|
+
let idleSince = null;
|
|
151
|
+
let shuttingDown = false;
|
|
152
|
+
const log = (msg) => {
|
|
153
|
+
console.log(`[${new Date().toISOString()}] ${msg}`);
|
|
154
|
+
};
|
|
155
|
+
const publishNow = async (force = false) => {
|
|
156
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
const { blob, basisHash, json } = buildPresenceBlob(coordRoot);
|
|
160
|
+
const keepaliveDue = Date.now() - lastSentAt >= KEEPALIVE_MS;
|
|
161
|
+
if (!force && basisHash === lastBasisHash && !keepaliveDue)
|
|
162
|
+
return;
|
|
163
|
+
const enc = await encryptPayload(creds, json);
|
|
164
|
+
ws.send(JSON.stringify({ t: "pub", sender: senderId, ...enc }));
|
|
165
|
+
lastBasisHash = basisHash;
|
|
166
|
+
lastSentAt = Date.now();
|
|
167
|
+
idleSince = blob.agents.length === 0 ? (idleSince ?? Date.now()) : null;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
/* next trigger retries */
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const handleFrame = async (raw) => {
|
|
174
|
+
const frame = parseRelayFrame(raw);
|
|
175
|
+
if (frame?.t !== "pub")
|
|
176
|
+
return;
|
|
177
|
+
if (frame.sender === senderId)
|
|
178
|
+
return; // our own warm-join echo
|
|
179
|
+
const pt = await decryptPayload(creds, frame);
|
|
180
|
+
if (!pt)
|
|
181
|
+
return;
|
|
182
|
+
let blob;
|
|
183
|
+
try {
|
|
184
|
+
blob = JSON.parse(pt);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (blob?.v !== 1 || typeof blob.machine !== "string")
|
|
190
|
+
return;
|
|
191
|
+
const m = sanitizeRefComponent(blob.machine);
|
|
192
|
+
if (m === machine)
|
|
193
|
+
return;
|
|
194
|
+
try {
|
|
195
|
+
atomicWriteJson(join(remoteCacheDir(coordRoot), `${m}.json`), blob);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
/* best-effort */
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const connect = () => {
|
|
202
|
+
if (shuttingDown)
|
|
203
|
+
return;
|
|
204
|
+
try {
|
|
205
|
+
const sock = new WebSocket(wsUrl);
|
|
206
|
+
ws = sock;
|
|
207
|
+
sock.onopen = () => {
|
|
208
|
+
backoff = BACKOFF_MIN_MS;
|
|
209
|
+
log(`connected room=${creds.roomId.slice(0, 8)}… as ${machine}`);
|
|
210
|
+
void publishNow(true);
|
|
211
|
+
};
|
|
212
|
+
sock.onmessage = (ev) => {
|
|
213
|
+
void handleFrame(String(ev.data));
|
|
214
|
+
};
|
|
215
|
+
const scheduleReconnect = () => {
|
|
216
|
+
if (shuttingDown)
|
|
217
|
+
return;
|
|
218
|
+
const jitter = Math.random() * backoff * 0.3;
|
|
219
|
+
const delay = Math.min(BACKOFF_MAX_MS, backoff) + jitter;
|
|
220
|
+
backoff = Math.min(BACKOFF_MAX_MS, backoff * 2);
|
|
221
|
+
setTimeout(connect, delay);
|
|
222
|
+
};
|
|
223
|
+
sock.onclose = scheduleReconnect;
|
|
224
|
+
sock.onerror = () => {
|
|
225
|
+
try {
|
|
226
|
+
sock.close();
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
/* ignore */
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
setTimeout(connect, backoff);
|
|
235
|
+
backoff = Math.min(BACKOFF_MAX_MS, backoff * 2);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
connect();
|
|
239
|
+
// Publish on heartbeat change (debounced).
|
|
240
|
+
let debounce = null;
|
|
241
|
+
const activeDir = join(coordRoot, ".harnery", "active");
|
|
242
|
+
mkdirSync(activeDir, { recursive: true });
|
|
243
|
+
try {
|
|
244
|
+
watch(activeDir, () => {
|
|
245
|
+
if (debounce)
|
|
246
|
+
clearTimeout(debounce);
|
|
247
|
+
debounce = setTimeout(() => void publishNow(), WATCH_DEBOUNCE_MS);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* keepalive interval still covers publication */
|
|
252
|
+
}
|
|
253
|
+
// Keepalive + lifecycle checks.
|
|
254
|
+
const interval = setInterval(() => {
|
|
255
|
+
void publishNow();
|
|
256
|
+
// Superseded by a newer daemon → exit quietly.
|
|
257
|
+
const rec = readPidRecord(coordRoot);
|
|
258
|
+
if (!rec || rec.pid !== process.pid) {
|
|
259
|
+
log("pid file superseded; exiting");
|
|
260
|
+
shuttingDown = true;
|
|
261
|
+
clearInterval(interval);
|
|
262
|
+
try {
|
|
263
|
+
ws?.close();
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
/* ignore */
|
|
267
|
+
}
|
|
268
|
+
process.exit(0);
|
|
269
|
+
}
|
|
270
|
+
// Idle machine (no live sessions for a while) → exit; hooks restart us.
|
|
271
|
+
if (idleSince && Date.now() - idleSince > IDLE_EXIT_MS) {
|
|
272
|
+
log("no live sessions; idle exit");
|
|
273
|
+
shuttingDown = true;
|
|
274
|
+
clearInterval(interval);
|
|
275
|
+
try {
|
|
276
|
+
rmSync(pidFilePath(coordRoot), { force: true });
|
|
277
|
+
ws?.close();
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
/* ignore */
|
|
281
|
+
}
|
|
282
|
+
process.exit(0);
|
|
283
|
+
}
|
|
284
|
+
}, KEEPALIVE_MS);
|
|
285
|
+
// Never resolves in normal operation; exits happen above.
|
|
286
|
+
return await new Promise(() => { });
|
|
287
|
+
}
|
|
288
|
+
/** Daemon status for `presence peers`/doctor surfaces. */
|
|
289
|
+
export function relayDaemonStatus(coordRoot) {
|
|
290
|
+
const rec = readPidRecord(coordRoot);
|
|
291
|
+
if (rec && pidAlive(rec.pid)) {
|
|
292
|
+
return { running: true, pid: rec.pid, url: rec.url, started_at: rec.started_at };
|
|
293
|
+
}
|
|
294
|
+
return { running: false };
|
|
295
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay protocol for cross-machine presence (ADR 0016, phase 2): the shared,
|
|
3
|
+
* host-agnostic logic both relay hosts (the Cloudflare Durable Objects worker
|
|
4
|
+
* and `harn relay serve`) and the client transport build on.
|
|
5
|
+
*
|
|
6
|
+
* Capability-based rooms: the room id and the E2E symmetric key are BOTH
|
|
7
|
+
* derived client-side via HKDF-SHA256 from (repo root-commit SHA, normalized
|
|
8
|
+
* origin URL, a rotatable salt). Anyone who can read the repo can derive them;
|
|
9
|
+
* nobody else can — including the relay operator, who sees only an opaque
|
|
10
|
+
* room id, opaque sender ids, and AES-GCM ciphertext. Rotating the salt
|
|
11
|
+
* rotates the room.
|
|
12
|
+
*
|
|
13
|
+
* The committed salt is NOT a secret and must never be treated as one: the
|
|
14
|
+
* derivation needs the root-commit SHA + origin URL too, which only
|
|
15
|
+
* repo-readers have. Repo access is the trust boundary (same as the git-refs
|
|
16
|
+
* transport).
|
|
17
|
+
*
|
|
18
|
+
* Wire format (JSON text frames over WebSocket):
|
|
19
|
+
* client → relay: { t: "pub", sender, iv, ct } broadcast + cache
|
|
20
|
+
* relay → client: { t: "pub", sender, iv, ct } live or warm-join replay
|
|
21
|
+
* relay → client: { t: "hello", peers } on join (peer count)
|
|
22
|
+
* The relay never sees plaintext: `ct` is AES-GCM over the presence-blob JSON,
|
|
23
|
+
* `sender` is an HMAC-derived opaque id, `iv` is the per-message nonce.
|
|
24
|
+
*
|
|
25
|
+
* Uses WebCrypto only (globalThis.crypto.subtle) so the same module runs on
|
|
26
|
+
* Bun, Node ≥ 20, and Cloudflare Workers.
|
|
27
|
+
*/
|
|
28
|
+
/** Default derivation salt when a repo hasn't minted `.harnery/presence-salt`.
|
|
29
|
+
* Public by design (see module docs); committing a random per-repo salt just
|
|
30
|
+
* rotates the room away from anything derived before. */
|
|
31
|
+
export declare const DEFAULT_ROOM_SALT = "harnery-presence-salt/v1";
|
|
32
|
+
export interface RoomInputs {
|
|
33
|
+
/** SHA of the repo's root commit (`git rev-list --max-parents=0 HEAD`,
|
|
34
|
+
* first line). Stable for the repo's whole life. */
|
|
35
|
+
rootCommitSha: string;
|
|
36
|
+
/** The origin remote URL, any format — normalized internally so ssh and
|
|
37
|
+
* https clones of the same repo land in the same room. */
|
|
38
|
+
originUrl: string;
|
|
39
|
+
/** Rotatable room salt (default DEFAULT_ROOM_SALT). */
|
|
40
|
+
salt?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface RoomCredentials {
|
|
43
|
+
/** Opaque 32-hex-char room id — the only room identity the relay sees. */
|
|
44
|
+
roomId: string;
|
|
45
|
+
/** AES-GCM-256 key for payload E2E encryption. Never leaves the client. */
|
|
46
|
+
key: CryptoKey;
|
|
47
|
+
/** Raw bytes for deriving per-machine opaque sender ids. */
|
|
48
|
+
senderKeyBytes: Uint8Array;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Normalize a git remote URL so every clone shape of the same repo derives
|
|
52
|
+
* the same room: `git@github.com:Org/Repo.git`, `ssh://git@github.com/org/repo`,
|
|
53
|
+
* and `https://github.com/org/repo.git` all → `github.com/org/repo`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function normalizeOriginUrl(raw: string): string;
|
|
56
|
+
/** Derive the room id, E2E key, and sender-id key from the repo identity. */
|
|
57
|
+
export declare function deriveRoomCredentials(inputs: RoomInputs): Promise<RoomCredentials>;
|
|
58
|
+
/** Opaque, stable per-machine sender id: HMAC(senderKey, machineLabel). The
|
|
59
|
+
* relay caches last-message-per-sender by this without learning the label. */
|
|
60
|
+
export declare function computeSenderId(creds: RoomCredentials, machineLabel: string): Promise<string>;
|
|
61
|
+
/** AES-GCM-encrypt a plaintext payload → { iv, ct } base64 pair. */
|
|
62
|
+
export declare function encryptPayload(creds: RoomCredentials, plaintext: string): Promise<{
|
|
63
|
+
iv: string;
|
|
64
|
+
ct: string;
|
|
65
|
+
}>;
|
|
66
|
+
/** Decrypt an { iv, ct } pair. Returns null on any failure (wrong room key,
|
|
67
|
+
* tampered frame) — callers drop the frame silently. */
|
|
68
|
+
export declare function decryptPayload(creds: RoomCredentials, frame: {
|
|
69
|
+
iv: string;
|
|
70
|
+
ct: string;
|
|
71
|
+
}): Promise<string | null>;
|
|
72
|
+
/** Client → relay and relay → client publication frame. */
|
|
73
|
+
export interface PubFrame {
|
|
74
|
+
t: "pub";
|
|
75
|
+
/** Opaque sender id (computeSenderId). The relay keys its warm-join cache on this. */
|
|
76
|
+
sender: string;
|
|
77
|
+
iv: string;
|
|
78
|
+
ct: string;
|
|
79
|
+
}
|
|
80
|
+
/** Relay → client greeting on join. */
|
|
81
|
+
export interface HelloFrame {
|
|
82
|
+
t: "hello";
|
|
83
|
+
/** Live sockets in the room at join time (including the joiner). */
|
|
84
|
+
peers: number;
|
|
85
|
+
}
|
|
86
|
+
export type RelayFrame = PubFrame | HelloFrame;
|
|
87
|
+
/** Hard cap a relay enforces per text frame (ciphertext of a ~2KB blob is far
|
|
88
|
+
* smaller; anything bigger is abuse or a bug). */
|
|
89
|
+
export declare const MAX_FRAME_BYTES: number;
|
|
90
|
+
/** Parse + shape-validate an incoming relay frame. Null on anything off. */
|
|
91
|
+
export declare function parseRelayFrame(raw: string): RelayFrame | null;
|
|
92
|
+
/** Room ids are 32 lowercase hex chars; relays reject anything else. */
|
|
93
|
+
export declare function isValidRoomId(roomId: string): boolean;
|
|
94
|
+
//# sourceMappingURL=relay-protocol.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-protocol.d.ts","sourceRoot":"","sources":["../../../src/core/presence/relay-protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAOH;;yDAEyD;AACzD,eAAO,MAAM,iBAAiB,6BAA6B,CAAC;AAE5D,MAAM,WAAW,UAAU;IACzB;wDACoD;IACpD,aAAa,EAAE,MAAM,CAAC;IACtB;8DAC0D;IAC1D,SAAS,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,GAAG,EAAE,SAAS,CAAC;IACf,4DAA4D;IAC5D,cAAc,EAAE,UAAU,CAAC;CAC5B;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAiBtD;AAyBD,6EAA6E;AAC7E,wBAAsB,qBAAqB,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,eAAe,CAAC,CAgBxF;AAED;8EAC8E;AAC9E,wBAAsB,eAAe,CACnC,KAAK,EAAE,eAAe,EACtB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,MAAM,CAAC,CAUjB;AAeD,oEAAoE;AACpE,wBAAsB,cAAc,CAClC,KAAK,EAAE,eAAe,EACtB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,CAQrC;AAED;wDACwD;AACxD,wBAAsB,cAAc,CAClC,KAAK,EAAE,eAAe,EACtB,KAAK,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAChC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAWxB;AAID,2DAA2D;AAC3D,MAAM,WAAW,QAAQ;IACvB,CAAC,EAAE,KAAK,CAAC;IACT,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,uCAAuC;AACvC,MAAM,WAAW,UAAU;IACzB,CAAC,EAAE,OAAO,CAAC;IACX,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,UAAU,CAAC;AAE/C;kDACkD;AAClD,eAAO,MAAM,eAAe,QAAY,CAAC;AAEzC,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CA6B9D;AAED,wEAAwE;AACxE,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAErD"}
|