harnery 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +8 -0
  4. package/dist/commands/agents.d.ts +24 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +244 -13
  7. package/dist/commands/backup.d.ts +5 -4
  8. package/dist/commands/backup.d.ts.map +1 -1
  9. package/dist/commands/backup.js +15 -14
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +27 -1
  12. package/dist/commands/claude-desktop.d.ts +19 -0
  13. package/dist/commands/claude-desktop.d.ts.map +1 -0
  14. package/dist/commands/claude-desktop.js +168 -0
  15. package/dist/commands/context.d.ts.map +1 -1
  16. package/dist/commands/context.js +149 -1
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +37 -0
  19. package/dist/commands/eml.d.ts +32 -0
  20. package/dist/commands/eml.d.ts.map +1 -1
  21. package/dist/commands/eml.js +16 -3
  22. package/dist/commands/grep.d.ts +35 -2
  23. package/dist/commands/grep.d.ts.map +1 -1
  24. package/dist/commands/grep.js +427 -139
  25. package/dist/commands/harness.d.ts +11 -0
  26. package/dist/commands/harness.d.ts.map +1 -0
  27. package/dist/commands/harness.js +118 -0
  28. package/dist/commands/init.d.ts +15 -5
  29. package/dist/commands/init.d.ts.map +1 -1
  30. package/dist/commands/init.js +125 -14
  31. package/dist/commands/presence.d.ts +9 -4
  32. package/dist/commands/presence.d.ts.map +1 -1
  33. package/dist/commands/presence.js +88 -5
  34. package/dist/commands/relay.d.ts +9 -0
  35. package/dist/commands/relay.d.ts.map +1 -0
  36. package/dist/commands/relay.js +143 -0
  37. package/dist/commands/sync.d.ts.map +1 -1
  38. package/dist/commands/sync.js +5 -0
  39. package/dist/commands/workflow.d.ts +4 -0
  40. package/dist/commands/workflow.d.ts.map +1 -0
  41. package/dist/commands/workflow.js +82 -0
  42. package/dist/core/agents/canonical-emit.d.ts +15 -0
  43. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  44. package/dist/core/agents/canonical-emit.js +41 -3
  45. package/dist/core/agents/cli.js +14 -5
  46. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  47. package/dist/core/agents/render/prompt-context.js +44 -3
  48. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/session-context.js +26 -1
  50. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  51. package/dist/core/agents/rules/claim-conflict.js +26 -1
  52. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  53. package/dist/core/agents/rules/commit-conflict.js +4 -37
  54. package/dist/core/agents/rules/stop-hook.d.ts +8 -0
  55. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  56. package/dist/core/agents/rules/stop-hook.js +8 -0
  57. package/dist/core/agents/session-events.d.ts.map +1 -1
  58. package/dist/core/agents/session-events.js +16 -43
  59. package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
  60. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  61. package/dist/core/agents/state/heartbeat-projector.js +24 -4
  62. package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
  63. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  64. package/dist/core/agents/state/heartbeat-writer.js +24 -7
  65. package/dist/core/agents/state/names.d.ts +38 -3
  66. package/dist/core/agents/state/names.d.ts.map +1 -1
  67. package/dist/core/agents/state/names.js +46 -7
  68. package/dist/core/agents/state/pidmap.d.ts +5 -1
  69. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  70. package/dist/core/agents/state/pidmap.js +58 -2
  71. package/dist/core/agents/state/stale-sweep.d.ts +3 -2
  72. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  73. package/dist/core/agents/state/stale-sweep.js +6 -6
  74. package/dist/core/config.d.ts +77 -11
  75. package/dist/core/config.d.ts.map +1 -1
  76. package/dist/core/config.js +210 -25
  77. package/dist/core/context/index.d.ts +144 -0
  78. package/dist/core/context/index.d.ts.map +1 -0
  79. package/dist/core/context/index.js +380 -0
  80. package/dist/core/harnesses/bench.d.ts +29 -0
  81. package/dist/core/harnesses/bench.d.ts.map +1 -0
  82. package/dist/core/harnesses/bench.js +151 -0
  83. package/dist/core/harnesses/index.d.ts +9 -0
  84. package/dist/core/harnesses/index.d.ts.map +1 -0
  85. package/dist/core/harnesses/index.js +4 -0
  86. package/dist/core/harnesses/profiles.d.ts +62 -0
  87. package/dist/core/harnesses/profiles.d.ts.map +1 -0
  88. package/dist/core/harnesses/profiles.js +115 -0
  89. package/dist/core/harnesses/registry.d.ts +15 -0
  90. package/dist/core/harnesses/registry.d.ts.map +1 -0
  91. package/dist/core/harnesses/registry.js +113 -0
  92. package/dist/core/harnesses/types.d.ts +64 -0
  93. package/dist/core/harnesses/types.d.ts.map +1 -0
  94. package/dist/core/harnesses/types.js +20 -0
  95. package/dist/core/hooks/cli.js +309 -66
  96. package/dist/core/hooks/events/schema.d.ts +42 -1
  97. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  98. package/dist/core/hooks/harness/events.d.ts +7 -0
  99. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  100. package/dist/core/hooks/harness/events.js +4 -0
  101. package/dist/core/hooks/harness/parse.d.ts +1 -1
  102. package/dist/core/hooks/harness/parse.d.ts.map +1 -1
  103. package/dist/core/hooks/harness/parse.js +4 -0
  104. package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
  105. package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
  106. package/dist/core/hooks/resolve/coord-root.js +28 -6
  107. package/dist/core/presence/blob.d.ts +40 -0
  108. package/dist/core/presence/blob.d.ts.map +1 -0
  109. package/dist/core/presence/blob.js +91 -0
  110. package/dist/core/presence/git.d.ts +68 -0
  111. package/dist/core/presence/git.d.ts.map +1 -0
  112. package/dist/core/presence/git.js +173 -0
  113. package/dist/core/presence/index.d.ts +74 -0
  114. package/dist/core/presence/index.d.ts.map +1 -0
  115. package/dist/core/presence/index.js +224 -0
  116. package/dist/core/presence/relay-client.d.ts +39 -0
  117. package/dist/core/presence/relay-client.d.ts.map +1 -0
  118. package/dist/core/presence/relay-client.js +295 -0
  119. package/dist/core/presence/relay-protocol.d.ts +94 -0
  120. package/dist/core/presence/relay-protocol.d.ts.map +1 -0
  121. package/dist/core/presence/relay-protocol.js +169 -0
  122. package/dist/core/workflow/billing.d.ts +48 -0
  123. package/dist/core/workflow/billing.d.ts.map +1 -0
  124. package/dist/core/workflow/billing.js +108 -0
  125. package/dist/core/workflow/child-env.d.ts +30 -0
  126. package/dist/core/workflow/child-env.d.ts.map +1 -0
  127. package/dist/core/workflow/child-env.js +43 -0
  128. package/dist/core/workflow/engine.d.ts +21 -0
  129. package/dist/core/workflow/engine.d.ts.map +1 -0
  130. package/dist/core/workflow/engine.js +349 -0
  131. package/dist/core/workflow/harnesses.d.ts +17 -0
  132. package/dist/core/workflow/harnesses.d.ts.map +1 -0
  133. package/dist/core/workflow/harnesses.js +21 -0
  134. package/dist/core/workflow/spawn-claude.d.ts +25 -0
  135. package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
  136. package/dist/core/workflow/spawn-claude.js +103 -0
  137. package/dist/core/workflow/spawn-codex.d.ts +22 -0
  138. package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
  139. package/dist/core/workflow/spawn-codex.js +88 -0
  140. package/dist/core/workflow/spawn-cursor.d.ts +29 -0
  141. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
  142. package/dist/core/workflow/spawn-cursor.js +91 -0
  143. package/dist/core/workflow/types.d.ts +152 -0
  144. package/dist/core/workflow/types.d.ts.map +1 -0
  145. package/dist/core/workflow/types.js +9 -0
  146. package/dist/core/workflow/validate.d.ts +15 -0
  147. package/dist/core/workflow/validate.d.ts.map +1 -0
  148. package/dist/core/workflow/validate.js +70 -0
  149. package/dist/lib/browser/client.d.ts +16 -0
  150. package/dist/lib/browser/client.d.ts.map +1 -1
  151. package/dist/lib/browser/client.js +22 -0
  152. package/dist/lib/browser/index.d.ts +1 -0
  153. package/dist/lib/browser/index.d.ts.map +1 -1
  154. package/dist/lib/browser/index.js +1 -0
  155. package/dist/lib/browser/launch-args.d.ts +21 -0
  156. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  157. package/dist/lib/browser/launch-args.js +32 -0
  158. package/dist/lib/claude-desktop.d.ts +105 -0
  159. package/dist/lib/claude-desktop.d.ts.map +1 -0
  160. package/dist/lib/claude-desktop.js +217 -0
  161. package/dist/lib/docs-lint.d.ts.map +1 -1
  162. package/dist/lib/docs-lint.js +5 -2
  163. package/dist/lib/identities/assume.d.ts +51 -0
  164. package/dist/lib/identities/assume.d.ts.map +1 -0
  165. package/dist/lib/identities/assume.js +274 -0
  166. package/dist/lib/identities/index.d.ts +7 -7
  167. package/dist/lib/identities/index.d.ts.map +1 -1
  168. package/dist/lib/identities/index.js +24 -24
  169. package/dist/lib/instructions/templates.d.ts.map +1 -1
  170. package/dist/lib/instructions/templates.js +15 -2
  171. package/package.json +12 -1
  172. package/schemas/config.schema.json +88 -19
  173. package/src/commander.ts +8 -0
  174. package/src/commands/agents.ts +280 -14
  175. package/src/commands/backup.ts +17 -16
  176. package/src/commands/browse.ts +32 -0
  177. package/src/commands/claude-desktop.ts +215 -0
  178. package/src/commands/context.ts +174 -1
  179. package/src/commands/doctor.ts +44 -0
  180. package/src/commands/eml.ts +22 -4
  181. package/src/commands/grep.ts +535 -142
  182. package/src/commands/harness.ts +147 -0
  183. package/src/commands/init.ts +138 -17
  184. package/src/commands/presence.ts +111 -5
  185. package/src/commands/relay.ts +160 -0
  186. package/src/commands/sync.ts +4 -0
  187. package/src/commands/workflow.ts +123 -0
  188. package/src/core/agents/canonical-emit.ts +41 -3
  189. package/src/core/agents/cli.ts +15 -6
  190. package/src/core/agents/render/prompt-context.ts +48 -3
  191. package/src/core/agents/render/session-context.ts +27 -1
  192. package/src/core/agents/rules/claim-conflict.ts +26 -1
  193. package/src/core/agents/rules/commit-conflict.ts +4 -33
  194. package/src/core/agents/rules/stop-hook.ts +17 -0
  195. package/src/core/agents/session-events.ts +15 -39
  196. package/src/core/agents/state/heartbeat-projector.ts +23 -2
  197. package/src/core/agents/state/heartbeat-writer.ts +33 -7
  198. package/src/core/agents/state/names.ts +68 -9
  199. package/src/core/agents/state/pidmap.ts +59 -2
  200. package/src/core/agents/state/stale-sweep.ts +6 -11
  201. package/src/core/config.ts +271 -24
  202. package/src/core/context/index.ts +575 -0
  203. package/src/core/harnesses/bench.ts +214 -0
  204. package/src/core/harnesses/index.ts +31 -0
  205. package/src/core/harnesses/profiles.ts +141 -0
  206. package/src/core/harnesses/registry.ts +140 -0
  207. package/src/core/harnesses/types.ts +90 -0
  208. package/src/core/hooks/cli.ts +361 -65
  209. package/src/core/hooks/events/schema.ts +72 -0
  210. package/src/core/hooks/harness/events.ts +11 -0
  211. package/src/core/hooks/harness/parse.ts +7 -1
  212. package/src/core/hooks/resolve/coord-root.ts +28 -6
  213. package/src/core/presence/blob.ts +125 -0
  214. package/src/core/presence/git.ts +191 -0
  215. package/src/core/presence/index.ts +274 -0
  216. package/src/core/presence/relay-client.ts +322 -0
  217. package/src/core/presence/relay-protocol.ts +246 -0
  218. package/src/core/workflow/billing.ts +152 -0
  219. package/src/core/workflow/child-env.ts +47 -0
  220. package/src/core/workflow/engine.ts +403 -0
  221. package/src/core/workflow/harnesses.ts +33 -0
  222. package/src/core/workflow/spawn-claude.ts +118 -0
  223. package/src/core/workflow/spawn-codex.ts +93 -0
  224. package/src/core/workflow/spawn-cursor.ts +108 -0
  225. package/src/core/workflow/types.ts +160 -0
  226. package/src/core/workflow/validate.ts +75 -0
  227. package/src/lib/browser/client.ts +37 -0
  228. package/src/lib/browser/index.ts +1 -0
  229. package/src/lib/browser/launch-args.ts +33 -0
  230. package/src/lib/claude-desktop.ts +293 -0
  231. package/src/lib/docs-lint.ts +5 -2
  232. package/src/lib/identities/assume.ts +363 -0
  233. package/src/lib/identities/index.ts +28 -24
  234. package/src/lib/instructions/templates.ts +16 -2
@@ -0,0 +1,143 @@
1
+ import { MAX_FRAME_BYTES } from "../core/presence/relay-protocol.js";
2
+ /**
3
+ * `relay serve`: the Bun self-host of the presence relay (ADR 0016 phase 2).
4
+ * Same wire protocol as the Cloudflare Durable Objects host in relay/worker/ —
5
+ * one in-memory room per opaque room id, warm-join cache, fan-out, rate
6
+ * limits. For teams who'd rather run their own box than use the public
7
+ * relay.harnery.com; point clients at it via `.harnery/config.jsonc` →
8
+ * `{ "presence": { "relay": "ws://host:port" } }`.
9
+ *
10
+ * Bun-only (Bun.serve WebSockets), like `harn tunnel` — fails fast with a
11
+ * clear message on a Bun-free host.
12
+ */
13
+ const MAX_SOCKETS_PER_ROOM = 64;
14
+ const RATE_BURST = 10;
15
+ const RATE_REFILL_MS = 2000;
16
+ const CACHE_TTL_MS = 15 * 60 * 1000;
17
+ const ROOM_ID_RE = /^[0-9a-f]{32}$/;
18
+ /** Start the relay server. Exported for in-process integration tests. */
19
+ export function startRelayServer(port) {
20
+ if (typeof Bun === "undefined" || typeof Bun.serve !== "function") {
21
+ throw new Error("relay serve requires Bun (Bun.serve WebSockets)");
22
+ }
23
+ const rooms = new Map();
24
+ const roomOf = (roomId) => {
25
+ let r = rooms.get(roomId);
26
+ if (!r) {
27
+ r = { sockets: new Set(), cache: new Map() };
28
+ rooms.set(roomId, r);
29
+ }
30
+ return r;
31
+ };
32
+ const server = Bun.serve({
33
+ port,
34
+ fetch(req, srv) {
35
+ const url = new URL(req.url);
36
+ if (url.pathname === "/" || url.pathname === "/healthz") {
37
+ return Response.json({ ok: true, service: "harnery-presence-relay", v: 1 });
38
+ }
39
+ const m = /^\/v1\/room\/([0-9a-f]{32})$/.exec(url.pathname);
40
+ if (!m || !ROOM_ID_RE.test(m[1]))
41
+ return new Response("not found", { status: 404 });
42
+ const room = roomOf(m[1]);
43
+ if (room.sockets.size >= MAX_SOCKETS_PER_ROOM) {
44
+ return new Response("room full", { status: 429 });
45
+ }
46
+ const ok = srv.upgrade(req, {
47
+ data: { roomId: m[1], bucketTokens: RATE_BURST, bucketAt: Date.now() },
48
+ });
49
+ return ok ? undefined : new Response("expected websocket", { status: 426 });
50
+ },
51
+ websocket: {
52
+ open(ws) {
53
+ const room = roomOf(ws.data.roomId);
54
+ room.sockets.add(ws);
55
+ const now = Date.now();
56
+ for (const [sender, entry] of room.cache) {
57
+ if (now - entry.at > CACHE_TTL_MS) {
58
+ room.cache.delete(sender);
59
+ continue;
60
+ }
61
+ ws.send(entry.raw);
62
+ }
63
+ ws.send(JSON.stringify({ t: "hello", peers: room.sockets.size }));
64
+ },
65
+ message(ws, message) {
66
+ if (typeof message !== "string" || message.length > MAX_FRAME_BYTES)
67
+ return;
68
+ // Rate limit (token bucket per socket).
69
+ const d = ws.data;
70
+ const now = Date.now();
71
+ const refill = Math.floor((now - d.bucketAt) / RATE_REFILL_MS);
72
+ if (refill > 0) {
73
+ d.bucketTokens = Math.min(RATE_BURST, d.bucketTokens + refill);
74
+ d.bucketAt = now;
75
+ }
76
+ if (d.bucketTokens <= 0)
77
+ return;
78
+ d.bucketTokens -= 1;
79
+ let frame;
80
+ try {
81
+ frame = JSON.parse(message);
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ if (frame.t !== "pub" ||
87
+ typeof frame.sender !== "string" ||
88
+ !/^[0-9a-f]+$/.test(frame.sender) ||
89
+ frame.sender.length > 64) {
90
+ return;
91
+ }
92
+ const room = roomOf(d.roomId);
93
+ room.cache.set(frame.sender, { raw: message, at: now });
94
+ for (const peer of room.sockets) {
95
+ if (peer === ws)
96
+ continue;
97
+ try {
98
+ peer.send(message);
99
+ }
100
+ catch {
101
+ /* dead socket */
102
+ }
103
+ }
104
+ },
105
+ close(ws) {
106
+ const room = rooms.get(ws.data.roomId);
107
+ if (!room)
108
+ return;
109
+ room.sockets.delete(ws);
110
+ if (room.sockets.size === 0 && room.cache.size === 0)
111
+ rooms.delete(ws.data.roomId);
112
+ },
113
+ },
114
+ });
115
+ return {
116
+ port: server.port ?? port,
117
+ stop: () => server.stop(true),
118
+ };
119
+ }
120
+ export function registerRelayCommand(program, emit) {
121
+ const cmd = program
122
+ .command("relay")
123
+ .description("Self-host the presence relay (see also relay/worker/ for the Cloudflare host)");
124
+ cmd
125
+ .command("serve")
126
+ .description("Run the presence relay on this machine (Bun-only; same protocol as relay/worker/)")
127
+ .option("--port <n>", "Port to listen on", "8787")
128
+ .action((opts) => {
129
+ let server;
130
+ try {
131
+ server = startRelayServer(Number.parseInt(opts.port, 10));
132
+ }
133
+ catch (e) {
134
+ emit.error({
135
+ code: "relay_serve_failed",
136
+ message: e instanceof Error ? e.message : String(e),
137
+ });
138
+ process.exit(1);
139
+ }
140
+ emit.log(`presence relay listening on :${server.port} (ws path: /v1/room/<room-id>)`, "info");
141
+ // Foreground service: run until interrupted.
142
+ });
143
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/commands/sync.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAgHnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAuK7E"}
1
+ {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/commands/sync.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAMH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAoHnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAuK7E"}
@@ -22,6 +22,7 @@ import { spawnSync } from "node:child_process";
22
22
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
23
  import os from "node:os";
24
24
  import path from "node:path";
25
+ import { syncJsoncConfig } from "../core/config.js";
25
26
  // What we sync. Anything matching one of these (relative to .harnery/) is
26
27
  // considered part of the sync set. Everything else stays local-only.
27
28
  const SYNC_PATHS = ["identities/", "scratch/archived/", "councils/"];
@@ -29,10 +30,14 @@ function configPath() {
29
30
  return path.join(os.homedir(), ".config", "harnery", "sync.json");
30
31
  }
31
32
  function loadConfig() {
33
+ // Precedence: env → .harnery/config.jsonc `sync` → ~/.config/harnery/sync.json.
32
34
  const envRemote = process.env.HARNERY_SYNC_REMOTE;
33
35
  const envPrefix = process.env.HARNERY_SYNC_PREFIX ?? "harnery";
34
36
  if (envRemote)
35
37
  return { remote: envRemote, prefix: envPrefix };
38
+ const jsonc = syncJsoncConfig();
39
+ if (jsonc)
40
+ return jsonc;
36
41
  const p = configPath();
37
42
  if (!existsSync(p))
38
43
  return null;
@@ -0,0 +1,4 @@
1
+ import type { Command } from "commander";
2
+ import type { EmitContext } from "../commander.js";
3
+ export declare function registerWorkflowCommand(program: Command, emit: EmitContext): void;
4
+ //# sourceMappingURL=workflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workflow.d.ts","sourceRoot":"","sources":["../../src/commands/workflow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AA0BnD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CA+FjF"}
@@ -0,0 +1,82 @@
1
+ import { workflowSubscriptionOnly } from "../core/config.js";
2
+ import { createBuiltinHarnessRegistry } from "../core/harnesses/index.js";
3
+ import { findCoordRoot } from "../core/hooks/resolve/coord-root.js";
4
+ export function registerWorkflowCommand(program, emit) {
5
+ const registry = createBuiltinHarnessRegistry();
6
+ const harnesses = registry.ids();
7
+ const workflow = program
8
+ .command("workflow")
9
+ .description("Run bounded, schema-gated multi-subagent workflow scripts.");
10
+ workflow
11
+ .command("run <script>")
12
+ .description("Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log}) => …`). " +
13
+ "Subagents spawn as headless harness-CLI subprocesses, coordination-registered.")
14
+ .option("--max-agents <n>", "Total-agent ceiling for the run (default 50)")
15
+ .option("--concurrency <n>", "Concurrent-subagent cap (default 4)")
16
+ .option("--cwd <dir>", "Working directory children spawn in (default: coord root)")
17
+ .option("--harness <name>", `Default harness for agent() calls: ${harnesses.join(" | ")} (default claude-code); scripts can override per agent via opts.harness`)
18
+ .option("--resume-from <run-id>", "Reuse completed agent results from a prior run's journal; only changed/failed calls re-run")
19
+ .option("--subscription-only", "Guarantee subscription billing: scrub API-key vars from child envs; fail loud when a " +
20
+ "harness has no stored login (repo default via config.jsonc workflow.subscriptionOnly)")
21
+ .option("--allow-api-billing", "Permit an exported API key to override a stored subscription login (per-token billing); " +
22
+ "without this the engine refuses that silent-override state")
23
+ .option("--json", "Emit the full RunReport as JSON")
24
+ .action(async (script, opts) => {
25
+ const coordRoot = findCoordRoot();
26
+ if (!coordRoot) {
27
+ emit.error({
28
+ code: "no_coord_root",
29
+ message: "no .harnery/ coordination root found; run `init` first",
30
+ });
31
+ process.exit(1);
32
+ }
33
+ if (opts.harness && !registry.get(opts.harness)) {
34
+ emit.error({
35
+ code: "bad_harness",
36
+ message: `unknown harness "${opts.harness}" (expected: ${harnesses.join(" | ")})`,
37
+ });
38
+ process.exit(1);
39
+ }
40
+ // Flag beats config; the two flags contradict each other by design.
41
+ const subscriptionOnly = opts.subscriptionOnly || workflowSubscriptionOnly(coordRoot);
42
+ if (subscriptionOnly && opts.allowApiBilling) {
43
+ emit.error({
44
+ code: "billing_flags_conflict",
45
+ message: "--allow-api-billing contradicts subscription-only mode " +
46
+ "(flag or config.jsonc workflow.subscriptionOnly); pick one",
47
+ });
48
+ process.exit(1);
49
+ }
50
+ try {
51
+ const { runWorkflow } = await import("../core/workflow/engine.js");
52
+ const report = await runWorkflow(script, {
53
+ coordRoot,
54
+ spawners: registry.spawners(),
55
+ defaultHarness: opts.harness,
56
+ resumeFrom: opts.resumeFrom,
57
+ subscriptionOnly,
58
+ allowApiBilling: opts.allowApiBilling,
59
+ maxAgents: opts.maxAgents ? Number.parseInt(opts.maxAgents, 10) : undefined,
60
+ concurrency: opts.concurrency ? Number.parseInt(opts.concurrency, 10) : undefined,
61
+ cwd: opts.cwd,
62
+ });
63
+ if (opts.json) {
64
+ emit.config({ format: "json" });
65
+ emit.data(report);
66
+ return;
67
+ }
68
+ const cachedPart = report.agentsCached > 0 ? ` (+${report.agentsCached} cached)` : "";
69
+ const billingPart = report.billing.length
70
+ ? `billing: ${report.billing.map((b) => `${b.harness}=${b.mode}`).join(", ")}\n`
71
+ : "";
72
+ emit.text(`run ${report.runId} (${report.name}) finished: ${report.agentsSpawned} agent(s)${cachedPart}, ` +
73
+ `$${report.costUsd.toFixed(4)}, ${Math.round(report.durationMs / 1000)}s\n${billingPart}` +
74
+ `journal: ${report.journalPath}\n` +
75
+ `result: ${typeof report.result === "string" ? report.result : JSON.stringify(report.result, null, 2)}\n`);
76
+ }
77
+ catch (err) {
78
+ emit.error({ code: "workflow_failed", message: err.message });
79
+ process.exit(1);
80
+ }
81
+ });
82
+ }
@@ -18,6 +18,21 @@ export interface CanonicalEmitInput {
18
18
  parentSessionId?: string;
19
19
  parentTurnId?: string;
20
20
  }
21
+ /**
22
+ * Resolve the coord root for a canonical emit.
23
+ *
24
+ * Order: explicit `HARNERY_COORD_ROOT_OVERRIDE` → git-superproject-aware
25
+ * `monorepoRoot()` (must actually carry `.harnery/`) → cwd walk (non-git
26
+ * hosts). The git-aware step is load-bearing: a shell cd'd into a nested
27
+ * directory that carries its own `.harnery/` (e.g. an embedded harnery
28
+ * checkout) made the old cwd-walk resolve the NESTED root, from which
29
+ * `<root>/harnery/bin/agent-coord` doesn't exist — so `agents status` /
30
+ * `set-task` emits silently vanished and the Stop-hook's rule 1/3
31
+ * (`state.status_checked` in-turn) blocked turns that had done the ritual.
32
+ * Same bug class as the coordHelperOpts root-pin fix; this closes the
33
+ * emitCanonical instance of it.
34
+ */
35
+ export declare function resolveEmitRoot(start?: string): string | null;
21
36
  export declare function emitCanonical(input: CanonicalEmitInput): void;
22
37
  /**
23
38
  * Normalize the heartbeat's `platform` field (which uses snake_case
@@ -1 +1 @@
1
- {"version":3,"file":"canonical-emit.d.ts","sourceRoot":"","sources":["../../../src/core/agents/canonical-emit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,aAAa,GAAG,QAAQ,GAAG,OAAO,CAAC;IAC5C,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI,CA8B7D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,aAAa,GAAG,QAAQ,GAAG,OAAO,CAIjG"}
1
+ {"version":3,"file":"canonical-emit.d.ts","sourceRoot":"","sources":["../../../src/core/agents/canonical-emit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAOH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,aAAa,GAAG,QAAQ,GAAG,OAAO,CAAC;IAC5C,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAW5E;AAID,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI,CAsC7D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,aAAa,GAAG,QAAQ,GAAG,OAAO,CAIjG"}
@@ -11,8 +11,38 @@
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { existsSync } from "node:fs";
13
13
  import { dirname, join, resolve } from "node:path";
14
+ import { monorepoRoot } from "./coord-client.js";
15
+ /**
16
+ * Resolve the coord root for a canonical emit.
17
+ *
18
+ * Order: explicit `HARNERY_COORD_ROOT_OVERRIDE` → git-superproject-aware
19
+ * `monorepoRoot()` (must actually carry `.harnery/`) → cwd walk (non-git
20
+ * hosts). The git-aware step is load-bearing: a shell cd'd into a nested
21
+ * directory that carries its own `.harnery/` (e.g. an embedded harnery
22
+ * checkout) made the old cwd-walk resolve the NESTED root, from which
23
+ * `<root>/harnery/bin/agent-coord` doesn't exist — so `agents status` /
24
+ * `set-task` emits silently vanished and the Stop-hook's rule 1/3
25
+ * (`state.status_checked` in-turn) blocked turns that had done the ritual.
26
+ * Same bug class as the coordHelperOpts root-pin fix; this closes the
27
+ * emitCanonical instance of it.
28
+ */
29
+ export function resolveEmitRoot(start = process.cwd()) {
30
+ const override = process.env.HARNERY_COORD_ROOT_OVERRIDE;
31
+ if (override)
32
+ return override;
33
+ // Memoized per start-dir: the session-tee middleware resolves once per
34
+ // emitted event, and monorepoRoot() spawns git — cache so a streaming
35
+ // command doesn't pay 1-3 subprocess spawns per output line.
36
+ if (cachedRoot && cachedRoot.start === start)
37
+ return cachedRoot.root;
38
+ const gitRoot = monorepoRoot();
39
+ const root = gitRoot && existsSync(join(gitRoot, ".harnery")) ? gitRoot : findRepoRoot(start);
40
+ cachedRoot = { start, root };
41
+ return root;
42
+ }
43
+ let cachedRoot;
14
44
  export function emitCanonical(input) {
15
- const root = findRepoRoot();
45
+ const root = resolveEmitRoot();
16
46
  if (!root)
17
47
  return;
18
48
  const binary = resolve(root, "harnery", "bin", "agent-coord");
@@ -38,11 +68,19 @@ export function emitCanonical(input) {
38
68
  args.push("--parent-session-id", input.parentSessionId);
39
69
  if (input.parentTurnId)
40
70
  args.push("--parent-turn-id", input.parentTurnId);
41
- spawnSync(binary, args, {
71
+ // Pin cwd + HARNERY_COORD_ROOT_OVERRIDE so the child writes to the SAME
72
+ // stream we resolved, regardless of where the caller's shell is cd'd.
73
+ const result = spawnSync(binary, args, {
42
74
  encoding: "utf8",
43
75
  stdio: "ignore",
44
76
  timeout: 3000,
77
+ cwd: root,
78
+ env: { ...process.env, HARNERY_COORD_ROOT_OVERRIDE: root },
45
79
  });
80
+ if (result.error || result.status !== 0) {
81
+ const why = result.error ? result.error.message : `exit ${result.status}`;
82
+ 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
83
+ }
46
84
  }
47
85
  catch {
48
86
  /* never break the caller */
@@ -59,7 +97,7 @@ export function normalizeHarness(platform) {
59
97
  return "codex";
60
98
  return "claude-code";
61
99
  }
62
- function findRepoRoot(start = process.cwd()) {
100
+ function findRepoRoot(start) {
63
101
  let dir = resolve(start);
64
102
  while (true) {
65
103
  if (existsSync(join(dir, ".harnery")))
@@ -188,7 +188,9 @@ async function handleStateAction(root, action, rest) {
188
188
  const task = args.join(" ");
189
189
  const hb = writer.setTask(root, owner, task);
190
190
  if (!hb) {
191
- process.stderr.write(`agent-coord set-task: no heartbeat at .harnery/active/${owner}.json\n`);
191
+ // Name the RESOLVED root: when a nested .harnery/ shadows the real
192
+ // coordination home, the full path is what makes that diagnosable.
193
+ process.stderr.write(`agent-coord set-task: no heartbeat at ${root}/.harnery/active/${owner}.json\n`);
192
194
  return 1;
193
195
  }
194
196
  process.stdout.write(`${JSON.stringify({ instance_id: owner, task: hb.task ?? null, cleared: !task })}\n`);
@@ -427,11 +429,16 @@ async function handlePostCommit(root) {
427
429
  }
428
430
  const { groupUnclaim } = await import("./state/heartbeat-writer.js");
429
431
  // Session-group-wide unclaim. `owner` is the parent's session_id which is
430
- // also the group key (parent + subagents share session_id).
432
+ // also the group key (parent + subagents share session_id). Each actual
433
+ // removal also emits a durable claim.release event — the projector rebuilds
434
+ // files_touched from the permanent Edit/Write events, so a file-only prune
435
+ // would resurrect on the next replay.
431
436
  if (req.owner && Array.isArray(req.prune)) {
432
437
  for (const path of req.prune) {
433
438
  try {
434
- groupUnclaim(root, req.owner, path);
439
+ for (const hit of groupUnclaim(root, req.owner, path)) {
440
+ await emitClaimRelease(root, hit.instance_id, hit, path, "commit");
441
+ }
435
442
  }
436
443
  catch {
437
444
  /* best-effort */
@@ -453,7 +460,9 @@ async function handlePostCheckout(root, _rest) {
453
460
  if (req.owner && Array.isArray(req.removed)) {
454
461
  for (const path of req.removed) {
455
462
  try {
456
- groupUnclaim(root, req.owner, path);
463
+ for (const hit of groupUnclaim(root, req.owner, path)) {
464
+ await emitClaimRelease(root, hit.instance_id, hit, path, "checkout");
465
+ }
457
466
  }
458
467
  catch {
459
468
  /* best-effort */
@@ -657,7 +666,7 @@ async function handleResolveName(root, rest) {
657
666
  process.stdout.write(`${JSON.stringify({ instance_id: owner, name: null, kind: null })}\n`);
658
667
  return 0;
659
668
  }
660
- process.stdout.write(`${JSON.stringify({ instance_id: owner, name: resolved.name, kind: resolved.kind })}\n`);
669
+ process.stdout.write(`${JSON.stringify({ instance_id: owner, name: resolved.name, kind: resolved.kind, agent_id: resolved.agent_id ?? null })}\n`);
661
670
  return 0;
662
671
  }
663
672
  async function handleEmitEvent(root, rest) {
@@ -1 +1 @@
1
- {"version":3,"file":"prompt-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/prompt-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAgCH,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;iDAC6C;IAC7C,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAqBnE"}
1
+ {"version":3,"file":"prompt-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/prompt-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAiCH,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;iDAC6C;IAC7C,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAqBnE"}
@@ -26,6 +26,7 @@ import { createHash } from "node:crypto";
26
26
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
27
27
  import { join } from "node:path";
28
28
  import { coordEnv } from "../../../lib/env.js";
29
+ import { readRemoteMachines } from "../../presence/index.js";
29
30
  import { formatPendingCouncils } from "./session-context.js";
30
31
  /**
31
32
  * Build the combined UserPromptSubmit additionalContext string. Returns "" when
@@ -132,7 +133,10 @@ function computePeerTableIfChanged(coordRoot, selfInstanceId, selfSessionIdFallb
132
133
  continue;
133
134
  peers.push(hb);
134
135
  }
135
- if (peers.length === 0)
136
+ // Cross-machine presence (ADR 0016): sessions on other machines, from the
137
+ // locally-fetched presence refs. Advisory (no claim blocking in v1).
138
+ const remote = readRemoteMachinesSafe(coordRoot);
139
+ if (peers.length === 0 && remote.length === 0)
136
140
  return "";
137
141
  // Build hash basis: sorted-by-instance_id projection of semantically-relevant fields.
138
142
  const basis = peers
@@ -146,19 +150,56 @@ function computePeerTableIfChanged(coordRoot, selfInstanceId, selfSessionIdFallb
146
150
  platform: p.platform ?? null,
147
151
  }))
148
152
  .sort((a, b) => (a.instance_id ?? "").localeCompare(b.instance_id ?? ""));
149
- const newHash = sha256Hex16(JSON.stringify(basis));
153
+ // Remote basis: machine + per-agent identity/task/files (published_at is
154
+ // excluded — a keepalive re-publish alone shouldn't re-emit the table).
155
+ const remoteBasis = remote.map((m) => ({
156
+ machine: m.machine,
157
+ agents: m.agents.map((a) => ({
158
+ instance_id: a.instance_id,
159
+ name: a.name ?? null,
160
+ task: a.task ?? null,
161
+ files_touched: Array.from(a.files_touched ?? []).sort(),
162
+ })),
163
+ }));
164
+ const newHash = sha256Hex16(JSON.stringify({ local: basis, remote: remoteBasis }));
150
165
  const hashFile = join(coordRoot, ".harnery", `.last-peer-hash.${selfInstanceId}`);
151
166
  const oldHash = safeRead(hashFile);
152
167
  if (oldHash && oldHash === newHash)
153
168
  return "";
154
169
  // Render peer table via the same formatter used at SessionStart.
155
- const table = formatPeerTable(peers, mySessionId);
170
+ const local = formatPeerTable(peers, mySessionId);
171
+ const remoteTable = formatRemoteTable(remote);
172
+ const table = [local, remoteTable].filter(Boolean).join("\n\n");
156
173
  if (!table)
157
174
  return "";
158
175
  // Persist hash atomically (temp + rename, same convention as other coord writes).
159
176
  writeHashFile(hashFile, newHash);
160
177
  return table;
161
178
  }
179
+ /** readRemoteMachines with a local failure guard (rendering must never throw). */
180
+ function readRemoteMachinesSafe(coordRoot) {
181
+ try {
182
+ return readRemoteMachines(coordRoot);
183
+ }
184
+ catch {
185
+ return [];
186
+ }
187
+ }
188
+ /** Render sessions on other machines (presence transport) as a subtable. */
189
+ function formatRemoteTable(remote) {
190
+ if (remote.length === 0)
191
+ return "";
192
+ const nowSec = Math.floor(Date.now() / 1000);
193
+ const lines = [];
194
+ for (const m of remote) {
195
+ for (const a of m.agents.slice(0, 10)) {
196
+ lines.push(formatRow({ ...a, display_files: Array.from(a.files_touched ?? []).sort() }, nowSec).replace(/^ {2}- (agent-[^ ]+)/, ` - $1 @${m.machine}`));
197
+ }
198
+ }
199
+ if (lines.length === 0)
200
+ return "";
201
+ return `Sessions on other machines (advisory, via presence refs):\n${lines.join("\n")}`;
202
+ }
162
203
  function computeCouncilPendingIfChanged(coordRoot, selfInstanceId, agentName) {
163
204
  const councilsDir = join(coordRoot, ".harnery", "councils");
164
205
  if (!existsSync(councilsDir))
@@ -1 +1 @@
1
- {"version":3,"file":"session-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/session-context.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAyC,QAAQ,EAAE,MAAM,SAAS,CAAC;AAoB1E,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;+BAC2B;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CA+D7D;AAwHD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAwBvD;AAwDD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAiDlF;AAED;;;GAGG;AACH,eAAO,MAAM,uBAAuB,iBAAW,CAAC"}
1
+ {"version":3,"file":"session-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/session-context.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAyC,QAAQ,EAAE,MAAM,SAAS,CAAC;AAqB1E,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;+BAC2B;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAiE7D;AA+ID;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAwBvD;AAwDD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAiDlF;AAED;;;GAGG;AACH,eAAO,MAAM,uBAAuB,iBAAW,CAAC"}
@@ -11,6 +11,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { resolveBinName, resolveHooksSetupHint } from "../../config.js";
13
13
  import { harneryVersion, loadHarnessWiring } from "../../hooks/harness/wiring.js";
14
+ import { readRemoteMachines } from "../../presence/index.js";
14
15
  /**
15
16
  * Build the combined SessionStart systemMessage. Returns the additionalContext
16
17
  * string (or "" if there's nothing to say).
@@ -20,7 +21,9 @@ export function renderSessionContext(opts) {
20
21
  const messages = [];
21
22
  // 1. Self-name line + peer table (folded if peers present)
22
23
  const peers = readActivePeers(coordRoot, instanceId);
23
- const peerTable = formatPeerTable(peers, sessionId);
24
+ const localTable = formatPeerTable(peers, sessionId);
25
+ // Cross-machine presence (ADR 0016): sessions on other machines (advisory).
26
+ const peerTable = [localTable, formatRemoteMachines(coordRoot)].filter(Boolean).join("\n\n");
24
27
  if (agentName) {
25
28
  const suffix = platformLabel ? ` (${platformLabel})` : "";
26
29
  const selfLine = `You are agent-${agentName}${suffix}.`;
@@ -72,6 +75,28 @@ export function renderSessionContext(opts) {
72
75
  }
73
76
  return messages.join("\n\n");
74
77
  }
78
+ /** One-line-per-agent view of sessions on other machines (presence refs). */
79
+ function formatRemoteMachines(coordRoot) {
80
+ try {
81
+ const remote = readRemoteMachines(coordRoot);
82
+ if (remote.length === 0)
83
+ return "";
84
+ const lines = [];
85
+ for (const m of remote) {
86
+ for (const a of m.agents.slice(0, 10)) {
87
+ const task = a.task ? ` "${a.task.slice(0, 60)}"` : "";
88
+ const files = a.files_touched?.length
89
+ ? `holds: ${a.files_touched.slice(0, 3).join(", ")}${a.files_touched.length > 3 ? `, +${a.files_touched.length - 3} more` : ""}`
90
+ : "nothing held";
91
+ lines.push(` - agent-${a.name ?? a.instance_id.slice(0, 8)} @${m.machine}${task} (${files})`);
92
+ }
93
+ }
94
+ return `Sessions on other machines (advisory, via presence refs):\n${lines.join("\n")}`;
95
+ }
96
+ catch {
97
+ return "";
98
+ }
99
+ }
75
100
  /** Read all peer heartbeats from .harnery/active/, excluding self. */
76
101
  function readActivePeers(coordRoot, selfInstanceId) {
77
102
  const out = [];
@@ -1 +1 @@
1
- {"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAQH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAeF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,aAAa,CAoGjF"}
1
+ {"version":3,"file":"claim-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/claim-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AASH,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAeF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,aAAa,CAoGjF"}
@@ -16,6 +16,7 @@
16
16
  import { spawnSync } from "node:child_process";
17
17
  import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
+ import { emit } from "../events/emit.js";
19
20
  const FRESHNESS_SECS = 600;
20
21
  /**
21
22
  * Evaluate a file-claim. Returns deny when another fresh peer claims the
@@ -267,13 +268,37 @@ function pruneClaimFromPeer(coordRoot, instanceId, relPath) {
267
268
  try {
268
269
  const body = JSON.parse(readFileSync(path, "utf8"));
269
270
  const files = body.files_touched ?? [];
270
- const next = files.filter((p) => p !== relPath);
271
+ // Normalize both sides: files_touched can hold absolute-under-coordRoot
272
+ // entries (legacy projections) as well as canonical relative ones — an
273
+ // exact-string filter silently no-ops on the mixed-form case and the
274
+ // stale claim never heals.
275
+ const norm = (p) => p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
276
+ const target = norm(relPath);
277
+ const next = files.filter((p) => norm(p) !== target);
271
278
  if (next.length === files.length)
272
279
  return;
273
280
  body.files_touched = next;
274
281
  const tmp = `${path}.tmp.${process.pid}`;
275
282
  writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
276
283
  renameSync(tmp, path);
284
+ // Durable subtraction: the projector rebuilds files_touched from the
285
+ // permanent Edit/Write events, so a file-only prune resurrects on the
286
+ // next replay. Soft-fail — the heartbeat write already landed.
287
+ try {
288
+ const platform = body.platform;
289
+ emit(coordRoot, {
290
+ event_type: "claim.release",
291
+ instance_id: instanceId,
292
+ session_id: body.session_id ?? instanceId,
293
+ harness: platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code",
294
+ source: "agent-coord",
295
+ data: { path: target, reason: "heal" },
296
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
297
+ });
298
+ }
299
+ catch {
300
+ /* soft-fail */
301
+ }
277
302
  }
278
303
  catch {
279
304
  /* silent */
@@ -1 +1 @@
1
- {"version":3,"file":"commit-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/commit-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAiBH,UAAU,QAAQ;IAChB,4CAA4C;IAC5C,WAAW,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,YAAY,EAAE,MAAM,CAAC;IACrB,0BAA0B;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,UAAU,EAAE,MAAM,CAAC;IACnB,yCAAyC;IACzC,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB;wEACoE;IACpE,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,qFAAqF;IACrF,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,8DAA8D;IAC9D,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,2BAA2B,CAAC,EAAE,OAAO,CAAC;CACvC;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,oBAAoB,GAAG,mBAAmB,CAsGhG"}
1
+ {"version":3,"file":"commit-conflict.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/rules/commit-conflict.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAgBH,UAAU,QAAQ;IAChB,4CAA4C;IAC5C,WAAW,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,YAAY,EAAE,MAAM,CAAC;IACrB,0BAA0B;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,UAAU,EAAE,MAAM,CAAC;IACnB,yCAAyC;IACzC,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB;wEACoE;IACpE,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,qFAAqF;IACrF,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,8DAA8D;IAC9D,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,2BAA2B,CAAC,EAAE,OAAO,CAAC;CACvC;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,oBAAoB,GAAG,mBAAmB,CAsGhG"}