harnery 0.9.0 → 0.11.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 (211) 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 +53 -17
  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 +5 -0
  108. package/dist/core/workflow/engine.d.ts.map +1 -1
  109. package/dist/core/workflow/engine.js +164 -16
  110. package/dist/core/workflow/harnesses.d.ts +3 -3
  111. package/dist/core/workflow/harnesses.d.ts.map +1 -1
  112. package/dist/core/workflow/harnesses.js +8 -17
  113. package/dist/core/workflow/index.d.ts +5 -0
  114. package/dist/core/workflow/index.d.ts.map +1 -0
  115. package/dist/core/workflow/index.js +3 -0
  116. package/dist/core/workflow/proof.d.ts +42 -0
  117. package/dist/core/workflow/proof.d.ts.map +1 -0
  118. package/dist/core/workflow/proof.js +342 -0
  119. package/dist/core/workflow/spawn-claude.d.ts +4 -1
  120. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  121. package/dist/core/workflow/spawn-claude.js +39 -18
  122. package/dist/core/workflow/spawn-codex.d.ts +4 -1
  123. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  124. package/dist/core/workflow/spawn-codex.js +44 -22
  125. package/dist/core/workflow/spawn-cursor.d.ts +4 -1
  126. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  127. package/dist/core/workflow/spawn-cursor.js +35 -16
  128. package/dist/core/workflow/types.d.ts +156 -2
  129. package/dist/core/workflow/types.d.ts.map +1 -1
  130. package/dist/core/workflow/types.js +1 -1
  131. package/dist/lib/browser/client.d.ts +16 -0
  132. package/dist/lib/browser/client.d.ts.map +1 -1
  133. package/dist/lib/browser/client.js +22 -0
  134. package/dist/lib/browser/index.d.ts +1 -0
  135. package/dist/lib/browser/index.d.ts.map +1 -1
  136. package/dist/lib/browser/index.js +1 -0
  137. package/dist/lib/browser/launch-args.d.ts +21 -0
  138. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  139. package/dist/lib/browser/launch-args.js +32 -0
  140. package/dist/lib/claude-desktop.d.ts +105 -0
  141. package/dist/lib/claude-desktop.d.ts.map +1 -0
  142. package/dist/lib/claude-desktop.js +217 -0
  143. package/dist/lib/docs-lint.d.ts.map +1 -1
  144. package/dist/lib/docs-lint.js +5 -2
  145. package/dist/lib/identities/assume.d.ts +51 -0
  146. package/dist/lib/identities/assume.d.ts.map +1 -0
  147. package/dist/lib/identities/assume.js +274 -0
  148. package/dist/lib/identities/index.d.ts +7 -7
  149. package/dist/lib/identities/index.d.ts.map +1 -1
  150. package/dist/lib/identities/index.js +24 -24
  151. package/dist/lib/instructions/templates.d.ts.map +1 -1
  152. package/dist/lib/instructions/templates.js +15 -2
  153. package/package.json +22 -6
  154. package/schemas/config.schema.json +88 -19
  155. package/src/commander.ts +6 -0
  156. package/src/commands/agents.ts +280 -14
  157. package/src/commands/backup.ts +17 -16
  158. package/src/commands/browse.ts +32 -0
  159. package/src/commands/claude-desktop.ts +215 -0
  160. package/src/commands/context.ts +174 -1
  161. package/src/commands/doctor.ts +2 -3
  162. package/src/commands/eml.ts +22 -4
  163. package/src/commands/harness.ts +147 -0
  164. package/src/commands/presence.ts +111 -5
  165. package/src/commands/relay.ts +160 -0
  166. package/src/commands/sync.ts +4 -0
  167. package/src/commands/workflow.ts +62 -19
  168. package/src/core/agents/canonical-emit.ts +41 -3
  169. package/src/core/agents/cli.ts +4 -2
  170. package/src/core/agents/render/prompt-context.ts +48 -3
  171. package/src/core/agents/render/session-context.ts +27 -1
  172. package/src/core/agents/rules/commit-conflict.ts +4 -33
  173. package/src/core/agents/session-events.ts +15 -39
  174. package/src/core/agents/state/heartbeat-projector.ts +10 -1
  175. package/src/core/agents/state/heartbeat-writer.ts +3 -2
  176. package/src/core/agents/state/names.ts +68 -9
  177. package/src/core/agents/state/pidmap.ts +59 -2
  178. package/src/core/agents/state/stale-sweep.ts +6 -11
  179. package/src/core/config.ts +248 -24
  180. package/src/core/context/index.ts +575 -0
  181. package/src/core/harnesses/bench.ts +214 -0
  182. package/src/core/harnesses/index.ts +31 -0
  183. package/src/core/harnesses/profiles.ts +141 -0
  184. package/src/core/harnesses/registry.ts +140 -0
  185. package/src/core/harnesses/types.ts +90 -0
  186. package/src/core/hooks/cli.ts +331 -62
  187. package/src/core/hooks/events/schema.ts +68 -0
  188. package/src/core/hooks/harness/events.ts +3 -0
  189. package/src/core/hooks/harness/parse.ts +7 -1
  190. package/src/core/presence/blob.ts +125 -0
  191. package/src/core/presence/git.ts +191 -0
  192. package/src/core/presence/index.ts +274 -0
  193. package/src/core/presence/relay-client.ts +322 -0
  194. package/src/core/presence/relay-protocol.ts +246 -0
  195. package/src/core/workflow/billing.ts +8 -2
  196. package/src/core/workflow/engine.ts +182 -16
  197. package/src/core/workflow/harnesses.ts +14 -19
  198. package/src/core/workflow/index.ts +43 -0
  199. package/src/core/workflow/proof.ts +434 -0
  200. package/src/core/workflow/spawn-claude.ts +39 -20
  201. package/src/core/workflow/spawn-codex.ts +47 -28
  202. package/src/core/workflow/spawn-cursor.ts +37 -18
  203. package/src/core/workflow/types.ts +176 -2
  204. package/src/lib/browser/client.ts +37 -0
  205. package/src/lib/browser/index.ts +1 -0
  206. package/src/lib/browser/launch-args.ts +33 -0
  207. package/src/lib/claude-desktop.ts +293 -0
  208. package/src/lib/docs-lint.ts +5 -2
  209. package/src/lib/identities/assume.ts +363 -0
  210. package/src/lib/identities/index.ts +28 -24
  211. package/src/lib/instructions/templates.ts +16 -2
@@ -0,0 +1,125 @@
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
+
8
+ import { createHash } from "node:crypto";
9
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { resolveMachineLabel } from "../../lib/machine.ts";
12
+
13
+ /** Mirror of the local heartbeat freshness window (commands/agents.ts). */
14
+ const FRESHNESS_SECS = 600;
15
+
16
+ /** Caps that bound the blob to ~1-2KB regardless of local state. */
17
+ const MAX_AGENTS = 20;
18
+ const MAX_FILES_PER_AGENT = 10;
19
+
20
+ export interface PresenceAgent {
21
+ instance_id: string;
22
+ name?: string;
23
+ kind?: string;
24
+ session_id?: string;
25
+ platform?: string;
26
+ task?: string;
27
+ turn_summary?: string;
28
+ files_touched?: string[];
29
+ last_tool?: string;
30
+ started_at?: string;
31
+ last_heartbeat?: string;
32
+ }
33
+
34
+ export interface PresenceBlob {
35
+ v: 1;
36
+ machine: string;
37
+ published_at: string;
38
+ agents: PresenceAgent[];
39
+ }
40
+
41
+ export interface BuiltBlob {
42
+ blob: PresenceBlob;
43
+ /** Stable hash of the semantically-relevant projection — the change
44
+ * detector that decides whether a publish is worth a push. */
45
+ basisHash: string;
46
+ json: string;
47
+ }
48
+
49
+ /**
50
+ * Build this machine's presence blob from `.harnery/active/` heartbeats.
51
+ * Includes live sessions + subagents; excludes kind=transient stubs (they are
52
+ * fold-artifacts of the local claim model, not sessions) and anything past the
53
+ * freshness window.
54
+ */
55
+ export function buildPresenceBlob(coordRoot: string, now: Date = new Date()): BuiltBlob {
56
+ const machine = resolveMachineLabel();
57
+ const agents: PresenceAgent[] = [];
58
+ const activeDir = join(coordRoot, ".harnery", "active");
59
+ const cutoffMs = now.getTime() - FRESHNESS_SECS * 1000;
60
+
61
+ if (existsSync(activeDir)) {
62
+ for (const f of readdirSync(activeDir)) {
63
+ if (!f.endsWith(".json")) continue;
64
+ let hb: Record<string, unknown>;
65
+ try {
66
+ hb = JSON.parse(readFileSync(join(activeDir, f), "utf8")) as Record<string, unknown>;
67
+ } catch {
68
+ continue;
69
+ }
70
+ if (typeof hb.instance_id !== "string") continue;
71
+ if ((hb.kind as string | undefined) === "transient") continue;
72
+ const ts = Date.parse((hb.last_heartbeat as string | undefined) ?? "");
73
+ if (!Number.isFinite(ts) || ts < cutoffMs) continue;
74
+ agents.push({
75
+ instance_id: hb.instance_id,
76
+ name: strOr(hb.name),
77
+ kind: strOr(hb.kind),
78
+ session_id: strOr(hb.session_id),
79
+ platform: strOr(hb.platform),
80
+ task: strOr(hb.task),
81
+ turn_summary: clamp(strOr(hb.turn_summary), 160),
82
+ files_touched: Array.isArray(hb.files_touched)
83
+ ? (hb.files_touched as string[]).slice(0, MAX_FILES_PER_AGENT)
84
+ : undefined,
85
+ last_tool: strOr(hb.last_tool),
86
+ started_at: strOr(hb.started_at),
87
+ last_heartbeat: strOr(hb.last_heartbeat),
88
+ });
89
+ }
90
+ }
91
+
92
+ agents.sort((a, b) => a.instance_id.localeCompare(b.instance_id));
93
+ const capped = agents.slice(0, MAX_AGENTS);
94
+
95
+ const blob: PresenceBlob = {
96
+ v: 1,
97
+ machine,
98
+ published_at: now.toISOString().replace(/\.\d{3}Z$/, "Z"),
99
+ agents: capped,
100
+ };
101
+
102
+ // Basis: the fields whose change should trigger a re-publish. Timestamps
103
+ // and last_tool are excluded (pure churn); task/turn_summary/files are the
104
+ // signal peers actually read.
105
+ const basis = capped.map((a) => ({
106
+ i: a.instance_id,
107
+ n: a.name ?? null,
108
+ k: a.kind ?? null,
109
+ t: a.task ?? null,
110
+ s: a.turn_summary ?? null,
111
+ f: [...(a.files_touched ?? [])].sort(),
112
+ }));
113
+ const basisHash = createHash("sha256").update(JSON.stringify(basis)).digest("hex").slice(0, 16);
114
+
115
+ return { blob, basisHash, json: JSON.stringify(blob) };
116
+ }
117
+
118
+ function strOr(v: unknown): string | undefined {
119
+ return typeof v === "string" && v.length > 0 ? v : undefined;
120
+ }
121
+
122
+ function clamp(v: string | undefined, max: number): string | undefined {
123
+ if (!v) return undefined;
124
+ return v.length > max ? v.slice(0, max) : v;
125
+ }
@@ -0,0 +1,191 @@
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
+
16
+ import { spawn, spawnSync } from "node:child_process";
17
+
18
+ /** The ref namespace. Hardcoded — never interpolated from config — so the
19
+ * publisher is structurally unable to touch refs/heads/* (ADR 0016). */
20
+ export const PRESENCE_REF_PREFIX = "refs/harnery/presence/";
21
+
22
+ /** Well-known SHA of git's empty tree; `git mktree` below guarantees the
23
+ * object exists in the local odb before we build a commit on it. */
24
+ const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
25
+
26
+ const GIT_TIMEOUT_MS = 8000;
27
+
28
+ /** Non-interactive env for every presence git call: never prompt for creds
29
+ * (a hook must not hang on a password), and a fixed identity so commit-tree
30
+ * works on machines with no user.name/user.email configured. */
31
+ function gitEnv(): NodeJS.ProcessEnv {
32
+ return {
33
+ ...process.env,
34
+ GIT_TERMINAL_PROMPT: "0",
35
+ GIT_AUTHOR_NAME: "harnery-presence",
36
+ GIT_AUTHOR_EMAIL: "presence@harnery.invalid",
37
+ GIT_COMMITTER_NAME: "harnery-presence",
38
+ GIT_COMMITTER_EMAIL: "presence@harnery.invalid",
39
+ };
40
+ }
41
+
42
+ function runGit(
43
+ root: string,
44
+ args: string[],
45
+ input?: string,
46
+ ): { ok: boolean; stdout: string; stderr: string } {
47
+ try {
48
+ const r = spawnSync("git", ["-C", root, ...args], {
49
+ encoding: "utf8",
50
+ timeout: GIT_TIMEOUT_MS,
51
+ env: gitEnv(),
52
+ ...(input !== undefined ? { input } : {}),
53
+ });
54
+ return { ok: r.status === 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
55
+ } catch {
56
+ return { ok: false, stdout: "", stderr: "spawn failed" };
57
+ }
58
+ }
59
+
60
+ /** Does the repo have an `origin` remote to rendezvous on? */
61
+ export function hasOrigin(root: string): boolean {
62
+ return runGit(root, ["remote", "get-url", "origin"]).ok;
63
+ }
64
+
65
+ /** The origin remote URL, or null. Input to relay room derivation. */
66
+ export function originUrl(root: string): string | null {
67
+ const r = runGit(root, ["remote", "get-url", "origin"]);
68
+ const url = r.stdout.trim();
69
+ return r.ok && url ? url : null;
70
+ }
71
+
72
+ /**
73
+ * The repo's root-commit SHA — the stable repo-identity input to relay room
74
+ * derivation. Repos can have multiple roots (merged histories); the list is
75
+ * sorted so every clone picks the same one. Null when the repo has no commits.
76
+ */
77
+ export function rootCommitSha(root: string): string | null {
78
+ const r = runGit(root, ["rev-list", "--max-parents=0", "HEAD"]);
79
+ if (!r.ok) return null;
80
+ const shas = r.stdout
81
+ .split("\n")
82
+ .map((l) => l.trim())
83
+ .filter(Boolean)
84
+ .sort();
85
+ return shas[0] ?? null;
86
+ }
87
+
88
+ /**
89
+ * Sanitize a machine label into a valid single ref component: lowercase,
90
+ * [a-z0-9._-] only, no leading dot, no `.lock` suffix, never empty.
91
+ */
92
+ export function sanitizeRefComponent(label: string): string {
93
+ let s = label
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9._-]+/g, "-")
96
+ .replace(/\.{2,}/g, ".")
97
+ .replace(/^[.-]+/, "")
98
+ .replace(/\.lock$/, "");
99
+ if (!s) s = "unknown";
100
+ return s;
101
+ }
102
+
103
+ /** Build the parentless presence commit carrying `message` (the JSON blob).
104
+ * Returns the commit sha, or null on failure. Local-only, fast (~10ms). */
105
+ export function writePresenceCommit(root: string, message: string): string | null {
106
+ // Ensure the empty-tree object exists in this odb (mktree with empty stdin
107
+ // writes it idempotently).
108
+ const mk = runGit(root, ["mktree"], "");
109
+ const tree = mk.ok && mk.stdout.trim() ? mk.stdout.trim() : EMPTY_TREE_SHA;
110
+ const r = runGit(root, ["commit-tree", tree, "-m", message]);
111
+ return r.ok && r.stdout.trim() ? r.stdout.trim() : null;
112
+ }
113
+
114
+ /**
115
+ * Force-push a presence commit to `refs/harnery/presence/<machine>`.
116
+ * `sync: true` (CLI/tests) waits and reports; default is detached fire-and-
117
+ * forget (hook path — a slow or credential-less push must not block a turn).
118
+ */
119
+ export function pushPresenceRef(
120
+ root: string,
121
+ machine: string,
122
+ sha: string,
123
+ opts: { sync?: boolean } = {},
124
+ ): { ok: boolean; error?: string } {
125
+ const refspec = `+${sha}:${PRESENCE_REF_PREFIX}${sanitizeRefComponent(machine)}`;
126
+ const args = ["-C", root, "push", "--quiet", "--no-verify", "origin", refspec];
127
+ if (opts.sync) {
128
+ const r = runGit(root, args.slice(2));
129
+ return r.ok ? { ok: true } : { ok: false, error: r.stderr.trim().slice(0, 300) };
130
+ }
131
+ try {
132
+ const child = spawn("git", args, { detached: true, stdio: "ignore", env: gitEnv() });
133
+ child.unref();
134
+ return { ok: true };
135
+ } catch {
136
+ return { ok: false, error: "spawn failed" };
137
+ }
138
+ }
139
+
140
+ /** Fetch every peer presence ref from origin into the local ref namespace. */
141
+ export function fetchPresenceRefs(
142
+ root: string,
143
+ opts: { sync?: boolean } = {},
144
+ ): { ok: boolean; error?: string } {
145
+ const refspec = `+${PRESENCE_REF_PREFIX}*:${PRESENCE_REF_PREFIX}*`;
146
+ const args = ["-C", root, "fetch", "--quiet", "--no-tags", "origin", refspec];
147
+ if (opts.sync) {
148
+ const r = runGit(root, args.slice(2));
149
+ return r.ok ? { ok: true } : { ok: false, error: r.stderr.trim().slice(0, 300) };
150
+ }
151
+ try {
152
+ const child = spawn("git", args, { detached: true, stdio: "ignore", env: gitEnv() });
153
+ child.unref();
154
+ return { ok: true };
155
+ } catch {
156
+ return { ok: false, error: "spawn failed" };
157
+ }
158
+ }
159
+
160
+ /** Read every locally-known presence ref → [{ref, machine, message}]. */
161
+ export function readPresenceRefs(root: string): Array<{ machine: string; message: string }> {
162
+ const r = runGit(root, [
163
+ "for-each-ref",
164
+ "--format=%(refname)%00%(contents)%01",
165
+ `${PRESENCE_REF_PREFIX}*`,
166
+ ]);
167
+ if (!r.ok) return [];
168
+ return parseForEachRefOutput(r.stdout);
169
+ }
170
+
171
+ /**
172
+ * Parse `for-each-ref --format='%(refname)%00%(contents)%01'` output.
173
+ * Records are %01-terminated (contents may contain newlines); refname and
174
+ * contents are %00-separated. Exported for unit tests.
175
+ */
176
+ export function parseForEachRefOutput(out: string): Array<{ machine: string; message: string }> {
177
+ const rows: Array<{ machine: string; message: string }> = [];
178
+ for (const rec of out.split("\u0001")) {
179
+ const trimmed = rec.replace(/^\n+/, "");
180
+ if (!trimmed) continue;
181
+ const sep = trimmed.indexOf("\u0000");
182
+ if (sep === -1) continue;
183
+ const refname = trimmed.slice(0, sep).trim();
184
+ if (!refname.startsWith(PRESENCE_REF_PREFIX)) continue;
185
+ rows.push({
186
+ machine: refname.slice(PRESENCE_REF_PREFIX.length),
187
+ message: trimmed.slice(sep + 1).trim(),
188
+ });
189
+ }
190
+ return rows;
191
+ }
@@ -0,0 +1,274 @@
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
+
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readdirSync,
22
+ readFileSync,
23
+ renameSync,
24
+ statSync,
25
+ utimesSync,
26
+ writeFileSync,
27
+ } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { coordEnv } from "../../lib/env.ts";
30
+ import { resolveMachineLabel } from "../../lib/machine.ts";
31
+ import { presenceEnabled } from "../config.ts";
32
+ import { buildPresenceBlob, type PresenceAgent, type PresenceBlob } from "./blob.ts";
33
+ import {
34
+ fetchPresenceRefs,
35
+ hasOrigin,
36
+ pushPresenceRef,
37
+ readPresenceRefs,
38
+ sanitizeRefComponent,
39
+ writePresenceCommit,
40
+ } from "./git.ts";
41
+
42
+ export type { PresenceAgent, PresenceBlob } from "./blob.ts";
43
+ export { buildPresenceBlob } from "./blob.ts";
44
+ export { PRESENCE_REF_PREFIX, parseForEachRefOutput, sanitizeRefComponent } from "./git.ts";
45
+ export { ensureRelayDaemon, relayDaemonStatus, runRelayDaemon } from "./relay-client.ts";
46
+
47
+ /** Re-push at least this often while sessions are live, so peers can treat a
48
+ * blob older than ~3x this as "machine offline" rather than "no change". */
49
+ const KEEPALIVE_SECS = 300;
50
+ /** Never push more often than this, however chatty the triggers. */
51
+ const MIN_PUSH_INTERVAL_SECS = 15;
52
+ /** Default fetch throttle (hook cadence fires far more often than this). */
53
+ const FETCH_INTERVAL_SECS = 60;
54
+ /** A blob whose published_at is older than this is rendered as gone. */
55
+ const REMOTE_STALE_SECS = 900;
56
+
57
+ export type PublishResult =
58
+ | { status: "published"; machine: string; agents: number; sha: string }
59
+ | { status: "skipped"; reason: string }
60
+ | { status: "error"; error: string };
61
+
62
+ export type FetchResult =
63
+ | { status: "fetched" }
64
+ | { status: "skipped"; reason: string }
65
+ | { status: "error"; error: string };
66
+
67
+ interface PublishState {
68
+ basis_hash?: string;
69
+ pushed_at?: string;
70
+ agents?: number;
71
+ }
72
+
73
+ function presenceDir(coordRoot: string): string {
74
+ return join(coordRoot, ".harnery", "presence");
75
+ }
76
+
77
+ function statePath(coordRoot: string): string {
78
+ return join(presenceDir(coordRoot), "publish-state.json");
79
+ }
80
+
81
+ function fetchStampPath(coordRoot: string): string {
82
+ return join(presenceDir(coordRoot), "fetch-stamp");
83
+ }
84
+
85
+ function readState(coordRoot: string): PublishState {
86
+ try {
87
+ return JSON.parse(readFileSync(statePath(coordRoot), "utf8")) as PublishState;
88
+ } catch {
89
+ return {};
90
+ }
91
+ }
92
+
93
+ function writeState(coordRoot: string, state: PublishState): void {
94
+ try {
95
+ const p = statePath(coordRoot);
96
+ mkdirSync(presenceDir(coordRoot), { recursive: true });
97
+ const tmp = `${p}.tmp.${process.pid}`;
98
+ writeFileSync(tmp, JSON.stringify(state), "utf8");
99
+ renameSync(tmp, p);
100
+ } catch {
101
+ /* best-effort */
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Publish this machine's presence blob to origin if it changed (or the
107
+ * keepalive elapsed). `sync: true` pushes in-process and reports errors (CLI
108
+ * path); default spawns the push detached (hook path).
109
+ */
110
+ export function publishPresence(
111
+ coordRoot: string,
112
+ opts: { force?: boolean; sync?: boolean } = {},
113
+ ): PublishResult {
114
+ try {
115
+ if (!presenceEnabled(coordRoot)) return { status: "skipped", reason: "disabled" };
116
+ if (!hasOrigin(coordRoot)) return { status: "skipped", reason: "no origin remote" };
117
+
118
+ const { blob, basisHash, json } = buildPresenceBlob(coordRoot);
119
+ const state = readState(coordRoot);
120
+ const pushedAtMs = Date.parse(state.pushed_at ?? "");
121
+ const sincePush = Number.isFinite(pushedAtMs)
122
+ ? (Date.now() - pushedAtMs) / 1000
123
+ : Number.POSITIVE_INFINITY;
124
+
125
+ if (!opts.force) {
126
+ // Nothing live and the last publish already said so → stay quiet.
127
+ if (blob.agents.length === 0 && (state.agents ?? 0) === 0) {
128
+ return { status: "skipped", reason: "no live sessions" };
129
+ }
130
+ const unchanged = state.basis_hash === basisHash;
131
+ if (unchanged && sincePush < KEEPALIVE_SECS) {
132
+ return { status: "skipped", reason: "unchanged (within keepalive)" };
133
+ }
134
+ if (!unchanged && sincePush < MIN_PUSH_INTERVAL_SECS) {
135
+ return { status: "skipped", reason: "min push interval" };
136
+ }
137
+ }
138
+
139
+ const sha = writePresenceCommit(coordRoot, json);
140
+ if (!sha) return { status: "error", error: "commit-tree failed" };
141
+
142
+ const push = pushPresenceRef(coordRoot, blob.machine, sha, { sync: opts.sync });
143
+ if (!push.ok) return { status: "error", error: push.error ?? "push failed" };
144
+
145
+ // Optimistic on the detached path: a failed background push self-heals at
146
+ // the next keepalive re-push.
147
+ writeState(coordRoot, {
148
+ basis_hash: basisHash,
149
+ pushed_at: new Date().toISOString(),
150
+ agents: blob.agents.length,
151
+ });
152
+ return { status: "published", machine: blob.machine, agents: blob.agents.length, sha };
153
+ } catch (e) {
154
+ return { status: "error", error: e instanceof Error ? e.message : String(e) };
155
+ }
156
+ }
157
+
158
+ /** Throttled fetch of peer presence refs from origin. */
159
+ export function fetchPresence(
160
+ coordRoot: string,
161
+ opts: { force?: boolean; sync?: boolean } = {},
162
+ ): FetchResult {
163
+ try {
164
+ if (!presenceEnabled(coordRoot)) return { status: "skipped", reason: "disabled" };
165
+ if (!hasOrigin(coordRoot)) return { status: "skipped", reason: "no origin remote" };
166
+
167
+ const stamp = fetchStampPath(coordRoot);
168
+ if (!opts.force) {
169
+ const interval = Number.parseInt(
170
+ coordEnv("PRESENCE_FETCH_INTERVAL") ?? String(FETCH_INTERVAL_SECS),
171
+ 10,
172
+ );
173
+ try {
174
+ const age = (Date.now() - statSync(stamp).mtimeMs) / 1000;
175
+ if (age < interval) return { status: "skipped", reason: "within fetch interval" };
176
+ } catch {
177
+ /* no stamp yet → fetch */
178
+ }
179
+ }
180
+
181
+ // Touch the stamp BEFORE spawning so concurrent hooks don't stampede.
182
+ try {
183
+ mkdirSync(presenceDir(coordRoot), { recursive: true });
184
+ if (existsSync(stamp)) {
185
+ const now = new Date();
186
+ utimesSync(stamp, now, now);
187
+ } else {
188
+ writeFileSync(stamp, "", "utf8");
189
+ }
190
+ } catch {
191
+ /* best-effort */
192
+ }
193
+
194
+ const r = fetchPresenceRefs(coordRoot, { sync: opts.sync });
195
+ if (!r.ok) return { status: "error", error: r.error ?? "fetch failed" };
196
+ return { status: "fetched" };
197
+ } catch (e) {
198
+ return { status: "error", error: e instanceof Error ? e.message : String(e) };
199
+ }
200
+ }
201
+
202
+ export interface RemoteMachine {
203
+ machine: string;
204
+ published_at: string;
205
+ age_secs: number;
206
+ agents: PresenceAgent[];
207
+ }
208
+
209
+ /**
210
+ * Render-ready view of every OTHER machine's presence, merged from BOTH
211
+ * transports: the locally-fetched git refs (the floor) and the relay
212
+ * daemon's cache files at `.harnery/presence/remote/` (the live upgrade).
213
+ * Freshest source per machine wins. Self machine excluded; blobs older than
214
+ * the stale window or with zero agents dropped.
215
+ */
216
+ export function readRemoteMachines(
217
+ coordRoot: string,
218
+ opts: { includeSelf?: boolean; includeStale?: boolean } = {},
219
+ ): RemoteMachine[] {
220
+ try {
221
+ if (!presenceEnabled(coordRoot)) return [];
222
+ const selfMachine = sanitizeRefComponent(resolveMachineLabel());
223
+
224
+ // Collect candidate blobs from both transports, freshest per machine.
225
+ const byMachine = new Map<string, PresenceBlob>();
226
+ const consider = (machine: string, blob: PresenceBlob | null): void => {
227
+ if (blob?.v !== 1 || !Array.isArray(blob.agents)) return;
228
+ const publishedMs = Date.parse(blob.published_at ?? "");
229
+ if (!Number.isFinite(publishedMs)) return;
230
+ const prev = byMachine.get(machine);
231
+ if (prev && Date.parse(prev.published_at) >= publishedMs) return;
232
+ byMachine.set(machine, blob);
233
+ };
234
+ for (const { machine, message } of readPresenceRefs(coordRoot)) {
235
+ try {
236
+ consider(machine, JSON.parse(message) as PresenceBlob);
237
+ } catch {
238
+ /* skip malformed */
239
+ }
240
+ }
241
+ const relayDir = join(coordRoot, ".harnery", "presence", "remote");
242
+ if (existsSync(relayDir)) {
243
+ for (const f of readdirSync(relayDir)) {
244
+ if (!f.endsWith(".json")) continue;
245
+ try {
246
+ consider(
247
+ f.replace(/\.json$/, ""),
248
+ JSON.parse(readFileSync(join(relayDir, f), "utf8")) as PresenceBlob,
249
+ );
250
+ } catch {
251
+ /* skip malformed */
252
+ }
253
+ }
254
+ }
255
+
256
+ const out: RemoteMachine[] = [];
257
+ for (const [machine, blob] of byMachine) {
258
+ if (!opts.includeSelf && machine === selfMachine) continue;
259
+ const ageSecs = Math.max(0, Math.floor((Date.now() - Date.parse(blob.published_at)) / 1000));
260
+ if (!opts.includeStale && ageSecs > REMOTE_STALE_SECS) continue;
261
+ if (blob.agents.length === 0) continue;
262
+ out.push({
263
+ machine,
264
+ published_at: blob.published_at,
265
+ age_secs: ageSecs,
266
+ agents: blob.agents,
267
+ });
268
+ }
269
+ out.sort((a, b) => a.machine.localeCompare(b.machine));
270
+ return out;
271
+ } catch {
272
+ return [];
273
+ }
274
+ }