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
@@ -1,11 +1,19 @@
1
1
  import type { Command } from "commander";
2
2
  import type { EmitContext } from "../commander.ts";
3
+ import { monorepoRoot } from "../core/agents/coord-client.ts";
3
4
  import {
4
5
  emitCanonical,
5
6
  normalizeHarness,
6
7
  readHeartbeat,
7
8
  resolveOwner,
8
9
  } from "../core/agents/index.ts";
10
+ import {
11
+ fetchPresence,
12
+ publishPresence,
13
+ readRemoteMachines,
14
+ relayDaemonStatus,
15
+ runRelayDaemon,
16
+ } from "../core/presence/index.ts";
9
17
  import {
10
18
  ageSeconds,
11
19
  applyDetection,
@@ -18,17 +26,24 @@ import {
18
26
  } from "../lib/presence.ts";
19
27
 
20
28
  /**
21
- * `presence`: mobile/office state for agents (~/.claude/presence).
29
+ * `presence`: two related surfaces under one name.
30
+ *
31
+ * 1. Mobile/office state for agents (~/.claude/presence): get / set /
32
+ * clear / detect. The state file path is Claude-Code-specific but the
33
+ * logic is generic.
34
+ * 2. Cross-machine session presence (ADR 0016): publish / fetch / peers —
35
+ * the git-refs transport that lets sessions on other machines show up
36
+ * in the peer table via `refs/harnery/presence/<machine>` on origin.
22
37
  *
23
38
  * Uses the injected EmitContext so composed and standalone consumers share
24
- * one code path. The state file path is Claude-Code-specific (lives under
25
- * ~/.claude/) but the detect/set/clear logic is generic: any agent harness
26
- * can adopt the same convention.
39
+ * one code path.
27
40
  */
28
41
  export function registerPresenceCommand(program: Command, emit: EmitContext): void {
29
42
  const cmd = program
30
43
  .command("presence")
31
- .description("Mobile/office state for agents (~/.claude/presence)")
44
+ .description(
45
+ "Presence: mobile/office state (get/set/clear/detect) + cross-machine session presence (publish/fetch/peers)",
46
+ )
32
47
  .action(() => {
33
48
  printStatus(emit);
34
49
  });
@@ -83,6 +98,88 @@ export function registerPresenceCommand(program: Command, emit: EmitContext): vo
83
98
  });
84
99
  });
85
100
 
101
+ // ---- cross-machine session presence (ADR 0016) ----
102
+
103
+ cmd
104
+ .command("publish")
105
+ .description(
106
+ "Publish this machine's live sessions to refs/harnery/presence/<machine> on origin. " +
107
+ "Change-batched with a keepalive; --force pushes regardless. Synchronous (errors report).",
108
+ )
109
+ .option("--force", "Push even if unchanged / within throttle windows")
110
+ .action((opts: { force?: boolean }) => {
111
+ const root = requireRoot(emit);
112
+ const r = publishPresence(root, { force: opts.force, sync: true });
113
+ if (r.status === "error") {
114
+ emit.error({ code: "publish_failed", message: r.error });
115
+ process.exit(1);
116
+ }
117
+ emit.data(r);
118
+ });
119
+
120
+ cmd
121
+ .command("fetch")
122
+ .description(
123
+ "Fetch peer machines' presence refs from origin (throttled; --force bypasses). Synchronous.",
124
+ )
125
+ .option("--force", "Fetch even within the throttle interval")
126
+ .action((opts: { force?: boolean }) => {
127
+ const root = requireRoot(emit);
128
+ const r = fetchPresence(root, { force: opts.force, sync: true });
129
+ if (r.status === "error") {
130
+ emit.error({ code: "fetch_failed", message: r.error });
131
+ process.exit(1);
132
+ }
133
+ emit.data(r);
134
+ });
135
+
136
+ cmd
137
+ .command("relay-daemon")
138
+ .description(
139
+ "Run the relay client daemon in the foreground (normally spawned detached by the hooks " +
140
+ "when presence.relay is configured; holds the WebSocket, publishes on heartbeat change, " +
141
+ "caches peers' blobs for the renderers, exits when the machine goes idle).",
142
+ )
143
+ .action(async () => {
144
+ const root = requireRoot(emit);
145
+ process.exit(await runRelayDaemon(root));
146
+ });
147
+
148
+ cmd
149
+ .command("peers")
150
+ .description(
151
+ "Show sessions on other machines (merged from presence refs + the relay daemon's cache)",
152
+ )
153
+ .option("--json", "JSON output")
154
+ .option("--stale", "Include machines past the stale window")
155
+ .action((opts: { json?: boolean; stale?: boolean }) => {
156
+ const root = requireRoot(emit);
157
+ const machines = readRemoteMachines(root, { includeStale: opts.stale });
158
+ const daemon = relayDaemonStatus(root);
159
+ if (opts.json) emit.config({ format: "json" });
160
+ if (opts.json || !process.stdout.isTTY) {
161
+ emit.data({ machines, relay_daemon: daemon });
162
+ return;
163
+ }
164
+ emit.text(
165
+ daemon.running
166
+ ? `relay daemon: running (pid ${daemon.pid}, ${daemon.url})\n`
167
+ : "relay daemon: not running (git-refs transport only)\n",
168
+ );
169
+ if (machines.length === 0) {
170
+ emit.text("no remote sessions known (try `presence fetch --force`)\n");
171
+ return;
172
+ }
173
+ for (const m of machines) {
174
+ emit.text(`${m.machine} (published ${formatAge(m.age_secs)} ago)\n`);
175
+ for (const a of m.agents) {
176
+ const task = a.task ? ` "${a.task.slice(0, 70)}"` : "";
177
+ const files = a.files_touched?.length ? ` [${a.files_touched.length} files]` : "";
178
+ emit.text(` agent-${a.name ?? a.instance_id.slice(0, 8)}${task}${files}\n`);
179
+ }
180
+ }
181
+ });
182
+
86
183
  cmd
87
184
  .command("detect")
88
185
  .description(
@@ -111,6 +208,15 @@ export function registerPresenceCommand(program: Command, emit: EmitContext): vo
111
208
  });
112
209
  }
113
210
 
211
+ function requireRoot(emit: EmitContext): string {
212
+ const root = monorepoRoot();
213
+ if (!root) {
214
+ emit.error({ code: "not_in_repo", message: "coord root not found (no .harnery/ upward)" });
215
+ process.exit(1);
216
+ }
217
+ return root;
218
+ }
219
+
114
220
  function printStatus(emit: EmitContext): void {
115
221
  const r = readPresence();
116
222
  emit.data({
@@ -0,0 +1,160 @@
1
+ import type { Command } from "commander";
2
+ import type { EmitContext } from "../commander.ts";
3
+ import { MAX_FRAME_BYTES } from "../core/presence/relay-protocol.ts";
4
+
5
+ /**
6
+ * `relay serve`: the Bun self-host of the presence relay (ADR 0016 phase 2).
7
+ * Same wire protocol as the Cloudflare Durable Objects host in relay/worker/ —
8
+ * one in-memory room per opaque room id, warm-join cache, fan-out, rate
9
+ * limits. For teams who'd rather run their own box than use the public
10
+ * relay.harnery.com; point clients at it via `.harnery/config.jsonc` →
11
+ * `{ "presence": { "relay": "ws://host:port" } }`.
12
+ *
13
+ * Bun-only (Bun.serve WebSockets), like `harn tunnel` — fails fast with a
14
+ * clear message on a Bun-free host.
15
+ */
16
+
17
+ const MAX_SOCKETS_PER_ROOM = 64;
18
+ const RATE_BURST = 10;
19
+ const RATE_REFILL_MS = 2000;
20
+ const CACHE_TTL_MS = 15 * 60 * 1000;
21
+ const ROOM_ID_RE = /^[0-9a-f]{32}$/;
22
+
23
+ interface RoomState {
24
+ sockets: Set<unknown>;
25
+ cache: Map<string, { raw: string; at: number }>;
26
+ }
27
+
28
+ interface SocketData {
29
+ roomId: string;
30
+ bucketTokens: number;
31
+ bucketAt: number;
32
+ }
33
+
34
+ /** Start the relay server. Exported for in-process integration tests. */
35
+ export function startRelayServer(port: number): { port: number; stop: () => void } {
36
+ if (typeof Bun === "undefined" || typeof Bun.serve !== "function") {
37
+ throw new Error("relay serve requires Bun (Bun.serve WebSockets)");
38
+ }
39
+ const rooms = new Map<string, RoomState>();
40
+
41
+ const roomOf = (roomId: string): RoomState => {
42
+ let r = rooms.get(roomId);
43
+ if (!r) {
44
+ r = { sockets: new Set(), cache: new Map() };
45
+ rooms.set(roomId, r);
46
+ }
47
+ return r;
48
+ };
49
+
50
+ const server = Bun.serve<SocketData>({
51
+ port,
52
+ fetch(req, srv) {
53
+ const url = new URL(req.url);
54
+ if (url.pathname === "/" || url.pathname === "/healthz") {
55
+ return Response.json({ ok: true, service: "harnery-presence-relay", v: 1 });
56
+ }
57
+ const m = /^\/v1\/room\/([0-9a-f]{32})$/.exec(url.pathname);
58
+ if (!m || !ROOM_ID_RE.test(m[1]!)) return new Response("not found", { status: 404 });
59
+ const room = roomOf(m[1]!);
60
+ if (room.sockets.size >= MAX_SOCKETS_PER_ROOM) {
61
+ return new Response("room full", { status: 429 });
62
+ }
63
+ const ok = srv.upgrade(req, {
64
+ data: { roomId: m[1]!, bucketTokens: RATE_BURST, bucketAt: Date.now() },
65
+ });
66
+ return ok ? undefined : new Response("expected websocket", { status: 426 });
67
+ },
68
+ websocket: {
69
+ open(ws) {
70
+ const room = roomOf(ws.data.roomId);
71
+ room.sockets.add(ws);
72
+ const now = Date.now();
73
+ for (const [sender, entry] of room.cache) {
74
+ if (now - entry.at > CACHE_TTL_MS) {
75
+ room.cache.delete(sender);
76
+ continue;
77
+ }
78
+ ws.send(entry.raw);
79
+ }
80
+ ws.send(JSON.stringify({ t: "hello", peers: room.sockets.size }));
81
+ },
82
+ message(ws, message) {
83
+ if (typeof message !== "string" || message.length > MAX_FRAME_BYTES) return;
84
+ // Rate limit (token bucket per socket).
85
+ const d = ws.data;
86
+ const now = Date.now();
87
+ const refill = Math.floor((now - d.bucketAt) / RATE_REFILL_MS);
88
+ if (refill > 0) {
89
+ d.bucketTokens = Math.min(RATE_BURST, d.bucketTokens + refill);
90
+ d.bucketAt = now;
91
+ }
92
+ if (d.bucketTokens <= 0) return;
93
+ d.bucketTokens -= 1;
94
+
95
+ let frame: { t?: unknown; sender?: unknown };
96
+ try {
97
+ frame = JSON.parse(message) as typeof frame;
98
+ } catch {
99
+ return;
100
+ }
101
+ if (
102
+ frame.t !== "pub" ||
103
+ typeof frame.sender !== "string" ||
104
+ !/^[0-9a-f]+$/.test(frame.sender) ||
105
+ frame.sender.length > 64
106
+ ) {
107
+ return;
108
+ }
109
+ const room = roomOf(d.roomId);
110
+ room.cache.set(frame.sender, { raw: message, at: now });
111
+ for (const peer of room.sockets) {
112
+ if (peer === ws) continue;
113
+ try {
114
+ (peer as { send(m: string): void }).send(message);
115
+ } catch {
116
+ /* dead socket */
117
+ }
118
+ }
119
+ },
120
+ close(ws) {
121
+ const room = rooms.get(ws.data.roomId);
122
+ if (!room) return;
123
+ room.sockets.delete(ws);
124
+ if (room.sockets.size === 0 && room.cache.size === 0) rooms.delete(ws.data.roomId);
125
+ },
126
+ },
127
+ });
128
+
129
+ return {
130
+ port: server.port ?? port,
131
+ stop: () => server.stop(true),
132
+ };
133
+ }
134
+
135
+ export function registerRelayCommand(program: Command, emit: EmitContext): void {
136
+ const cmd = program
137
+ .command("relay")
138
+ .description("Self-host the presence relay (see also relay/worker/ for the Cloudflare host)");
139
+
140
+ cmd
141
+ .command("serve")
142
+ .description(
143
+ "Run the presence relay on this machine (Bun-only; same protocol as relay/worker/)",
144
+ )
145
+ .option("--port <n>", "Port to listen on", "8787")
146
+ .action((opts: { port: string }) => {
147
+ let server: { port: number; stop: () => void };
148
+ try {
149
+ server = startRelayServer(Number.parseInt(opts.port, 10));
150
+ } catch (e) {
151
+ emit.error({
152
+ code: "relay_serve_failed",
153
+ message: e instanceof Error ? e.message : String(e),
154
+ });
155
+ process.exit(1);
156
+ }
157
+ emit.log(`presence relay listening on :${server.port} (ws path: /v1/room/<room-id>)`, "info");
158
+ // Foreground service: run until interrupted.
159
+ });
160
+ }
@@ -25,6 +25,7 @@ import os from "node:os";
25
25
  import path from "node:path";
26
26
  import type { Command } from "commander";
27
27
  import type { EmitContext } from "../commander.ts";
28
+ import { syncJsoncConfig } from "../core/config.ts";
28
29
 
29
30
  // What we sync. Anything matching one of these (relative to .harnery/) is
30
31
  // considered part of the sync set. Everything else stays local-only.
@@ -40,9 +41,12 @@ function configPath(): string {
40
41
  }
41
42
 
42
43
  function loadConfig(): SyncConfig | null {
44
+ // Precedence: env → .harnery/config.jsonc `sync` → ~/.config/harnery/sync.json.
43
45
  const envRemote = process.env.HARNERY_SYNC_REMOTE;
44
46
  const envPrefix = process.env.HARNERY_SYNC_PREFIX ?? "harnery";
45
47
  if (envRemote) return { remote: envRemote, prefix: envPrefix };
48
+ const jsonc = syncJsoncConfig();
49
+ if (jsonc) return jsonc;
46
50
  const p = configPath();
47
51
  if (!existsSync(p)) return null;
48
52
  try {
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import type { EmitContext } from "../commander.ts";
3
3
  import { workflowSubscriptionOnly } from "../core/config.ts";
4
+ import { createBuiltinHarnessRegistry } from "../core/harnesses/index.ts";
4
5
  import { findCoordRoot } from "../core/hooks/resolve/coord-root.ts";
5
6
 
6
7
  /**
@@ -24,9 +25,9 @@ interface WorkflowRunOpts {
24
25
  json?: boolean;
25
26
  }
26
27
 
27
- const HARNESSES = ["claude-code", "codex", "cursor"] as const;
28
-
29
28
  export function registerWorkflowCommand(program: Command, emit: EmitContext): void {
29
+ const registry = createBuiltinHarnessRegistry();
30
+ const harnesses = registry.ids();
30
31
  const workflow = program
31
32
  .command("workflow")
32
33
  .description("Run bounded, schema-gated multi-subagent workflow scripts.");
@@ -42,7 +43,7 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
42
43
  .option("--cwd <dir>", "Working directory children spawn in (default: coord root)")
43
44
  .option(
44
45
  "--harness <name>",
45
- `Default harness for agent() calls: ${HARNESSES.join(" | ")} (default claude-code); scripts can override per agent via opts.harness`,
46
+ `Default harness for agent() calls: ${harnesses.join(" | ")} (default claude-code); scripts can override per agent via opts.harness`,
46
47
  )
47
48
  .option(
48
49
  "--resume-from <run-id>",
@@ -68,10 +69,10 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
68
69
  });
69
70
  process.exit(1);
70
71
  }
71
- if (opts.harness && !HARNESSES.includes(opts.harness as (typeof HARNESSES)[number])) {
72
+ if (opts.harness && !registry.get(opts.harness)) {
72
73
  emit.error({
73
74
  code: "bad_harness",
74
- message: `unknown harness "${opts.harness}" (expected: ${HARNESSES.join(" | ")})`,
75
+ message: `unknown harness "${opts.harness}" (expected: ${harnesses.join(" | ")})`,
75
76
  });
76
77
  process.exit(1);
77
78
  }
@@ -87,21 +88,11 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
87
88
  process.exit(1);
88
89
  }
89
90
  try {
90
- const [{ runWorkflow }, { claudeCodeSpawner }, { codexSpawner }, { cursorSpawner }] =
91
- await Promise.all([
92
- import("../core/workflow/engine.ts"),
93
- import("../core/workflow/spawn-claude.ts"),
94
- import("../core/workflow/spawn-codex.ts"),
95
- import("../core/workflow/spawn-cursor.ts"),
96
- ]);
91
+ const { runWorkflow } = await import("../core/workflow/engine.ts");
97
92
  const report = await runWorkflow(script, {
98
93
  coordRoot,
99
- spawners: {
100
- "claude-code": claudeCodeSpawner,
101
- codex: codexSpawner,
102
- cursor: cursorSpawner,
103
- },
104
- defaultHarness: opts.harness as "claude-code" | "codex" | "cursor" | undefined,
94
+ spawners: registry.spawners(),
95
+ defaultHarness: opts.harness,
105
96
  resumeFrom: opts.resumeFrom,
106
97
  subscriptionOnly,
107
98
  allowApiBilling: opts.allowApiBilling,
@@ -12,6 +12,7 @@
12
12
  import { spawnSync } from "node:child_process";
13
13
  import { existsSync } from "node:fs";
14
14
  import { dirname, join, resolve } from "node:path";
15
+ import { monorepoRoot } from "./coord-client.ts";
15
16
 
16
17
  export interface CanonicalEmitInput {
17
18
  type: string;
@@ -24,8 +25,37 @@ export interface CanonicalEmitInput {
24
25
  parentTurnId?: string;
25
26
  }
26
27
 
28
+ /**
29
+ * Resolve the coord root for a canonical emit.
30
+ *
31
+ * Order: explicit `HARNERY_COORD_ROOT_OVERRIDE` → git-superproject-aware
32
+ * `monorepoRoot()` (must actually carry `.harnery/`) → cwd walk (non-git
33
+ * hosts). The git-aware step is load-bearing: a shell cd'd into a nested
34
+ * directory that carries its own `.harnery/` (e.g. an embedded harnery
35
+ * checkout) made the old cwd-walk resolve the NESTED root, from which
36
+ * `<root>/harnery/bin/agent-coord` doesn't exist — so `agents status` /
37
+ * `set-task` emits silently vanished and the Stop-hook's rule 1/3
38
+ * (`state.status_checked` in-turn) blocked turns that had done the ritual.
39
+ * Same bug class as the coordHelperOpts root-pin fix; this closes the
40
+ * emitCanonical instance of it.
41
+ */
42
+ export function resolveEmitRoot(start: string = process.cwd()): string | null {
43
+ const override = process.env.HARNERY_COORD_ROOT_OVERRIDE;
44
+ if (override) return override;
45
+ // Memoized per start-dir: the session-tee middleware resolves once per
46
+ // emitted event, and monorepoRoot() spawns git — cache so a streaming
47
+ // command doesn't pay 1-3 subprocess spawns per output line.
48
+ if (cachedRoot && cachedRoot.start === start) return cachedRoot.root;
49
+ const gitRoot = monorepoRoot();
50
+ const root = gitRoot && existsSync(join(gitRoot, ".harnery")) ? gitRoot : findRepoRoot(start);
51
+ cachedRoot = { start, root };
52
+ return root;
53
+ }
54
+
55
+ let cachedRoot: { start: string; root: string | null } | undefined;
56
+
27
57
  export function emitCanonical(input: CanonicalEmitInput): void {
28
- const root = findRepoRoot();
58
+ const root = resolveEmitRoot();
29
59
  if (!root) return;
30
60
  const binary = resolve(root, "harnery", "bin", "agent-coord");
31
61
  if (!existsSync(binary)) return;
@@ -46,11 +76,19 @@ export function emitCanonical(input: CanonicalEmitInput): void {
46
76
  if (input.turnId) args.push("--turn-id", input.turnId);
47
77
  if (input.parentSessionId) args.push("--parent-session-id", input.parentSessionId);
48
78
  if (input.parentTurnId) args.push("--parent-turn-id", input.parentTurnId);
49
- spawnSync(binary, args, {
79
+ // Pin cwd + HARNERY_COORD_ROOT_OVERRIDE so the child writes to the SAME
80
+ // stream we resolved, regardless of where the caller's shell is cd'd.
81
+ const result = spawnSync(binary, args, {
50
82
  encoding: "utf8",
51
83
  stdio: "ignore",
52
84
  timeout: 3000,
85
+ cwd: root,
86
+ env: { ...process.env, HARNERY_COORD_ROOT_OVERRIDE: root },
53
87
  });
88
+ if (result.error || result.status !== 0) {
89
+ const why = result.error ? result.error.message : `exit ${result.status}`;
90
+ process.stderr.write(`emitCanonical: ${input.type} emit failed (${why})\n`); // lint-ok-emission: soft-fail diagnostic promised by the module doc; silent drops cost blocked turns
91
+ }
54
92
  } catch {
55
93
  /* never break the caller */
56
94
  }
@@ -66,7 +104,7 @@ export function normalizeHarness(platform: string | undefined): "claude-code" |
66
104
  return "claude-code";
67
105
  }
68
106
 
69
- function findRepoRoot(start: string = process.cwd()): string | null {
107
+ function findRepoRoot(start: string): string | null {
70
108
  let dir = resolve(start);
71
109
  while (true) {
72
110
  if (existsSync(join(dir, ".harnery"))) return dir;
@@ -210,8 +210,10 @@ async function handleStateAction(root: string, action: string, rest: string[]):
210
210
  const task = args.join(" ");
211
211
  const hb = writer.setTask(root, owner, task);
212
212
  if (!hb) {
213
+ // Name the RESOLVED root: when a nested .harnery/ shadows the real
214
+ // coordination home, the full path is what makes that diagnosable.
213
215
  process.stderr.write(
214
- `agent-coord set-task: no heartbeat at .harnery/active/${owner}.json\n`,
216
+ `agent-coord set-task: no heartbeat at ${root}/.harnery/active/${owner}.json\n`,
215
217
  );
216
218
  return 1;
217
219
  }
@@ -716,7 +718,7 @@ async function handleResolveName(root: string, rest: string[]): Promise<number>
716
718
  return 0;
717
719
  }
718
720
  process.stdout.write(
719
- `${JSON.stringify({ instance_id: owner, name: resolved.name, kind: resolved.kind })}\n`,
721
+ `${JSON.stringify({ instance_id: owner, name: resolved.name, kind: resolved.kind, agent_id: resolved.agent_id ?? null })}\n`,
720
722
  );
721
723
  return 0;
722
724
  }
@@ -36,6 +36,7 @@ import {
36
36
  import { join } from "node:path";
37
37
 
38
38
  import { coordEnv } from "../../../lib/env.ts";
39
+ import { type RemoteMachine, readRemoteMachines } from "../../presence/index.ts";
39
40
  import { formatPendingCouncils } from "./session-context.ts";
40
41
 
41
42
  interface HeartbeatRow {
@@ -170,7 +171,10 @@ function computePeerTableIfChanged(
170
171
  if (!hb?.instance_id || hb.instance_id === selfInstanceId) continue;
171
172
  peers.push(hb);
172
173
  }
173
- if (peers.length === 0) return "";
174
+ // Cross-machine presence (ADR 0016): sessions on other machines, from the
175
+ // locally-fetched presence refs. Advisory (no claim blocking in v1).
176
+ const remote = readRemoteMachinesSafe(coordRoot);
177
+ if (peers.length === 0 && remote.length === 0) return "";
174
178
 
175
179
  // Build hash basis: sorted-by-instance_id projection of semantically-relevant fields.
176
180
  const basis = peers
@@ -184,14 +188,27 @@ function computePeerTableIfChanged(
184
188
  platform: p.platform ?? null,
185
189
  }))
186
190
  .sort((a, b) => (a.instance_id ?? "").localeCompare(b.instance_id ?? ""));
187
- const newHash = sha256Hex16(JSON.stringify(basis));
191
+ // Remote basis: machine + per-agent identity/task/files (published_at is
192
+ // excluded — a keepalive re-publish alone shouldn't re-emit the table).
193
+ const remoteBasis = remote.map((m) => ({
194
+ machine: m.machine,
195
+ agents: m.agents.map((a) => ({
196
+ instance_id: a.instance_id,
197
+ name: a.name ?? null,
198
+ task: a.task ?? null,
199
+ files_touched: Array.from(a.files_touched ?? []).sort(),
200
+ })),
201
+ }));
202
+ const newHash = sha256Hex16(JSON.stringify({ local: basis, remote: remoteBasis }));
188
203
 
189
204
  const hashFile = join(coordRoot, ".harnery", `.last-peer-hash.${selfInstanceId}`);
190
205
  const oldHash = safeRead(hashFile);
191
206
  if (oldHash && oldHash === newHash) return "";
192
207
 
193
208
  // Render peer table via the same formatter used at SessionStart.
194
- const table = formatPeerTable(peers, mySessionId);
209
+ const local = formatPeerTable(peers, mySessionId);
210
+ const remoteTable = formatRemoteTable(remote);
211
+ const table = [local, remoteTable].filter(Boolean).join("\n\n");
195
212
  if (!table) return "";
196
213
 
197
214
  // Persist hash atomically (temp + rename, same convention as other coord writes).
@@ -199,6 +216,34 @@ function computePeerTableIfChanged(
199
216
  return table;
200
217
  }
201
218
 
219
+ /** readRemoteMachines with a local failure guard (rendering must never throw). */
220
+ function readRemoteMachinesSafe(coordRoot: string): RemoteMachine[] {
221
+ try {
222
+ return readRemoteMachines(coordRoot);
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ /** Render sessions on other machines (presence transport) as a subtable. */
229
+ function formatRemoteTable(remote: RemoteMachine[]): string {
230
+ if (remote.length === 0) return "";
231
+ const nowSec = Math.floor(Date.now() / 1000);
232
+ const lines: string[] = [];
233
+ for (const m of remote) {
234
+ for (const a of m.agents.slice(0, 10)) {
235
+ lines.push(
236
+ formatRow(
237
+ { ...a, display_files: Array.from(a.files_touched ?? []).sort() },
238
+ nowSec,
239
+ ).replace(/^ {2}- (agent-[^ ]+)/, ` - $1 @${m.machine}`),
240
+ );
241
+ }
242
+ }
243
+ if (lines.length === 0) return "";
244
+ return `Sessions on other machines (advisory, via presence refs):\n${lines.join("\n")}`;
245
+ }
246
+
202
247
  function computeCouncilPendingIfChanged(
203
248
  coordRoot: string,
204
249
  selfInstanceId: string,
@@ -12,6 +12,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { resolveBinName, resolveHooksSetupHint } from "../../config.ts";
14
14
  import { harneryVersion, loadHarnessWiring } from "../../hooks/harness/wiring.ts";
15
+ import { readRemoteMachines } from "../../presence/index.ts";
15
16
 
16
17
  interface HeartbeatRow {
17
18
  instance_id?: string;
@@ -48,7 +49,9 @@ export function renderSessionContext(opts: RenderOpts): string {
48
49
 
49
50
  // 1. Self-name line + peer table (folded if peers present)
50
51
  const peers = readActivePeers(coordRoot, instanceId);
51
- const peerTable = formatPeerTable(peers, sessionId);
52
+ const localTable = formatPeerTable(peers, sessionId);
53
+ // Cross-machine presence (ADR 0016): sessions on other machines (advisory).
54
+ const peerTable = [localTable, formatRemoteMachines(coordRoot)].filter(Boolean).join("\n\n");
52
55
  if (agentName) {
53
56
  const suffix = platformLabel ? ` (${platformLabel})` : "";
54
57
  const selfLine = `You are agent-${agentName}${suffix}.`;
@@ -107,6 +110,29 @@ export function renderSessionContext(opts: RenderOpts): string {
107
110
  return messages.join("\n\n");
108
111
  }
109
112
 
113
+ /** One-line-per-agent view of sessions on other machines (presence refs). */
114
+ function formatRemoteMachines(coordRoot: string): string {
115
+ try {
116
+ const remote = readRemoteMachines(coordRoot);
117
+ if (remote.length === 0) return "";
118
+ const lines: string[] = [];
119
+ for (const m of remote) {
120
+ for (const a of m.agents.slice(0, 10)) {
121
+ const task = a.task ? ` "${a.task.slice(0, 60)}"` : "";
122
+ const files = a.files_touched?.length
123
+ ? `holds: ${a.files_touched.slice(0, 3).join(", ")}${a.files_touched.length > 3 ? `, +${a.files_touched.length - 3} more` : ""}`
124
+ : "nothing held";
125
+ lines.push(
126
+ ` - agent-${a.name ?? a.instance_id.slice(0, 8)} @${m.machine}${task} (${files})`,
127
+ );
128
+ }
129
+ }
130
+ return `Sessions on other machines (advisory, via presence refs):\n${lines.join("\n")}`;
131
+ } catch {
132
+ return "";
133
+ }
134
+ }
135
+
110
136
  /** Read all peer heartbeats from .harnery/active/, excluding self. */
111
137
  function readActivePeers(coordRoot: string, selfInstanceId: string): HeartbeatRow[] {
112
138
  const out: HeartbeatRow[] = [];