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,147 @@
1
+ import type { Command } from "commander";
2
+ import type { EmitContext } from "../commander.ts";
3
+ import {
4
+ type BenchResult,
5
+ createBuiltinHarnessRegistry,
6
+ type HarnessBenchReport,
7
+ runHarnessBench,
8
+ } from "../core/harnesses/index.ts";
9
+ import type { HarnessProfile } from "../core/harnesses/types.ts";
10
+
11
+ interface FormatOpts {
12
+ json?: boolean;
13
+ }
14
+
15
+ interface BenchOpts extends FormatOpts {
16
+ requireInstalled?: boolean;
17
+ }
18
+
19
+ const registry = createBuiltinHarnessRegistry();
20
+
21
+ export function registerHarnessCommand(program: Command, emit: EmitContext): void {
22
+ const command = program
23
+ .command("harness")
24
+ .description("Inspect registered harness capabilities and run their conformance bench.");
25
+
26
+ command
27
+ .command("list")
28
+ .description("List registered harness adapters and their high-signal capability claims.")
29
+ .option("--json", "Machine-readable profile catalog")
30
+ .action((opts: FormatOpts) => {
31
+ const profiles = registry.list().map((adapter) => adapter.profile);
32
+ if (opts.json) {
33
+ emit.config({ format: "json" });
34
+ emit.data({ harnesses: profiles });
35
+ } else {
36
+ emit.text(renderProfileTable(profiles));
37
+ }
38
+ });
39
+
40
+ command
41
+ .command("show <id>")
42
+ .description("Show one harness's complete capability declaration.")
43
+ .option("--json", "Machine-readable profile")
44
+ .action((id: string, opts: FormatOpts) => {
45
+ const adapter = registry.get(id);
46
+ if (!adapter) {
47
+ emit.error({
48
+ code: "unknown_harness",
49
+ message: `unknown harness ${JSON.stringify(id)} (registered: ${registry.ids().join(", ")})`,
50
+ });
51
+ emit.setExitCode(1);
52
+ return;
53
+ }
54
+ if (opts.json) {
55
+ emit.config({ format: "json" });
56
+ emit.data(adapter.profile);
57
+ } else {
58
+ emit.text(renderProfile(adapter.profile));
59
+ }
60
+ });
61
+
62
+ command
63
+ .command("bench [harnesses...]")
64
+ .description("Run the offline adapter-contract bench (no model calls); drift exits non-zero.")
65
+ .option("--require-installed", "Also fail when a registered vendor CLI is missing")
66
+ .option("--json", "Machine-readable conformance report")
67
+ .action((harnesses: string[], opts: BenchOpts) => {
68
+ try {
69
+ const report = runHarnessBench(registry, { harnesses });
70
+ if (opts.json) {
71
+ emit.config({ format: "json" });
72
+ emit.data(report);
73
+ } else {
74
+ emit.text(renderBenchReport(report));
75
+ }
76
+ emit.setExitCode(report.drift || (opts.requireInstalled && report.skipped) ? 1 : 0);
77
+ } catch (error) {
78
+ emit.error({ code: "harness_bench_failed", message: (error as Error).message });
79
+ emit.setExitCode(1);
80
+ }
81
+ });
82
+ }
83
+
84
+ export function renderProfileTable(profiles: readonly HarnessProfile[]): string {
85
+ const rows = profiles.map((profile) => [
86
+ profile.id,
87
+ profile.binary,
88
+ profile.capabilities.modelSelection.support,
89
+ profile.capabilities.effortSelection.support,
90
+ profile.capabilities.sessionId.support,
91
+ profile.capabilities.cost.support,
92
+ ]);
93
+ return renderTable(["HARNESS", "BINARY", "MODEL", "EFFORT", "SESSION", "COST"], rows);
94
+ }
95
+
96
+ export function renderProfile(profile: HarnessProfile): string {
97
+ const lines = [
98
+ `${profile.displayName} (${profile.id})`,
99
+ `binary: ${profile.binary}`,
100
+ `integration: ${profile.integrationMode}`,
101
+ `auth: ${profile.authModel}`,
102
+ `model family: ${profile.modelFamily}`,
103
+ `effort values: ${profile.effortValues.join(", ") || "none"}`,
104
+ profile.verified
105
+ ? `verified: ${profile.verified.date} (${profile.verified.version})`
106
+ : "verified: not recorded",
107
+ "",
108
+ "capabilities:",
109
+ ];
110
+ for (const [dimension, claim] of Object.entries(profile.capabilities)) {
111
+ lines.push(` ${dimension.padEnd(18)} ${claim.support}${claim.note ? ` — ${claim.note}` : ""}`);
112
+ }
113
+ return lines.join("\n");
114
+ }
115
+
116
+ export function renderBenchReport(report: HarnessBenchReport): string {
117
+ const rows = report.results.map((result) => [
118
+ result.harness,
119
+ result.dimension,
120
+ result.declared,
121
+ result.observed,
122
+ result.verdict,
123
+ ]);
124
+ const table = renderTable(["HARNESS", "DIMENSION", "DECLARED", "OBSERVED", "VERDICT"], rows);
125
+ const summary = Object.entries(report.summary)
126
+ .filter(([, count]) => count > 0)
127
+ .map(([verdict, count]) => `${verdict}=${count}`)
128
+ .join(", ");
129
+ return `${table}\n\nmode: ${report.mode} (no model calls)\n${summary}`;
130
+ }
131
+
132
+ function renderTable(headers: string[], rows: string[][]): string {
133
+ const widths = headers.map((header, index) =>
134
+ Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)),
135
+ );
136
+ const line = (cells: string[]) =>
137
+ cells
138
+ .map((cell, index) => cell.padEnd(widths[index]))
139
+ .join(" ")
140
+ .trimEnd();
141
+ return [line(headers), line(widths.map((width) => "-".repeat(width))), ...rows.map(line)].join(
142
+ "\n",
143
+ );
144
+ }
145
+
146
+ /** Kept exported so command tests can assert row semantics without model calls. */
147
+ export type { BenchResult };
@@ -120,6 +120,17 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
120
120
  if (stamp) actions.push(stamp);
121
121
  }
122
122
 
123
+ // ── 1c. workflow billing default ───────────────────────────────────────
124
+ // Every init'd project gets `workflow.subscriptionOnly: true`: workflow
125
+ // children ride the logged-in (subscription) harness auth, and the pin
126
+ // makes per-token API billing structurally impossible unless the project
127
+ // deliberately flips it. A committed `workflow` key of any shape is a
128
+ // deliberate choice and is never touched.
129
+ {
130
+ const stamp = stampWorkflowDefaults(resolve(coordDir, "config.jsonc"), dryRun);
131
+ if (stamp) actions.push(stamp);
132
+ }
133
+
123
134
  // ── 2. harness hooks ───────────────────────────────────────────────────
124
135
  const settingsPath = resolve(projectRoot, spec.settingsFile);
125
136
  const agentHook = relative(projectRoot, resolve(HARNERY_ROOT, "bin", "agent-hook"));
@@ -139,23 +150,23 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
139
150
  } else {
140
151
  settings = {};
141
152
  }
142
- const { wired, already, removed } = wireHooks(settings, spec, agentHook, harness);
153
+ const { wired, already, removed, upgraded } = wireHooks(settings, spec, agentHook, harness);
143
154
 
144
- if (wired === 0 && removed === 0) {
155
+ if (wired === 0 && removed === 0 && upgraded === 0) {
145
156
  actions.push(
146
157
  `· all ${spec.events.length} ${harness} hooks already wired in ${rel(projectRoot, settingsPath)}`,
147
158
  );
148
159
  } else if (dryRun) {
149
160
  actions.push(
150
- `+ would wire ${wired} hook(s) and remove ${removed} legacy hook(s) in ` +
151
- `${rel(projectRoot, settingsPath)} (${already} already present)`,
161
+ `+ would wire ${wired} hook(s), upgrade ${upgraded} stale command(s), and remove ` +
162
+ `${removed} legacy hook(s) in ${rel(projectRoot, settingsPath)} (${already} already present)`,
152
163
  );
153
164
  } else {
154
165
  mkdirSync(dirname(settingsPath), { recursive: true });
155
166
  writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
156
167
  actions.push(
157
- `+ wired ${wired} hook(s) and removed ${removed} legacy hook(s) in ` +
158
- `${rel(projectRoot, settingsPath)} (${already} already present)`,
168
+ `+ wired ${wired} hook(s), upgraded ${upgraded} stale command(s), and removed ` +
169
+ `${removed} legacy hook(s) in ${rel(projectRoot, settingsPath)} (${already} already present)`,
159
170
  );
160
171
  }
161
172
 
@@ -169,11 +180,12 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
169
180
 
170
181
  /**
171
182
  * Merge agent-hook entries into a harness settings object in place, idempotently.
172
- * Preserves every existing hook; skips events already wired to
173
- * `agent-hook <subcommand>`. Honors the harness's entry shape (Claude/Codex nest
174
- * under an inner `hooks` array; Cursor uses a flat `{ command }`) and ensures the
175
- * root `version` key when the harness requires one. Pure (no fs/git) so it's
176
- * unit-testable.
183
+ * Preserves every existing hook; rewrites events already wired to
184
+ * `agent-hook <subcommand>` when their command string is stale (e.g. an older
185
+ * harnery wired a bare relative path). Honors the harness's entry shape
186
+ * (Claude/Codex nest under an inner `hooks` array; Cursor uses a flat
187
+ * `{ command }`) and ensures the root `version` key when the harness requires
188
+ * one. Pure (no fs/git) so it's unit-testable.
177
189
  *
178
190
  * The trailing space in the match (`agent-hook ${subcommand} `) is load-bearing:
179
191
  * it keeps `stop` from matching `stop-failure`.
@@ -183,7 +195,7 @@ export function wireHooks(
183
195
  spec: HarnessSpec,
184
196
  agentHookPath: string,
185
197
  harness: HarnessId,
186
- ): { wired: number; already: number; removed: number } {
198
+ ): { wired: number; already: number; removed: number; upgraded: number } {
187
199
  if (spec.rootVersion !== undefined && settings.version === undefined) {
188
200
  settings.version = spec.rootVersion;
189
201
  }
@@ -191,6 +203,7 @@ export function wireHooks(
191
203
  let wired = 0;
192
204
  let already = 0;
193
205
  let removed = 0;
206
+ let upgraded = 0;
194
207
  for (const { settingsKey, subcommand } of spec.legacyEvents ?? []) {
195
208
  const groups = settings.hooks[settingsKey] ?? [];
196
209
  const kept = groups.filter(
@@ -202,11 +215,14 @@ export function wireHooks(
202
215
  else settings.hooks[settingsKey] = kept;
203
216
  }
204
217
  for (const { settingsKey, subcommand } of spec.events) {
205
- const command = `bash ${agentHookPath} ${subcommand} --harness ${harness}`;
218
+ const command = hookCommand(spec, agentHookPath, subcommand, harness);
206
219
  const groups = settings.hooks[settingsKey] ?? [];
207
- const present = groups.some((g) =>
208
- groupCommands(g).some((c) => commandWiresSubcommand(c, subcommand)),
209
- );
220
+ let present = false;
221
+ for (const group of groups) {
222
+ upgraded += rewriteStaleCommands(group, subcommand, command, () => {
223
+ present = true;
224
+ });
225
+ }
210
226
  if (present) {
211
227
  already++;
212
228
  continue;
@@ -215,7 +231,62 @@ export function wireHooks(
215
231
  settings.hooks[settingsKey] = groups;
216
232
  wired++;
217
233
  }
218
- return { wired, already, removed };
234
+ return { wired, already, removed, upgraded };
235
+ }
236
+
237
+ /**
238
+ * The canonical hook command for one event. When the harness exports a
239
+ * project-dir env var to hook processes (Claude Code's CLAUDE_PROJECT_DIR),
240
+ * anchor the agent-hook path on it: hook processes inherit the session
241
+ * shell's cwd, which follows `cd` away from the project root — a bare
242
+ * relative path silently fails to spawn from there (no events, no image
243
+ * capture, no guards). `:-.` keeps the command working on harness versions
244
+ * that don't set the var. Only the env expansion is quoted so the
245
+ * `agent-hook <subcommand> ` wiring match stays byte-identical.
246
+ */
247
+ function hookCommand(
248
+ spec: HarnessSpec,
249
+ agentHookPath: string,
250
+ subcommand: string,
251
+ harness: HarnessId,
252
+ ): string {
253
+ const anchor = spec.projectDirEnv ? `"\${${spec.projectDirEnv}:-.}"/` : "";
254
+ return `bash ${anchor}${agentHookPath} ${subcommand} --harness ${harness}`;
255
+ }
256
+
257
+ /**
258
+ * Rewrite any harnery-owned command for `subcommand` inside one hook group to
259
+ * the canonical form, in place. Returns the number of commands rewritten and
260
+ * calls `onPresent` when the subcommand is wired in this group at all.
261
+ */
262
+ function rewriteStaleCommands(
263
+ group: HookGroup,
264
+ subcommand: string,
265
+ canonical: string,
266
+ onPresent: () => void,
267
+ ): number {
268
+ let rewritten = 0;
269
+ if ("command" in group && typeof group.command === "string") {
270
+ if (commandWiresSubcommand(group.command, subcommand)) {
271
+ onPresent();
272
+ if (group.command !== canonical) {
273
+ group.command = canonical;
274
+ rewritten++;
275
+ }
276
+ }
277
+ }
278
+ if ("hooks" in group && Array.isArray(group.hooks)) {
279
+ for (const hook of group.hooks) {
280
+ if (typeof hook.command !== "string") continue;
281
+ if (!commandWiresSubcommand(hook.command, subcommand)) continue;
282
+ onPresent();
283
+ if (hook.command !== canonical) {
284
+ hook.command = canonical;
285
+ rewritten++;
286
+ }
287
+ }
288
+ }
289
+ return rewritten;
219
290
  }
220
291
 
221
292
  /**
@@ -311,6 +382,56 @@ export function stampBinName(configPath: string, binName: string, dryRun: boolea
311
382
  return `+ added binName "${binName}" to ${rel(configPath)}`;
312
383
  }
313
384
 
385
+ /** The commented workflow-billing default block init stamps into config.jsonc. */
386
+ const WORKFLOW_DEFAULT_BLOCK =
387
+ ` // Workflow children ride the logged-in (subscription) harness auth; this pin\n` +
388
+ ` // scrubs API-key vars from child envs so runs can never bill per-token rates.\n` +
389
+ ` // Key-only hosts (CI): set false, or HARNERY_WORKFLOW_SUBSCRIPTION_ONLY=0.\n` +
390
+ ` "workflow": { "subscriptionOnly": true }`;
391
+
392
+ /**
393
+ * Idempotently pin `workflow.subscriptionOnly: true` in `.harnery/config.jsonc`
394
+ * (see `workflowSubscriptionOnly()` in core/config.ts and the `workflow run`
395
+ * billing safeguards). Same comment-preserving discipline as `stampBinName`.
396
+ * A `workflow` key of ANY shape (including `subscriptionOnly: false`) is a
397
+ * deliberate project choice and is left alone.
398
+ */
399
+ export function stampWorkflowDefaults(configPath: string, dryRun: boolean): string | null {
400
+ const rel = (p: string) => relative(dirname(dirname(configPath)), p) || p;
401
+
402
+ if (!existsSync(configPath)) {
403
+ if (dryRun) return `+ would pin workflow.subscriptionOnly in ${rel(configPath)}`;
404
+ writeFileSync(configPath, `{\n${WORKFLOW_DEFAULT_BLOCK}\n}\n`);
405
+ return `+ pinned workflow.subscriptionOnly: true in ${rel(configPath)}`;
406
+ }
407
+
408
+ let raw: string;
409
+ try {
410
+ raw = readFileSync(configPath, "utf8");
411
+ } catch {
412
+ return null;
413
+ }
414
+ let parsed: { workflow?: unknown } & Record<string, unknown>;
415
+ try {
416
+ parsed = (JSON.parse(stripJsonComments(raw)) as typeof parsed) ?? {};
417
+ } catch {
418
+ return `· ${rel(configPath)} isn't valid JSONC; skipped workflow.subscriptionOnly pin`;
419
+ }
420
+
421
+ if ("workflow" in parsed) return null; // deliberate config; never touch
422
+
423
+ const at = firstBraceIndex(raw);
424
+ if (at < 0) return `· ${rel(configPath)} has no object literal; skipped workflow pin`;
425
+ const keys = Object.keys(parsed);
426
+ const next =
427
+ keys.length === 0
428
+ ? `{\n${WORKFLOW_DEFAULT_BLOCK}\n}\n`
429
+ : `${raw.slice(0, at + 1)}\n${WORKFLOW_DEFAULT_BLOCK},${raw.slice(at + 1)}`;
430
+ if (dryRun) return `+ would pin workflow.subscriptionOnly in ${rel(configPath)}`;
431
+ writeFileSync(configPath, next);
432
+ return `+ pinned workflow.subscriptionOnly: true in ${rel(configPath)}`;
433
+ }
434
+
314
435
  /** Index of the first structural `{`, skipping leading whitespace + comments. */
315
436
  function firstBraceIndex(raw: string): number {
316
437
  let i = 0;
@@ -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
+ }