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.
Files changed (234) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +8 -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 +37 -0
  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/grep.d.ts +35 -2
  23. package/dist/commands/grep.d.ts.map +1 -1
  24. package/dist/commands/grep.js +427 -139
  25. package/dist/commands/harness.d.ts +11 -0
  26. package/dist/commands/harness.d.ts.map +1 -0
  27. package/dist/commands/harness.js +118 -0
  28. package/dist/commands/init.d.ts +15 -5
  29. package/dist/commands/init.d.ts.map +1 -1
  30. package/dist/commands/init.js +125 -14
  31. package/dist/commands/presence.d.ts +9 -4
  32. package/dist/commands/presence.d.ts.map +1 -1
  33. package/dist/commands/presence.js +88 -5
  34. package/dist/commands/relay.d.ts +9 -0
  35. package/dist/commands/relay.d.ts.map +1 -0
  36. package/dist/commands/relay.js +143 -0
  37. package/dist/commands/sync.d.ts.map +1 -1
  38. package/dist/commands/sync.js +5 -0
  39. package/dist/commands/workflow.d.ts +4 -0
  40. package/dist/commands/workflow.d.ts.map +1 -0
  41. package/dist/commands/workflow.js +82 -0
  42. package/dist/core/agents/canonical-emit.d.ts +15 -0
  43. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  44. package/dist/core/agents/canonical-emit.js +41 -3
  45. package/dist/core/agents/cli.js +14 -5
  46. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  47. package/dist/core/agents/render/prompt-context.js +44 -3
  48. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/session-context.js +26 -1
  50. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  51. package/dist/core/agents/rules/claim-conflict.js +26 -1
  52. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  53. package/dist/core/agents/rules/commit-conflict.js +4 -37
  54. package/dist/core/agents/rules/stop-hook.d.ts +8 -0
  55. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  56. package/dist/core/agents/rules/stop-hook.js +8 -0
  57. package/dist/core/agents/session-events.d.ts.map +1 -1
  58. package/dist/core/agents/session-events.js +16 -43
  59. package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
  60. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  61. package/dist/core/agents/state/heartbeat-projector.js +24 -4
  62. package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
  63. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  64. package/dist/core/agents/state/heartbeat-writer.js +24 -7
  65. package/dist/core/agents/state/names.d.ts +38 -3
  66. package/dist/core/agents/state/names.d.ts.map +1 -1
  67. package/dist/core/agents/state/names.js +46 -7
  68. package/dist/core/agents/state/pidmap.d.ts +5 -1
  69. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  70. package/dist/core/agents/state/pidmap.js +58 -2
  71. package/dist/core/agents/state/stale-sweep.d.ts +3 -2
  72. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  73. package/dist/core/agents/state/stale-sweep.js +6 -6
  74. package/dist/core/config.d.ts +77 -11
  75. package/dist/core/config.d.ts.map +1 -1
  76. package/dist/core/config.js +210 -25
  77. package/dist/core/context/index.d.ts +144 -0
  78. package/dist/core/context/index.d.ts.map +1 -0
  79. package/dist/core/context/index.js +380 -0
  80. package/dist/core/harnesses/bench.d.ts +29 -0
  81. package/dist/core/harnesses/bench.d.ts.map +1 -0
  82. package/dist/core/harnesses/bench.js +151 -0
  83. package/dist/core/harnesses/index.d.ts +9 -0
  84. package/dist/core/harnesses/index.d.ts.map +1 -0
  85. package/dist/core/harnesses/index.js +4 -0
  86. package/dist/core/harnesses/profiles.d.ts +62 -0
  87. package/dist/core/harnesses/profiles.d.ts.map +1 -0
  88. package/dist/core/harnesses/profiles.js +115 -0
  89. package/dist/core/harnesses/registry.d.ts +15 -0
  90. package/dist/core/harnesses/registry.d.ts.map +1 -0
  91. package/dist/core/harnesses/registry.js +113 -0
  92. package/dist/core/harnesses/types.d.ts +64 -0
  93. package/dist/core/harnesses/types.d.ts.map +1 -0
  94. package/dist/core/harnesses/types.js +20 -0
  95. package/dist/core/hooks/cli.js +309 -66
  96. package/dist/core/hooks/events/schema.d.ts +42 -1
  97. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  98. package/dist/core/hooks/harness/events.d.ts +7 -0
  99. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  100. package/dist/core/hooks/harness/events.js +4 -0
  101. package/dist/core/hooks/harness/parse.d.ts +1 -1
  102. package/dist/core/hooks/harness/parse.d.ts.map +1 -1
  103. package/dist/core/hooks/harness/parse.js +4 -0
  104. package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
  105. package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
  106. package/dist/core/hooks/resolve/coord-root.js +28 -6
  107. package/dist/core/presence/blob.d.ts +40 -0
  108. package/dist/core/presence/blob.d.ts.map +1 -0
  109. package/dist/core/presence/blob.js +91 -0
  110. package/dist/core/presence/git.d.ts +68 -0
  111. package/dist/core/presence/git.d.ts.map +1 -0
  112. package/dist/core/presence/git.js +173 -0
  113. package/dist/core/presence/index.d.ts +74 -0
  114. package/dist/core/presence/index.d.ts.map +1 -0
  115. package/dist/core/presence/index.js +224 -0
  116. package/dist/core/presence/relay-client.d.ts +39 -0
  117. package/dist/core/presence/relay-client.d.ts.map +1 -0
  118. package/dist/core/presence/relay-client.js +295 -0
  119. package/dist/core/presence/relay-protocol.d.ts +94 -0
  120. package/dist/core/presence/relay-protocol.d.ts.map +1 -0
  121. package/dist/core/presence/relay-protocol.js +169 -0
  122. package/dist/core/workflow/billing.d.ts +48 -0
  123. package/dist/core/workflow/billing.d.ts.map +1 -0
  124. package/dist/core/workflow/billing.js +108 -0
  125. package/dist/core/workflow/child-env.d.ts +30 -0
  126. package/dist/core/workflow/child-env.d.ts.map +1 -0
  127. package/dist/core/workflow/child-env.js +43 -0
  128. package/dist/core/workflow/engine.d.ts +21 -0
  129. package/dist/core/workflow/engine.d.ts.map +1 -0
  130. package/dist/core/workflow/engine.js +349 -0
  131. package/dist/core/workflow/harnesses.d.ts +17 -0
  132. package/dist/core/workflow/harnesses.d.ts.map +1 -0
  133. package/dist/core/workflow/harnesses.js +21 -0
  134. package/dist/core/workflow/spawn-claude.d.ts +25 -0
  135. package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
  136. package/dist/core/workflow/spawn-claude.js +103 -0
  137. package/dist/core/workflow/spawn-codex.d.ts +22 -0
  138. package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
  139. package/dist/core/workflow/spawn-codex.js +88 -0
  140. package/dist/core/workflow/spawn-cursor.d.ts +29 -0
  141. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
  142. package/dist/core/workflow/spawn-cursor.js +91 -0
  143. package/dist/core/workflow/types.d.ts +152 -0
  144. package/dist/core/workflow/types.d.ts.map +1 -0
  145. package/dist/core/workflow/types.js +9 -0
  146. package/dist/core/workflow/validate.d.ts +15 -0
  147. package/dist/core/workflow/validate.d.ts.map +1 -0
  148. package/dist/core/workflow/validate.js +70 -0
  149. package/dist/lib/browser/client.d.ts +16 -0
  150. package/dist/lib/browser/client.d.ts.map +1 -1
  151. package/dist/lib/browser/client.js +22 -0
  152. package/dist/lib/browser/index.d.ts +1 -0
  153. package/dist/lib/browser/index.d.ts.map +1 -1
  154. package/dist/lib/browser/index.js +1 -0
  155. package/dist/lib/browser/launch-args.d.ts +21 -0
  156. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  157. package/dist/lib/browser/launch-args.js +32 -0
  158. package/dist/lib/claude-desktop.d.ts +105 -0
  159. package/dist/lib/claude-desktop.d.ts.map +1 -0
  160. package/dist/lib/claude-desktop.js +217 -0
  161. package/dist/lib/docs-lint.d.ts.map +1 -1
  162. package/dist/lib/docs-lint.js +5 -2
  163. package/dist/lib/identities/assume.d.ts +51 -0
  164. package/dist/lib/identities/assume.d.ts.map +1 -0
  165. package/dist/lib/identities/assume.js +274 -0
  166. package/dist/lib/identities/index.d.ts +7 -7
  167. package/dist/lib/identities/index.d.ts.map +1 -1
  168. package/dist/lib/identities/index.js +24 -24
  169. package/dist/lib/instructions/templates.d.ts.map +1 -1
  170. package/dist/lib/instructions/templates.js +15 -2
  171. package/package.json +12 -1
  172. package/schemas/config.schema.json +88 -19
  173. package/src/commander.ts +8 -0
  174. package/src/commands/agents.ts +280 -14
  175. package/src/commands/backup.ts +17 -16
  176. package/src/commands/browse.ts +32 -0
  177. package/src/commands/claude-desktop.ts +215 -0
  178. package/src/commands/context.ts +174 -1
  179. package/src/commands/doctor.ts +44 -0
  180. package/src/commands/eml.ts +22 -4
  181. package/src/commands/grep.ts +535 -142
  182. package/src/commands/harness.ts +147 -0
  183. package/src/commands/init.ts +138 -17
  184. package/src/commands/presence.ts +111 -5
  185. package/src/commands/relay.ts +160 -0
  186. package/src/commands/sync.ts +4 -0
  187. package/src/commands/workflow.ts +123 -0
  188. package/src/core/agents/canonical-emit.ts +41 -3
  189. package/src/core/agents/cli.ts +15 -6
  190. package/src/core/agents/render/prompt-context.ts +48 -3
  191. package/src/core/agents/render/session-context.ts +27 -1
  192. package/src/core/agents/rules/claim-conflict.ts +26 -1
  193. package/src/core/agents/rules/commit-conflict.ts +4 -33
  194. package/src/core/agents/rules/stop-hook.ts +17 -0
  195. package/src/core/agents/session-events.ts +15 -39
  196. package/src/core/agents/state/heartbeat-projector.ts +23 -2
  197. package/src/core/agents/state/heartbeat-writer.ts +33 -7
  198. package/src/core/agents/state/names.ts +68 -9
  199. package/src/core/agents/state/pidmap.ts +59 -2
  200. package/src/core/agents/state/stale-sweep.ts +6 -11
  201. package/src/core/config.ts +271 -24
  202. package/src/core/context/index.ts +575 -0
  203. package/src/core/harnesses/bench.ts +214 -0
  204. package/src/core/harnesses/index.ts +31 -0
  205. package/src/core/harnesses/profiles.ts +141 -0
  206. package/src/core/harnesses/registry.ts +140 -0
  207. package/src/core/harnesses/types.ts +90 -0
  208. package/src/core/hooks/cli.ts +361 -65
  209. package/src/core/hooks/events/schema.ts +72 -0
  210. package/src/core/hooks/harness/events.ts +11 -0
  211. package/src/core/hooks/harness/parse.ts +7 -1
  212. package/src/core/hooks/resolve/coord-root.ts +28 -6
  213. package/src/core/presence/blob.ts +125 -0
  214. package/src/core/presence/git.ts +191 -0
  215. package/src/core/presence/index.ts +274 -0
  216. package/src/core/presence/relay-client.ts +322 -0
  217. package/src/core/presence/relay-protocol.ts +246 -0
  218. package/src/core/workflow/billing.ts +152 -0
  219. package/src/core/workflow/child-env.ts +47 -0
  220. package/src/core/workflow/engine.ts +403 -0
  221. package/src/core/workflow/harnesses.ts +33 -0
  222. package/src/core/workflow/spawn-claude.ts +118 -0
  223. package/src/core/workflow/spawn-codex.ts +93 -0
  224. package/src/core/workflow/spawn-cursor.ts +108 -0
  225. package/src/core/workflow/types.ts +160 -0
  226. package/src/core/workflow/validate.ts +75 -0
  227. package/src/lib/browser/client.ts +37 -0
  228. package/src/lib/browser/index.ts +1 -0
  229. package/src/lib/browser/launch-args.ts +33 -0
  230. package/src/lib/claude-desktop.ts +293 -0
  231. package/src/lib/docs-lint.ts +5 -2
  232. package/src/lib/identities/assume.ts +363 -0
  233. package/src/lib/identities/index.ts +28 -24
  234. 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"}