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,91 @@
1
+ /**
2
+ * Presence blob: the per-machine payload published to
3
+ * `refs/harnery/presence/<machine>` (ADR 0016). One blob describes every live
4
+ * session on this machine; peers render it in their agents list / status box /
5
+ * peer table, labeled by machine.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { resolveMachineLabel } from "../../lib/machine.js";
11
+ /** Mirror of the local heartbeat freshness window (commands/agents.ts). */
12
+ const FRESHNESS_SECS = 600;
13
+ /** Caps that bound the blob to ~1-2KB regardless of local state. */
14
+ const MAX_AGENTS = 20;
15
+ const MAX_FILES_PER_AGENT = 10;
16
+ /**
17
+ * Build this machine's presence blob from `.harnery/active/` heartbeats.
18
+ * Includes live sessions + subagents; excludes kind=transient stubs (they are
19
+ * fold-artifacts of the local claim model, not sessions) and anything past the
20
+ * freshness window.
21
+ */
22
+ export function buildPresenceBlob(coordRoot, now = new Date()) {
23
+ const machine = resolveMachineLabel();
24
+ const agents = [];
25
+ const activeDir = join(coordRoot, ".harnery", "active");
26
+ const cutoffMs = now.getTime() - FRESHNESS_SECS * 1000;
27
+ if (existsSync(activeDir)) {
28
+ for (const f of readdirSync(activeDir)) {
29
+ if (!f.endsWith(".json"))
30
+ continue;
31
+ let hb;
32
+ try {
33
+ hb = JSON.parse(readFileSync(join(activeDir, f), "utf8"));
34
+ }
35
+ catch {
36
+ continue;
37
+ }
38
+ if (typeof hb.instance_id !== "string")
39
+ continue;
40
+ if (hb.kind === "transient")
41
+ continue;
42
+ const ts = Date.parse(hb.last_heartbeat ?? "");
43
+ if (!Number.isFinite(ts) || ts < cutoffMs)
44
+ continue;
45
+ agents.push({
46
+ instance_id: hb.instance_id,
47
+ name: strOr(hb.name),
48
+ kind: strOr(hb.kind),
49
+ session_id: strOr(hb.session_id),
50
+ platform: strOr(hb.platform),
51
+ task: strOr(hb.task),
52
+ turn_summary: clamp(strOr(hb.turn_summary), 160),
53
+ files_touched: Array.isArray(hb.files_touched)
54
+ ? hb.files_touched.slice(0, MAX_FILES_PER_AGENT)
55
+ : undefined,
56
+ last_tool: strOr(hb.last_tool),
57
+ started_at: strOr(hb.started_at),
58
+ last_heartbeat: strOr(hb.last_heartbeat),
59
+ });
60
+ }
61
+ }
62
+ agents.sort((a, b) => a.instance_id.localeCompare(b.instance_id));
63
+ const capped = agents.slice(0, MAX_AGENTS);
64
+ const blob = {
65
+ v: 1,
66
+ machine,
67
+ published_at: now.toISOString().replace(/\.\d{3}Z$/, "Z"),
68
+ agents: capped,
69
+ };
70
+ // Basis: the fields whose change should trigger a re-publish. Timestamps
71
+ // and last_tool are excluded (pure churn); task/turn_summary/files are the
72
+ // signal peers actually read.
73
+ const basis = capped.map((a) => ({
74
+ i: a.instance_id,
75
+ n: a.name ?? null,
76
+ k: a.kind ?? null,
77
+ t: a.task ?? null,
78
+ s: a.turn_summary ?? null,
79
+ f: [...(a.files_touched ?? [])].sort(),
80
+ }));
81
+ const basisHash = createHash("sha256").update(JSON.stringify(basis)).digest("hex").slice(0, 16);
82
+ return { blob, basisHash, json: JSON.stringify(blob) };
83
+ }
84
+ function strOr(v) {
85
+ return typeof v === "string" && v.length > 0 ? v : undefined;
86
+ }
87
+ function clamp(v, max) {
88
+ if (!v)
89
+ return undefined;
90
+ return v.length > max ? v.slice(0, max) : v;
91
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Git plumbing for the cross-machine presence transport (ADR 0016).
3
+ *
4
+ * Presence blobs travel as parentless commits (empty tree, JSON message)
5
+ * force-pushed to `refs/harnery/presence/<machine>` on the host repo's own
6
+ * `origin`. Non-branch refs: invisible to branch tooling, safe to force-push,
7
+ * no history accumulation. The remote is the rendezvous every install already
8
+ * shares, and repo access is the ACL.
9
+ *
10
+ * Everything here is best-effort and fail-silent by design — presence must
11
+ * never break a hook or a render. Network operations (push/fetch) run either
12
+ * detached (hook path: never block a turn) or synchronously (CLI path: the
13
+ * user wants to see errors).
14
+ */
15
+ /** The ref namespace. Hardcoded — never interpolated from config — so the
16
+ * publisher is structurally unable to touch refs/heads/* (ADR 0016). */
17
+ export declare const PRESENCE_REF_PREFIX = "refs/harnery/presence/";
18
+ /** Does the repo have an `origin` remote to rendezvous on? */
19
+ export declare function hasOrigin(root: string): boolean;
20
+ /** The origin remote URL, or null. Input to relay room derivation. */
21
+ export declare function originUrl(root: string): string | null;
22
+ /**
23
+ * The repo's root-commit SHA — the stable repo-identity input to relay room
24
+ * derivation. Repos can have multiple roots (merged histories); the list is
25
+ * sorted so every clone picks the same one. Null when the repo has no commits.
26
+ */
27
+ export declare function rootCommitSha(root: string): string | null;
28
+ /**
29
+ * Sanitize a machine label into a valid single ref component: lowercase,
30
+ * [a-z0-9._-] only, no leading dot, no `.lock` suffix, never empty.
31
+ */
32
+ export declare function sanitizeRefComponent(label: string): string;
33
+ /** Build the parentless presence commit carrying `message` (the JSON blob).
34
+ * Returns the commit sha, or null on failure. Local-only, fast (~10ms). */
35
+ export declare function writePresenceCommit(root: string, message: string): string | null;
36
+ /**
37
+ * Force-push a presence commit to `refs/harnery/presence/<machine>`.
38
+ * `sync: true` (CLI/tests) waits and reports; default is detached fire-and-
39
+ * forget (hook path — a slow or credential-less push must not block a turn).
40
+ */
41
+ export declare function pushPresenceRef(root: string, machine: string, sha: string, opts?: {
42
+ sync?: boolean;
43
+ }): {
44
+ ok: boolean;
45
+ error?: string;
46
+ };
47
+ /** Fetch every peer presence ref from origin into the local ref namespace. */
48
+ export declare function fetchPresenceRefs(root: string, opts?: {
49
+ sync?: boolean;
50
+ }): {
51
+ ok: boolean;
52
+ error?: string;
53
+ };
54
+ /** Read every locally-known presence ref → [{ref, machine, message}]. */
55
+ export declare function readPresenceRefs(root: string): Array<{
56
+ machine: string;
57
+ message: string;
58
+ }>;
59
+ /**
60
+ * Parse `for-each-ref --format='%(refname)%00%(contents)%01'` output.
61
+ * Records are %01-terminated (contents may contain newlines); refname and
62
+ * contents are %00-separated. Exported for unit tests.
63
+ */
64
+ export declare function parseForEachRefOutput(out: string): Array<{
65
+ machine: string;
66
+ message: string;
67
+ }>;
68
+ //# sourceMappingURL=git.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git.d.ts","sourceRoot":"","sources":["../../../src/core/presence/git.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH;wEACwE;AACxE,eAAO,MAAM,mBAAmB,2BAA2B,CAAC;AAwC5D,8DAA8D;AAC9D,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE/C;AAED,sEAAsE;AACtE,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIrD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CASzD;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAS1D;AAED;2EAC2E;AAC3E,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOhF;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,EACX,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,GAC5B;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAcjC;AAED,8EAA8E;AAC9E,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,GAC5B;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAcjC;AAED,yEAAyE;AACzE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAQ1F;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,KAAK,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAe9F"}
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Git plumbing for the cross-machine presence transport (ADR 0016).
3
+ *
4
+ * Presence blobs travel as parentless commits (empty tree, JSON message)
5
+ * force-pushed to `refs/harnery/presence/<machine>` on the host repo's own
6
+ * `origin`. Non-branch refs: invisible to branch tooling, safe to force-push,
7
+ * no history accumulation. The remote is the rendezvous every install already
8
+ * shares, and repo access is the ACL.
9
+ *
10
+ * Everything here is best-effort and fail-silent by design — presence must
11
+ * never break a hook or a render. Network operations (push/fetch) run either
12
+ * detached (hook path: never block a turn) or synchronously (CLI path: the
13
+ * user wants to see errors).
14
+ */
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ /** The ref namespace. Hardcoded — never interpolated from config — so the
17
+ * publisher is structurally unable to touch refs/heads/* (ADR 0016). */
18
+ export const PRESENCE_REF_PREFIX = "refs/harnery/presence/";
19
+ /** Well-known SHA of git's empty tree; `git mktree` below guarantees the
20
+ * object exists in the local odb before we build a commit on it. */
21
+ const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
22
+ const GIT_TIMEOUT_MS = 8000;
23
+ /** Non-interactive env for every presence git call: never prompt for creds
24
+ * (a hook must not hang on a password), and a fixed identity so commit-tree
25
+ * works on machines with no user.name/user.email configured. */
26
+ function gitEnv() {
27
+ return {
28
+ ...process.env,
29
+ GIT_TERMINAL_PROMPT: "0",
30
+ GIT_AUTHOR_NAME: "harnery-presence",
31
+ GIT_AUTHOR_EMAIL: "presence@harnery.invalid",
32
+ GIT_COMMITTER_NAME: "harnery-presence",
33
+ GIT_COMMITTER_EMAIL: "presence@harnery.invalid",
34
+ };
35
+ }
36
+ function runGit(root, args, input) {
37
+ try {
38
+ const r = spawnSync("git", ["-C", root, ...args], {
39
+ encoding: "utf8",
40
+ timeout: GIT_TIMEOUT_MS,
41
+ env: gitEnv(),
42
+ ...(input !== undefined ? { input } : {}),
43
+ });
44
+ return { ok: r.status === 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
45
+ }
46
+ catch {
47
+ return { ok: false, stdout: "", stderr: "spawn failed" };
48
+ }
49
+ }
50
+ /** Does the repo have an `origin` remote to rendezvous on? */
51
+ export function hasOrigin(root) {
52
+ return runGit(root, ["remote", "get-url", "origin"]).ok;
53
+ }
54
+ /** The origin remote URL, or null. Input to relay room derivation. */
55
+ export function originUrl(root) {
56
+ const r = runGit(root, ["remote", "get-url", "origin"]);
57
+ const url = r.stdout.trim();
58
+ return r.ok && url ? url : null;
59
+ }
60
+ /**
61
+ * The repo's root-commit SHA — the stable repo-identity input to relay room
62
+ * derivation. Repos can have multiple roots (merged histories); the list is
63
+ * sorted so every clone picks the same one. Null when the repo has no commits.
64
+ */
65
+ export function rootCommitSha(root) {
66
+ const r = runGit(root, ["rev-list", "--max-parents=0", "HEAD"]);
67
+ if (!r.ok)
68
+ return null;
69
+ const shas = r.stdout
70
+ .split("\n")
71
+ .map((l) => l.trim())
72
+ .filter(Boolean)
73
+ .sort();
74
+ return shas[0] ?? null;
75
+ }
76
+ /**
77
+ * Sanitize a machine label into a valid single ref component: lowercase,
78
+ * [a-z0-9._-] only, no leading dot, no `.lock` suffix, never empty.
79
+ */
80
+ export function sanitizeRefComponent(label) {
81
+ let s = label
82
+ .toLowerCase()
83
+ .replace(/[^a-z0-9._-]+/g, "-")
84
+ .replace(/\.{2,}/g, ".")
85
+ .replace(/^[.-]+/, "")
86
+ .replace(/\.lock$/, "");
87
+ if (!s)
88
+ s = "unknown";
89
+ return s;
90
+ }
91
+ /** Build the parentless presence commit carrying `message` (the JSON blob).
92
+ * Returns the commit sha, or null on failure. Local-only, fast (~10ms). */
93
+ export function writePresenceCommit(root, message) {
94
+ // Ensure the empty-tree object exists in this odb (mktree with empty stdin
95
+ // writes it idempotently).
96
+ const mk = runGit(root, ["mktree"], "");
97
+ const tree = mk.ok && mk.stdout.trim() ? mk.stdout.trim() : EMPTY_TREE_SHA;
98
+ const r = runGit(root, ["commit-tree", tree, "-m", message]);
99
+ return r.ok && r.stdout.trim() ? r.stdout.trim() : null;
100
+ }
101
+ /**
102
+ * Force-push a presence commit to `refs/harnery/presence/<machine>`.
103
+ * `sync: true` (CLI/tests) waits and reports; default is detached fire-and-
104
+ * forget (hook path — a slow or credential-less push must not block a turn).
105
+ */
106
+ export function pushPresenceRef(root, machine, sha, opts = {}) {
107
+ const refspec = `+${sha}:${PRESENCE_REF_PREFIX}${sanitizeRefComponent(machine)}`;
108
+ const args = ["-C", root, "push", "--quiet", "--no-verify", "origin", refspec];
109
+ if (opts.sync) {
110
+ const r = runGit(root, args.slice(2));
111
+ return r.ok ? { ok: true } : { ok: false, error: r.stderr.trim().slice(0, 300) };
112
+ }
113
+ try {
114
+ const child = spawn("git", args, { detached: true, stdio: "ignore", env: gitEnv() });
115
+ child.unref();
116
+ return { ok: true };
117
+ }
118
+ catch {
119
+ return { ok: false, error: "spawn failed" };
120
+ }
121
+ }
122
+ /** Fetch every peer presence ref from origin into the local ref namespace. */
123
+ export function fetchPresenceRefs(root, opts = {}) {
124
+ const refspec = `+${PRESENCE_REF_PREFIX}*:${PRESENCE_REF_PREFIX}*`;
125
+ const args = ["-C", root, "fetch", "--quiet", "--no-tags", "origin", refspec];
126
+ if (opts.sync) {
127
+ const r = runGit(root, args.slice(2));
128
+ return r.ok ? { ok: true } : { ok: false, error: r.stderr.trim().slice(0, 300) };
129
+ }
130
+ try {
131
+ const child = spawn("git", args, { detached: true, stdio: "ignore", env: gitEnv() });
132
+ child.unref();
133
+ return { ok: true };
134
+ }
135
+ catch {
136
+ return { ok: false, error: "spawn failed" };
137
+ }
138
+ }
139
+ /** Read every locally-known presence ref → [{ref, machine, message}]. */
140
+ export function readPresenceRefs(root) {
141
+ const r = runGit(root, [
142
+ "for-each-ref",
143
+ "--format=%(refname)%00%(contents)%01",
144
+ `${PRESENCE_REF_PREFIX}*`,
145
+ ]);
146
+ if (!r.ok)
147
+ return [];
148
+ return parseForEachRefOutput(r.stdout);
149
+ }
150
+ /**
151
+ * Parse `for-each-ref --format='%(refname)%00%(contents)%01'` output.
152
+ * Records are %01-terminated (contents may contain newlines); refname and
153
+ * contents are %00-separated. Exported for unit tests.
154
+ */
155
+ export function parseForEachRefOutput(out) {
156
+ const rows = [];
157
+ for (const rec of out.split("\u0001")) {
158
+ const trimmed = rec.replace(/^\n+/, "");
159
+ if (!trimmed)
160
+ continue;
161
+ const sep = trimmed.indexOf("\u0000");
162
+ if (sep === -1)
163
+ continue;
164
+ const refname = trimmed.slice(0, sep).trim();
165
+ if (!refname.startsWith(PRESENCE_REF_PREFIX))
166
+ continue;
167
+ rows.push({
168
+ machine: refname.slice(PRESENCE_REF_PREFIX.length),
169
+ message: trimmed.slice(sep + 1).trim(),
170
+ });
171
+ }
172
+ return rows;
173
+ }
@@ -0,0 +1,74 @@
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 { type PresenceAgent } from "./blob.js";
18
+ export type { PresenceAgent, PresenceBlob } from "./blob.js";
19
+ export { buildPresenceBlob } from "./blob.js";
20
+ export { PRESENCE_REF_PREFIX, parseForEachRefOutput, sanitizeRefComponent } from "./git.js";
21
+ export { ensureRelayDaemon, relayDaemonStatus, runRelayDaemon } from "./relay-client.js";
22
+ export type PublishResult = {
23
+ status: "published";
24
+ machine: string;
25
+ agents: number;
26
+ sha: string;
27
+ } | {
28
+ status: "skipped";
29
+ reason: string;
30
+ } | {
31
+ status: "error";
32
+ error: string;
33
+ };
34
+ export type FetchResult = {
35
+ status: "fetched";
36
+ } | {
37
+ status: "skipped";
38
+ reason: string;
39
+ } | {
40
+ status: "error";
41
+ error: string;
42
+ };
43
+ /**
44
+ * Publish this machine's presence blob to origin if it changed (or the
45
+ * keepalive elapsed). `sync: true` pushes in-process and reports errors (CLI
46
+ * path); default spawns the push detached (hook path).
47
+ */
48
+ export declare function publishPresence(coordRoot: string, opts?: {
49
+ force?: boolean;
50
+ sync?: boolean;
51
+ }): PublishResult;
52
+ /** Throttled fetch of peer presence refs from origin. */
53
+ export declare function fetchPresence(coordRoot: string, opts?: {
54
+ force?: boolean;
55
+ sync?: boolean;
56
+ }): FetchResult;
57
+ export interface RemoteMachine {
58
+ machine: string;
59
+ published_at: string;
60
+ age_secs: number;
61
+ agents: PresenceAgent[];
62
+ }
63
+ /**
64
+ * Render-ready view of every OTHER machine's presence, merged from BOTH
65
+ * transports: the locally-fetched git refs (the floor) and the relay
66
+ * daemon's cache files at `.harnery/presence/remote/` (the live upgrade).
67
+ * Freshest source per machine wins. Self machine excluded; blobs older than
68
+ * the stale window or with zero agents dropped.
69
+ */
70
+ export declare function readRemoteMachines(coordRoot: string, opts?: {
71
+ includeSelf?: boolean;
72
+ includeStale?: boolean;
73
+ }): RemoteMachine[];
74
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/core/presence/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAgBH,OAAO,EAAqB,KAAK,aAAa,EAAqB,MAAM,WAAW,CAAC;AAUrF,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAC7D,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAC5F,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAYzF,MAAM,MAAM,aAAa,GACrB;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACrE;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvC,MAAM,MAAM,WAAW,GACnB;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GACrB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAwCvC;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,EACjB,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,GAC7C,aAAa,CA2Cf;AAED,yDAAyD;AACzD,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,GAC7C,WAAW,CAsCb;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,aAAa,EAAE,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,IAAI,GAAE;IAAE,WAAW,CAAC,EAAE,OAAO,CAAC;IAAC,YAAY,CAAC,EAAE,OAAO,CAAA;CAAO,GAC3D,aAAa,EAAE,CAuDjB"}
@@ -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