harnery 0.9.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.
Files changed (201) hide show
  1. package/README.md +1 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +6 -0
  4. package/dist/commands/agents.d.ts +24 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +244 -13
  7. package/dist/commands/backup.d.ts +5 -4
  8. package/dist/commands/backup.d.ts.map +1 -1
  9. package/dist/commands/backup.js +15 -14
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +27 -1
  12. package/dist/commands/claude-desktop.d.ts +19 -0
  13. package/dist/commands/claude-desktop.d.ts.map +1 -0
  14. package/dist/commands/claude-desktop.js +168 -0
  15. package/dist/commands/context.d.ts.map +1 -1
  16. package/dist/commands/context.js +149 -1
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +2 -3
  19. package/dist/commands/eml.d.ts +32 -0
  20. package/dist/commands/eml.d.ts.map +1 -1
  21. package/dist/commands/eml.js +16 -3
  22. package/dist/commands/harness.d.ts +11 -0
  23. package/dist/commands/harness.d.ts.map +1 -0
  24. package/dist/commands/harness.js +118 -0
  25. package/dist/commands/presence.d.ts +9 -4
  26. package/dist/commands/presence.d.ts.map +1 -1
  27. package/dist/commands/presence.js +88 -5
  28. package/dist/commands/relay.d.ts +9 -0
  29. package/dist/commands/relay.d.ts.map +1 -0
  30. package/dist/commands/relay.js +143 -0
  31. package/dist/commands/sync.d.ts.map +1 -1
  32. package/dist/commands/sync.js +5 -0
  33. package/dist/commands/workflow.d.ts.map +1 -1
  34. package/dist/commands/workflow.js +8 -15
  35. package/dist/core/agents/canonical-emit.d.ts +15 -0
  36. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  37. package/dist/core/agents/canonical-emit.js +41 -3
  38. package/dist/core/agents/cli.js +4 -2
  39. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  40. package/dist/core/agents/render/prompt-context.js +44 -3
  41. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  42. package/dist/core/agents/render/session-context.js +26 -1
  43. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  44. package/dist/core/agents/rules/commit-conflict.js +4 -37
  45. package/dist/core/agents/session-events.d.ts.map +1 -1
  46. package/dist/core/agents/session-events.js +16 -43
  47. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  48. package/dist/core/agents/state/heartbeat-projector.js +11 -2
  49. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  50. package/dist/core/agents/state/heartbeat-writer.js +3 -3
  51. package/dist/core/agents/state/names.d.ts +38 -3
  52. package/dist/core/agents/state/names.d.ts.map +1 -1
  53. package/dist/core/agents/state/names.js +46 -7
  54. package/dist/core/agents/state/pidmap.d.ts +5 -1
  55. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  56. package/dist/core/agents/state/pidmap.js +58 -2
  57. package/dist/core/agents/state/stale-sweep.d.ts +3 -2
  58. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  59. package/dist/core/agents/state/stale-sweep.js +6 -6
  60. package/dist/core/config.d.ts +68 -11
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +191 -25
  63. package/dist/core/context/index.d.ts +144 -0
  64. package/dist/core/context/index.d.ts.map +1 -0
  65. package/dist/core/context/index.js +380 -0
  66. package/dist/core/harnesses/bench.d.ts +29 -0
  67. package/dist/core/harnesses/bench.d.ts.map +1 -0
  68. package/dist/core/harnesses/bench.js +151 -0
  69. package/dist/core/harnesses/index.d.ts +9 -0
  70. package/dist/core/harnesses/index.d.ts.map +1 -0
  71. package/dist/core/harnesses/index.js +4 -0
  72. package/dist/core/harnesses/profiles.d.ts +62 -0
  73. package/dist/core/harnesses/profiles.d.ts.map +1 -0
  74. package/dist/core/harnesses/profiles.js +115 -0
  75. package/dist/core/harnesses/registry.d.ts +15 -0
  76. package/dist/core/harnesses/registry.d.ts.map +1 -0
  77. package/dist/core/harnesses/registry.js +113 -0
  78. package/dist/core/harnesses/types.d.ts +64 -0
  79. package/dist/core/harnesses/types.d.ts.map +1 -0
  80. package/dist/core/harnesses/types.js +20 -0
  81. package/dist/core/hooks/cli.js +280 -63
  82. package/dist/core/hooks/events/schema.d.ts +38 -1
  83. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  84. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  85. package/dist/core/hooks/harness/events.js +3 -0
  86. package/dist/core/hooks/harness/parse.d.ts +1 -1
  87. package/dist/core/hooks/harness/parse.d.ts.map +1 -1
  88. package/dist/core/hooks/harness/parse.js +4 -0
  89. package/dist/core/presence/blob.d.ts +40 -0
  90. package/dist/core/presence/blob.d.ts.map +1 -0
  91. package/dist/core/presence/blob.js +91 -0
  92. package/dist/core/presence/git.d.ts +68 -0
  93. package/dist/core/presence/git.d.ts.map +1 -0
  94. package/dist/core/presence/git.js +173 -0
  95. package/dist/core/presence/index.d.ts +74 -0
  96. package/dist/core/presence/index.d.ts.map +1 -0
  97. package/dist/core/presence/index.js +224 -0
  98. package/dist/core/presence/relay-client.d.ts +39 -0
  99. package/dist/core/presence/relay-client.d.ts.map +1 -0
  100. package/dist/core/presence/relay-client.js +295 -0
  101. package/dist/core/presence/relay-protocol.d.ts +94 -0
  102. package/dist/core/presence/relay-protocol.d.ts.map +1 -0
  103. package/dist/core/presence/relay-protocol.js +169 -0
  104. package/dist/core/workflow/billing.d.ts +1 -1
  105. package/dist/core/workflow/billing.d.ts.map +1 -1
  106. package/dist/core/workflow/billing.js +7 -1
  107. package/dist/core/workflow/engine.d.ts.map +1 -1
  108. package/dist/core/workflow/engine.js +11 -2
  109. package/dist/core/workflow/harnesses.d.ts +3 -3
  110. package/dist/core/workflow/harnesses.d.ts.map +1 -1
  111. package/dist/core/workflow/harnesses.js +8 -17
  112. package/dist/core/workflow/spawn-claude.d.ts +4 -1
  113. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  114. package/dist/core/workflow/spawn-claude.js +39 -18
  115. package/dist/core/workflow/spawn-codex.d.ts +4 -1
  116. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  117. package/dist/core/workflow/spawn-codex.js +44 -22
  118. package/dist/core/workflow/spawn-cursor.d.ts +4 -1
  119. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  120. package/dist/core/workflow/spawn-cursor.js +35 -16
  121. package/dist/core/workflow/types.d.ts +9 -2
  122. package/dist/core/workflow/types.d.ts.map +1 -1
  123. package/dist/lib/browser/client.d.ts +16 -0
  124. package/dist/lib/browser/client.d.ts.map +1 -1
  125. package/dist/lib/browser/client.js +22 -0
  126. package/dist/lib/browser/index.d.ts +1 -0
  127. package/dist/lib/browser/index.d.ts.map +1 -1
  128. package/dist/lib/browser/index.js +1 -0
  129. package/dist/lib/browser/launch-args.d.ts +21 -0
  130. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  131. package/dist/lib/browser/launch-args.js +32 -0
  132. package/dist/lib/claude-desktop.d.ts +105 -0
  133. package/dist/lib/claude-desktop.d.ts.map +1 -0
  134. package/dist/lib/claude-desktop.js +217 -0
  135. package/dist/lib/docs-lint.d.ts.map +1 -1
  136. package/dist/lib/docs-lint.js +5 -2
  137. package/dist/lib/identities/assume.d.ts +51 -0
  138. package/dist/lib/identities/assume.d.ts.map +1 -0
  139. package/dist/lib/identities/assume.js +274 -0
  140. package/dist/lib/identities/index.d.ts +7 -7
  141. package/dist/lib/identities/index.d.ts.map +1 -1
  142. package/dist/lib/identities/index.js +24 -24
  143. package/dist/lib/instructions/templates.d.ts.map +1 -1
  144. package/dist/lib/instructions/templates.js +15 -2
  145. package/package.json +12 -1
  146. package/schemas/config.schema.json +88 -19
  147. package/src/commander.ts +6 -0
  148. package/src/commands/agents.ts +280 -14
  149. package/src/commands/backup.ts +17 -16
  150. package/src/commands/browse.ts +32 -0
  151. package/src/commands/claude-desktop.ts +215 -0
  152. package/src/commands/context.ts +174 -1
  153. package/src/commands/doctor.ts +2 -3
  154. package/src/commands/eml.ts +22 -4
  155. package/src/commands/harness.ts +147 -0
  156. package/src/commands/presence.ts +111 -5
  157. package/src/commands/relay.ts +160 -0
  158. package/src/commands/sync.ts +4 -0
  159. package/src/commands/workflow.ts +9 -18
  160. package/src/core/agents/canonical-emit.ts +41 -3
  161. package/src/core/agents/cli.ts +4 -2
  162. package/src/core/agents/render/prompt-context.ts +48 -3
  163. package/src/core/agents/render/session-context.ts +27 -1
  164. package/src/core/agents/rules/commit-conflict.ts +4 -33
  165. package/src/core/agents/session-events.ts +15 -39
  166. package/src/core/agents/state/heartbeat-projector.ts +10 -1
  167. package/src/core/agents/state/heartbeat-writer.ts +3 -2
  168. package/src/core/agents/state/names.ts +68 -9
  169. package/src/core/agents/state/pidmap.ts +59 -2
  170. package/src/core/agents/state/stale-sweep.ts +6 -11
  171. package/src/core/config.ts +248 -24
  172. package/src/core/context/index.ts +575 -0
  173. package/src/core/harnesses/bench.ts +214 -0
  174. package/src/core/harnesses/index.ts +31 -0
  175. package/src/core/harnesses/profiles.ts +141 -0
  176. package/src/core/harnesses/registry.ts +140 -0
  177. package/src/core/harnesses/types.ts +90 -0
  178. package/src/core/hooks/cli.ts +331 -62
  179. package/src/core/hooks/events/schema.ts +68 -0
  180. package/src/core/hooks/harness/events.ts +3 -0
  181. package/src/core/hooks/harness/parse.ts +7 -1
  182. package/src/core/presence/blob.ts +125 -0
  183. package/src/core/presence/git.ts +191 -0
  184. package/src/core/presence/index.ts +274 -0
  185. package/src/core/presence/relay-client.ts +322 -0
  186. package/src/core/presence/relay-protocol.ts +246 -0
  187. package/src/core/workflow/billing.ts +8 -2
  188. package/src/core/workflow/engine.ts +11 -2
  189. package/src/core/workflow/harnesses.ts +14 -19
  190. package/src/core/workflow/spawn-claude.ts +39 -20
  191. package/src/core/workflow/spawn-codex.ts +47 -28
  192. package/src/core/workflow/spawn-cursor.ts +37 -18
  193. package/src/core/workflow/types.ts +9 -2
  194. package/src/lib/browser/client.ts +37 -0
  195. package/src/lib/browser/index.ts +1 -0
  196. package/src/lib/browser/launch-args.ts +33 -0
  197. package/src/lib/claude-desktop.ts +293 -0
  198. package/src/lib/docs-lint.ts +5 -2
  199. package/src/lib/identities/assume.ts +363 -0
  200. package/src/lib/identities/index.ts +28 -24
  201. package/src/lib/instructions/templates.ts +16 -2
@@ -0,0 +1,322 @@
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
+ import { spawn } from "node:child_process";
26
+ import {
27
+ existsSync,
28
+ mkdirSync,
29
+ openSync,
30
+ readFileSync,
31
+ renameSync,
32
+ rmSync,
33
+ watch,
34
+ writeFileSync,
35
+ } from "node:fs";
36
+ import { join } from "node:path";
37
+ import { resolveMachineLabel } from "../../lib/machine.ts";
38
+ import { presenceRelayUrl } from "../config.ts";
39
+ import { buildPresenceBlob, type PresenceBlob } from "./blob.ts";
40
+ import { originUrl, rootCommitSha, sanitizeRefComponent } from "./git.ts";
41
+ import {
42
+ computeSenderId,
43
+ decryptPayload,
44
+ deriveRoomCredentials,
45
+ encryptPayload,
46
+ parseRelayFrame,
47
+ } from "./relay-protocol.ts";
48
+
49
+ const KEEPALIVE_MS = 60_000;
50
+ const WATCH_DEBOUNCE_MS = 500;
51
+ const IDLE_EXIT_MS = 5 * 60_000;
52
+ const BACKOFF_MIN_MS = 1_000;
53
+ const BACKOFF_MAX_MS = 60_000;
54
+
55
+ function presenceDir(coordRoot: string): string {
56
+ return join(coordRoot, ".harnery", "presence");
57
+ }
58
+
59
+ function remoteCacheDir(coordRoot: string): string {
60
+ return join(presenceDir(coordRoot), "remote");
61
+ }
62
+
63
+ function pidFilePath(coordRoot: string): string {
64
+ return join(presenceDir(coordRoot), "relay-daemon.json");
65
+ }
66
+
67
+ function logFilePath(coordRoot: string): string {
68
+ return join(presenceDir(coordRoot), "relay-daemon.log");
69
+ }
70
+
71
+ interface PidRecord {
72
+ pid: number;
73
+ url: string;
74
+ started_at: string;
75
+ }
76
+
77
+ function readPidRecord(coordRoot: string): PidRecord | null {
78
+ try {
79
+ return JSON.parse(readFileSync(pidFilePath(coordRoot), "utf8")) as PidRecord;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function pidAlive(pid: number): boolean {
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /** The committed rotatable room salt, when the repo minted one. */
95
+ function readRoomSalt(coordRoot: string): string | undefined {
96
+ try {
97
+ const s = readFileSync(join(coordRoot, ".harnery", "presence-salt"), "utf8").trim();
98
+ return s || undefined;
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ }
103
+
104
+ function atomicWriteJson(path: string, value: unknown): void {
105
+ mkdirSync(join(path, ".."), { recursive: true });
106
+ const tmp = `${path}.tmp.${process.pid}`;
107
+ writeFileSync(tmp, JSON.stringify(value), "utf8");
108
+ renameSync(tmp, path);
109
+ }
110
+
111
+ /**
112
+ * Lazy-start the relay daemon for this repo if a relay is configured and no
113
+ * live daemon already owns the pid file. Called from the hook path — must
114
+ * return fast and never throw.
115
+ */
116
+ export function ensureRelayDaemon(coordRoot: string): void {
117
+ try {
118
+ const url = presenceRelayUrl(coordRoot);
119
+ if (!url) return;
120
+ const rec = readPidRecord(coordRoot);
121
+ if (rec && rec.url === url && pidAlive(rec.pid)) return;
122
+
123
+ // Resolve the harn bin at the package root (works from src/ under Bun and
124
+ // dist/ under Node — both sit two levels below the root).
125
+ const harnBin = new URL("../../../bin/harn", import.meta.url).pathname;
126
+ if (!existsSync(harnBin)) return;
127
+
128
+ mkdirSync(presenceDir(coordRoot), { recursive: true });
129
+ const log = openSync(logFilePath(coordRoot), "a");
130
+ const child = spawn(harnBin, ["presence", "relay-daemon"], {
131
+ cwd: coordRoot,
132
+ detached: true,
133
+ stdio: ["ignore", log, log],
134
+ env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
135
+ });
136
+ child.unref();
137
+ } catch {
138
+ /* best-effort */
139
+ }
140
+ }
141
+
142
+ /** Foreground daemon loop (spawned detached by ensureRelayDaemon). */
143
+ export async function runRelayDaemon(coordRoot: string): Promise<number> {
144
+ const url = presenceRelayUrl(coordRoot);
145
+ if (!url) {
146
+ console.error("relay-daemon: no relay configured (presence.relay)");
147
+ return 2;
148
+ }
149
+ if (typeof WebSocket === "undefined") {
150
+ console.error("relay-daemon: no global WebSocket in this runtime (need Bun or Node >= 21)");
151
+ return 2;
152
+ }
153
+ const sha = rootCommitSha(coordRoot);
154
+ const origin = originUrl(coordRoot);
155
+ if (!sha || !origin) {
156
+ console.error("relay-daemon: repo has no commits or no origin remote");
157
+ return 2;
158
+ }
159
+
160
+ // Take (or take over) the pid file. A superseded daemon notices and exits.
161
+ const myRecord: PidRecord = {
162
+ pid: process.pid,
163
+ url,
164
+ started_at: new Date().toISOString(),
165
+ };
166
+ atomicWriteJson(pidFilePath(coordRoot), myRecord);
167
+
168
+ const creds = await deriveRoomCredentials({
169
+ rootCommitSha: sha,
170
+ originUrl: origin,
171
+ salt: readRoomSalt(coordRoot),
172
+ });
173
+ const machine = sanitizeRefComponent(resolveMachineLabel());
174
+ const senderId = await computeSenderId(creds, machine);
175
+ const wsUrl = `${url.replace(/\/+$/, "")}/v1/room/${creds.roomId}`;
176
+
177
+ let ws: WebSocket | null = null;
178
+ let backoff = BACKOFF_MIN_MS;
179
+ let lastBasisHash = "";
180
+ let lastSentAt = 0;
181
+ let idleSince: number | null = null;
182
+ let shuttingDown = false;
183
+
184
+ const log = (msg: string) => {
185
+ console.log(`[${new Date().toISOString()}] ${msg}`);
186
+ };
187
+
188
+ const publishNow = async (force = false): Promise<void> => {
189
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
190
+ try {
191
+ const { blob, basisHash, json } = buildPresenceBlob(coordRoot);
192
+ const keepaliveDue = Date.now() - lastSentAt >= KEEPALIVE_MS;
193
+ if (!force && basisHash === lastBasisHash && !keepaliveDue) return;
194
+ const enc = await encryptPayload(creds, json);
195
+ ws.send(JSON.stringify({ t: "pub", sender: senderId, ...enc }));
196
+ lastBasisHash = basisHash;
197
+ lastSentAt = Date.now();
198
+ idleSince = blob.agents.length === 0 ? (idleSince ?? Date.now()) : null;
199
+ } catch {
200
+ /* next trigger retries */
201
+ }
202
+ };
203
+
204
+ const handleFrame = async (raw: string): Promise<void> => {
205
+ const frame = parseRelayFrame(raw);
206
+ if (frame?.t !== "pub") return;
207
+ if (frame.sender === senderId) return; // our own warm-join echo
208
+ const pt = await decryptPayload(creds, frame);
209
+ if (!pt) return;
210
+ let blob: PresenceBlob;
211
+ try {
212
+ blob = JSON.parse(pt) as PresenceBlob;
213
+ } catch {
214
+ return;
215
+ }
216
+ if (blob?.v !== 1 || typeof blob.machine !== "string") return;
217
+ const m = sanitizeRefComponent(blob.machine);
218
+ if (m === machine) return;
219
+ try {
220
+ atomicWriteJson(join(remoteCacheDir(coordRoot), `${m}.json`), blob);
221
+ } catch {
222
+ /* best-effort */
223
+ }
224
+ };
225
+
226
+ const connect = (): void => {
227
+ if (shuttingDown) return;
228
+ try {
229
+ const sock = new WebSocket(wsUrl);
230
+ ws = sock;
231
+ sock.onopen = () => {
232
+ backoff = BACKOFF_MIN_MS;
233
+ log(`connected room=${creds.roomId.slice(0, 8)}… as ${machine}`);
234
+ void publishNow(true);
235
+ };
236
+ sock.onmessage = (ev) => {
237
+ void handleFrame(String(ev.data));
238
+ };
239
+ const scheduleReconnect = () => {
240
+ if (shuttingDown) return;
241
+ const jitter = Math.random() * backoff * 0.3;
242
+ const delay = Math.min(BACKOFF_MAX_MS, backoff) + jitter;
243
+ backoff = Math.min(BACKOFF_MAX_MS, backoff * 2);
244
+ setTimeout(connect, delay);
245
+ };
246
+ sock.onclose = scheduleReconnect;
247
+ sock.onerror = () => {
248
+ try {
249
+ sock.close();
250
+ } catch {
251
+ /* ignore */
252
+ }
253
+ };
254
+ } catch {
255
+ setTimeout(connect, backoff);
256
+ backoff = Math.min(BACKOFF_MAX_MS, backoff * 2);
257
+ }
258
+ };
259
+
260
+ connect();
261
+
262
+ // Publish on heartbeat change (debounced).
263
+ let debounce: ReturnType<typeof setTimeout> | null = null;
264
+ const activeDir = join(coordRoot, ".harnery", "active");
265
+ mkdirSync(activeDir, { recursive: true });
266
+ try {
267
+ watch(activeDir, () => {
268
+ if (debounce) clearTimeout(debounce);
269
+ debounce = setTimeout(() => void publishNow(), WATCH_DEBOUNCE_MS);
270
+ });
271
+ } catch {
272
+ /* keepalive interval still covers publication */
273
+ }
274
+
275
+ // Keepalive + lifecycle checks.
276
+ const interval = setInterval(() => {
277
+ void publishNow();
278
+ // Superseded by a newer daemon → exit quietly.
279
+ const rec = readPidRecord(coordRoot);
280
+ if (!rec || rec.pid !== process.pid) {
281
+ log("pid file superseded; exiting");
282
+ shuttingDown = true;
283
+ clearInterval(interval);
284
+ try {
285
+ ws?.close();
286
+ } catch {
287
+ /* ignore */
288
+ }
289
+ process.exit(0);
290
+ }
291
+ // Idle machine (no live sessions for a while) → exit; hooks restart us.
292
+ if (idleSince && Date.now() - idleSince > IDLE_EXIT_MS) {
293
+ log("no live sessions; idle exit");
294
+ shuttingDown = true;
295
+ clearInterval(interval);
296
+ try {
297
+ rmSync(pidFilePath(coordRoot), { force: true });
298
+ ws?.close();
299
+ } catch {
300
+ /* ignore */
301
+ }
302
+ process.exit(0);
303
+ }
304
+ }, KEEPALIVE_MS);
305
+
306
+ // Never resolves in normal operation; exits happen above.
307
+ return await new Promise<number>(() => {});
308
+ }
309
+
310
+ /** Daemon status for `presence peers`/doctor surfaces. */
311
+ export function relayDaemonStatus(coordRoot: string): {
312
+ running: boolean;
313
+ pid?: number;
314
+ url?: string;
315
+ started_at?: string;
316
+ } {
317
+ const rec = readPidRecord(coordRoot);
318
+ if (rec && pidAlive(rec.pid)) {
319
+ return { running: true, pid: rec.pid, url: rec.url, started_at: rec.started_at };
320
+ }
321
+ return { running: false };
322
+ }
@@ -0,0 +1,246 @@
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
+
29
+ const ROOM_INFO = "harnery-presence/room-id/v1";
30
+ const KEY_INFO = "harnery-presence/room-key/v1";
31
+ const SENDER_INFO = "harnery-presence/sender-id/v1";
32
+ const HKDF_SALT = "harnery-presence/hkdf-salt/v1";
33
+
34
+ /** Default derivation salt when a repo hasn't minted `.harnery/presence-salt`.
35
+ * Public by design (see module docs); committing a random per-repo salt just
36
+ * rotates the room away from anything derived before. */
37
+ export const DEFAULT_ROOM_SALT = "harnery-presence-salt/v1";
38
+
39
+ export interface RoomInputs {
40
+ /** SHA of the repo's root commit (`git rev-list --max-parents=0 HEAD`,
41
+ * first line). Stable for the repo's whole life. */
42
+ rootCommitSha: string;
43
+ /** The origin remote URL, any format — normalized internally so ssh and
44
+ * https clones of the same repo land in the same room. */
45
+ originUrl: string;
46
+ /** Rotatable room salt (default DEFAULT_ROOM_SALT). */
47
+ salt?: string;
48
+ }
49
+
50
+ export interface RoomCredentials {
51
+ /** Opaque 32-hex-char room id — the only room identity the relay sees. */
52
+ roomId: string;
53
+ /** AES-GCM-256 key for payload E2E encryption. Never leaves the client. */
54
+ key: CryptoKey;
55
+ /** Raw bytes for deriving per-machine opaque sender ids. */
56
+ senderKeyBytes: Uint8Array;
57
+ }
58
+
59
+ /**
60
+ * Normalize a git remote URL so every clone shape of the same repo derives
61
+ * the same room: `git@github.com:Org/Repo.git`, `ssh://git@github.com/org/repo`,
62
+ * and `https://github.com/org/repo.git` all → `github.com/org/repo`.
63
+ */
64
+ export function normalizeOriginUrl(raw: string): string {
65
+ let s = raw.trim();
66
+ // scp-like syntax: [user@]host:path
67
+ const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)(.+)$/.exec(s);
68
+ if (scp) {
69
+ s = `${scp[1]}/${scp[2]}`;
70
+ } else {
71
+ // URL syntax: strip scheme, then credentials.
72
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
73
+ s = s.replace(/^[^@/]+@/, "");
74
+ }
75
+ // Drop an explicit port, trailing slashes, trailing .git; lowercase.
76
+ // (Slashes first so `…/repo.git/` still sheds its .git suffix.)
77
+ s = s.replace(/^([^/:]+):\d+\//, "$1/");
78
+ s = s.replace(/\/+$/, "");
79
+ s = s.replace(/\.git$/i, "");
80
+ return s.toLowerCase();
81
+ }
82
+
83
+ async function hkdfBits(ikm: Uint8Array, info: string, bits: number): Promise<ArrayBuffer> {
84
+ const key = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, [
85
+ "deriveBits",
86
+ ]);
87
+ return crypto.subtle.deriveBits(
88
+ {
89
+ name: "HKDF",
90
+ hash: "SHA-256",
91
+ salt: new TextEncoder().encode(HKDF_SALT),
92
+ info: new TextEncoder().encode(info),
93
+ },
94
+ key,
95
+ bits,
96
+ );
97
+ }
98
+
99
+ function hex(buf: ArrayBuffer | Uint8Array): string {
100
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
101
+ return Array.from(bytes)
102
+ .map((b) => b.toString(16).padStart(2, "0"))
103
+ .join("");
104
+ }
105
+
106
+ /** Derive the room id, E2E key, and sender-id key from the repo identity. */
107
+ export async function deriveRoomCredentials(inputs: RoomInputs): Promise<RoomCredentials> {
108
+ const ikm = new TextEncoder().encode(
109
+ [
110
+ inputs.rootCommitSha.trim().toLowerCase(),
111
+ normalizeOriginUrl(inputs.originUrl),
112
+ inputs.salt ?? DEFAULT_ROOM_SALT,
113
+ ].join("\n"),
114
+ );
115
+ const roomBits = await hkdfBits(ikm, ROOM_INFO, 128);
116
+ const keyBits = await hkdfBits(ikm, KEY_INFO, 256);
117
+ const senderBits = await hkdfBits(ikm, SENDER_INFO, 256);
118
+ const key = await crypto.subtle.importKey("raw", keyBits, { name: "AES-GCM" }, false, [
119
+ "encrypt",
120
+ "decrypt",
121
+ ]);
122
+ return { roomId: hex(roomBits), key, senderKeyBytes: new Uint8Array(senderBits) };
123
+ }
124
+
125
+ /** Opaque, stable per-machine sender id: HMAC(senderKey, machineLabel). The
126
+ * relay caches last-message-per-sender by this without learning the label. */
127
+ export async function computeSenderId(
128
+ creds: RoomCredentials,
129
+ machineLabel: string,
130
+ ): Promise<string> {
131
+ const hmacKey = await crypto.subtle.importKey(
132
+ "raw",
133
+ creds.senderKeyBytes as BufferSource,
134
+ { name: "HMAC", hash: "SHA-256" },
135
+ false,
136
+ ["sign"],
137
+ );
138
+ const sig = await crypto.subtle.sign("HMAC", hmacKey, new TextEncoder().encode(machineLabel));
139
+ return hex(sig).slice(0, 32);
140
+ }
141
+
142
+ function toB64(bytes: Uint8Array): string {
143
+ let bin = "";
144
+ for (const b of bytes) bin += String.fromCharCode(b);
145
+ return btoa(bin);
146
+ }
147
+
148
+ function fromB64(s: string): Uint8Array {
149
+ const bin = atob(s);
150
+ const out = new Uint8Array(bin.length);
151
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
152
+ return out;
153
+ }
154
+
155
+ /** AES-GCM-encrypt a plaintext payload → { iv, ct } base64 pair. */
156
+ export async function encryptPayload(
157
+ creds: RoomCredentials,
158
+ plaintext: string,
159
+ ): Promise<{ iv: string; ct: string }> {
160
+ const iv = crypto.getRandomValues(new Uint8Array(12));
161
+ const ct = await crypto.subtle.encrypt(
162
+ { name: "AES-GCM", iv: iv as BufferSource },
163
+ creds.key,
164
+ new TextEncoder().encode(plaintext),
165
+ );
166
+ return { iv: toB64(iv), ct: toB64(new Uint8Array(ct)) };
167
+ }
168
+
169
+ /** Decrypt an { iv, ct } pair. Returns null on any failure (wrong room key,
170
+ * tampered frame) — callers drop the frame silently. */
171
+ export async function decryptPayload(
172
+ creds: RoomCredentials,
173
+ frame: { iv: string; ct: string },
174
+ ): Promise<string | null> {
175
+ try {
176
+ const pt = await crypto.subtle.decrypt(
177
+ { name: "AES-GCM", iv: fromB64(frame.iv) as BufferSource },
178
+ creds.key,
179
+ fromB64(frame.ct) as BufferSource,
180
+ );
181
+ return new TextDecoder().decode(pt);
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ /* ---------- wire envelope ---------- */
188
+
189
+ /** Client → relay and relay → client publication frame. */
190
+ export interface PubFrame {
191
+ t: "pub";
192
+ /** Opaque sender id (computeSenderId). The relay keys its warm-join cache on this. */
193
+ sender: string;
194
+ iv: string;
195
+ ct: string;
196
+ }
197
+
198
+ /** Relay → client greeting on join. */
199
+ export interface HelloFrame {
200
+ t: "hello";
201
+ /** Live sockets in the room at join time (including the joiner). */
202
+ peers: number;
203
+ }
204
+
205
+ export type RelayFrame = PubFrame | HelloFrame;
206
+
207
+ /** Hard cap a relay enforces per text frame (ciphertext of a ~2KB blob is far
208
+ * smaller; anything bigger is abuse or a bug). */
209
+ export const MAX_FRAME_BYTES = 16 * 1024;
210
+
211
+ /** Parse + shape-validate an incoming relay frame. Null on anything off. */
212
+ export function parseRelayFrame(raw: string): RelayFrame | null {
213
+ if (raw.length > MAX_FRAME_BYTES) return null;
214
+ let j: unknown;
215
+ try {
216
+ j = JSON.parse(raw);
217
+ } catch {
218
+ return null;
219
+ }
220
+ if (!j || typeof j !== "object") return null;
221
+ const f = j as Record<string, unknown>;
222
+ if (f.t === "pub") {
223
+ if (
224
+ typeof f.sender !== "string" ||
225
+ f.sender.length === 0 ||
226
+ f.sender.length > 64 ||
227
+ typeof f.iv !== "string" ||
228
+ typeof f.ct !== "string" ||
229
+ f.iv.length > 64 ||
230
+ f.ct.length > MAX_FRAME_BYTES
231
+ ) {
232
+ return null;
233
+ }
234
+ return { t: "pub", sender: f.sender, iv: f.iv, ct: f.ct };
235
+ }
236
+ if (f.t === "hello") {
237
+ if (typeof f.peers !== "number") return null;
238
+ return { t: "hello", peers: f.peers };
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /** Room ids are 32 lowercase hex chars; relays reject anything else. */
244
+ export function isValidRoomId(roomId: string): boolean {
245
+ return /^[0-9a-f]{32}$/.test(roomId);
246
+ }
@@ -25,7 +25,7 @@ import type { HarnessName } from "./types.ts";
25
25
  /** The env var each harness CLI reads as an API key (preferred over a stored
26
26
  * login when set). Shared with the child-env builder's subscription-only
27
27
  * scrub. */
28
- export const API_KEY_VARS: Record<HarnessName, string> = {
28
+ export const API_KEY_VARS: Record<string, string> = {
29
29
  "claude-code": "ANTHROPIC_API_KEY",
30
30
  codex: "OPENAI_API_KEY",
31
31
  cursor: "CURSOR_API_KEY",
@@ -63,7 +63,7 @@ export function probeBilling(harness: HarnessName, io: ProbeIo = {}): BillingPro
63
63
  const env = io.env ?? process.env;
64
64
  const home = io.home ?? homedir();
65
65
  const keyVar = API_KEY_VARS[harness];
66
- const envKey = Boolean(env[keyVar]?.trim());
66
+ const envKey = keyVar ? Boolean(env[keyVar]?.trim()) : false;
67
67
 
68
68
  let login: LoginState;
69
69
  let apiKeyPresent = envKey;
@@ -90,6 +90,12 @@ export function probeBilling(harness: HarnessName, io: ProbeIo = {}): BillingPro
90
90
  // presence or absence we can't prove.
91
91
  login = "unknown";
92
92
  break;
93
+ default:
94
+ // External adapters own their authentication. The generic engine must
95
+ // not guess credential locations or fail a plugin on a built-in-only
96
+ // heuristic.
97
+ login = "unknown";
98
+ break;
93
99
  }
94
100
 
95
101
  const mode: BillingMode =
@@ -121,7 +121,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
121
121
  const label = agentOpts.label ?? `${prompt.slice(0, 60).replace(/\s+/g, " ")}…`;
122
122
  const maxAttempts = agentOpts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
123
123
 
124
- // Call identity for resume: same stage + harness + model + turns + schema
124
+ // Call identity for resume: same stage + harness + model + effort + turns + schema
125
125
  // + ORIGINAL prompt → same key. Retry-mutated prompts never enter the key.
126
126
  const key = agentCallKey(currentStage, harness, agentOpts, prompt);
127
127
  const cached = resumeCache.get(key);
@@ -187,7 +187,14 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
187
187
 
188
188
  await acquire();
189
189
  try {
190
- journal("agent.start", { id, label, key, harness, model: agentOpts.model ?? null });
190
+ journal("agent.start", {
191
+ id,
192
+ label,
193
+ key,
194
+ harness,
195
+ model: agentOpts.model ?? null,
196
+ effort: agentOpts.effort ?? null,
197
+ });
191
198
  log(`[${name}] ${currentStage || "(no stage)"} → ${id} [${harness}] ${label}`);
192
199
 
193
200
  let attemptPrompt = prompt;
@@ -196,6 +203,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
196
203
  last = await spawner({
197
204
  prompt: attemptPrompt,
198
205
  model: agentOpts.model,
206
+ effort: agentOpts.effort,
199
207
  timeoutMs: agentOpts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
200
208
  maxTurns: agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
201
209
  cwd,
@@ -333,6 +341,7 @@ function agentCallKey(
333
341
  stage,
334
342
  harness,
335
343
  agentOpts.model ?? null,
344
+ agentOpts.effort ?? null,
336
345
  agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
337
346
  agentOpts.schema ?? null,
338
347
  prompt,
@@ -7,32 +7,27 @@
7
7
  * but they do drift — keep this module the single place they live.
8
8
  */
9
9
 
10
+ import { BUILTIN_HARNESS_PROFILES } from "../harnesses/profiles.ts";
10
11
  import type { HarnessName } from "./types.ts";
11
12
 
12
- export const HARNESS_BINARIES: Record<HarnessName, string> = {
13
- "claude-code": "claude",
14
- codex: "codex",
15
- cursor: "cursor-agent",
16
- };
13
+ export const HARNESS_BINARIES: Record<string, string> = Object.fromEntries(
14
+ Object.values(BUILTIN_HARNESS_PROFILES).map((profile) => [profile.id, profile.binary]),
15
+ );
17
16
 
18
- export const HARNESS_INSTALL_HINTS: Record<HarnessName, string> = {
19
- "claude-code": "npm install -g @anthropic-ai/claude-code",
20
- codex: "npm install -g @openai/codex",
21
- cursor: "curl https://cursor.com/install -fsS | bash",
22
- };
17
+ export const HARNESS_INSTALL_HINTS: Record<string, string> = Object.fromEntries(
18
+ Object.values(BUILTIN_HARNESS_PROFILES).map((profile) => [profile.id, profile.installHint]),
19
+ );
23
20
 
24
21
  /** How to authenticate each CLI with a subscription login (the billing
25
22
  * default — see billing.ts). */
26
- export const HARNESS_LOGIN_HINTS: Record<HarnessName, string> = {
27
- "claude-code": "run `claude` and use /login",
28
- codex: "codex login",
29
- cursor: "cursor-agent login",
30
- };
23
+ export const HARNESS_LOGIN_HINTS: Record<string, string> = Object.fromEntries(
24
+ Object.values(BUILTIN_HARNESS_PROFILES).map((profile) => [profile.id, profile.loginHint]),
25
+ );
31
26
 
32
27
  /** One-line "it's missing, here's the fix" string for spawn adapters. */
33
28
  export function notFoundError(harness: HarnessName): string {
34
- return (
35
- `${HARNESS_BINARIES[harness]} CLI not found on PATH; ` +
36
- `install: ${HARNESS_INSTALL_HINTS[harness]} then authenticate: ${HARNESS_LOGIN_HINTS[harness]}`
37
- );
29
+ const binary = HARNESS_BINARIES[harness] ?? harness;
30
+ const install = HARNESS_INSTALL_HINTS[harness] ?? "install the harness CLI";
31
+ const login = HARNESS_LOGIN_HINTS[harness] ?? "authenticate the harness CLI";
32
+ return `${binary} CLI not found on PATH; ` + `install: ${install} then authenticate: ${login}`;
38
33
  }