harnery 0.6.0 → 0.7.1

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 (137) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +19 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +2 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +51 -4
  7. package/dist/commands/deinit.d.ts.map +1 -1
  8. package/dist/commands/deinit.js +4 -0
  9. package/dist/commands/devtools.d.ts +4 -0
  10. package/dist/commands/devtools.d.ts.map +1 -0
  11. package/dist/commands/devtools.js +239 -0
  12. package/dist/commands/docs.d.ts.map +1 -1
  13. package/dist/commands/docs.js +69 -1
  14. package/dist/commands/doctor.js +12 -4
  15. package/dist/commands/env.d.ts.map +1 -1
  16. package/dist/commands/env.js +3 -63
  17. package/dist/commands/init.d.ts +1 -0
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +54 -14
  20. package/dist/commands/scratch.js +1 -1
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +273 -62
  23. package/dist/commands/web-fetch.js +1 -1
  24. package/dist/core/agents/cli.js +48 -0
  25. package/dist/core/agents/coord-client.d.ts.map +1 -1
  26. package/dist/core/agents/coord-client.js +32 -8
  27. package/dist/core/agents/events/emit.d.ts.map +1 -1
  28. package/dist/core/agents/events/emit.js +4 -0
  29. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  30. package/dist/core/agents/rules/claim-conflict.js +16 -5
  31. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  32. package/dist/core/agents/state/heartbeat-projector.js +10 -3
  33. package/dist/core/config.d.ts +10 -0
  34. package/dist/core/config.d.ts.map +1 -1
  35. package/dist/core/config.js +13 -0
  36. package/dist/core/hooks/cli.js +3 -3
  37. package/dist/core/hooks/effects/index.d.ts +11 -7
  38. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  39. package/dist/core/hooks/effects/index.js +15 -18
  40. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  41. package/dist/core/hooks/events/emit.js +4 -0
  42. package/dist/core/hooks/events/rotate.d.ts +43 -0
  43. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  44. package/dist/core/hooks/events/rotate.js +142 -0
  45. package/dist/core/hooks/harness/events.d.ts +11 -1
  46. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  47. package/dist/core/hooks/harness/events.js +22 -3
  48. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  49. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  50. package/dist/core/hooks/harness/wiring.js +34 -5
  51. package/dist/core/scratch/index.d.ts.map +1 -0
  52. package/dist/{lib → core}/scratch/index.js +2 -2
  53. package/dist/lib/devtools.d.ts +178 -0
  54. package/dist/lib/devtools.d.ts.map +1 -0
  55. package/dist/lib/devtools.js +1328 -0
  56. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  57. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  58. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  59. package/dist/lib/docs-frontmatter.d.ts +33 -0
  60. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  61. package/dist/lib/docs-frontmatter.js +130 -0
  62. package/dist/lib/docs-index.d.ts +1 -0
  63. package/dist/lib/docs-index.d.ts.map +1 -1
  64. package/dist/lib/docs-index.js +4 -5
  65. package/dist/lib/docs-lint.d.ts +2 -0
  66. package/dist/lib/docs-lint.d.ts.map +1 -1
  67. package/dist/lib/docs-lint.js +18 -12
  68. package/dist/lib/docs-meta.d.ts +14 -0
  69. package/dist/lib/docs-meta.d.ts.map +1 -0
  70. package/dist/lib/docs-meta.js +34 -0
  71. package/dist/lib/docs-sweep.d.ts +12 -0
  72. package/dist/lib/docs-sweep.d.ts.map +1 -1
  73. package/dist/lib/docs-sweep.js +98 -103
  74. package/dist/lib/format.js +2 -2
  75. package/dist/lib/http/index.d.ts +1 -0
  76. package/dist/lib/http/index.d.ts.map +1 -1
  77. package/dist/lib/http/index.js +1 -0
  78. package/dist/lib/http/request.d.ts +77 -0
  79. package/dist/lib/http/request.d.ts.map +1 -0
  80. package/dist/lib/http/request.js +105 -0
  81. package/dist/lib/instructions/apply.d.ts +63 -0
  82. package/dist/lib/instructions/apply.d.ts.map +1 -0
  83. package/dist/lib/instructions/apply.js +255 -0
  84. package/dist/lib/instructions/splice.d.ts +73 -0
  85. package/dist/lib/instructions/splice.d.ts.map +1 -0
  86. package/dist/lib/instructions/splice.js +118 -0
  87. package/dist/lib/instructions/templates.d.ts +45 -0
  88. package/dist/lib/instructions/templates.d.ts.map +1 -0
  89. package/dist/lib/instructions/templates.js +258 -0
  90. package/dist/lib/tunnel/gate.d.ts +1 -0
  91. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  92. package/dist/lib/tunnel/gate.js +14 -9
  93. package/dist/lib/tunnel/state.d.ts +11 -1
  94. package/dist/lib/tunnel/state.d.ts.map +1 -1
  95. package/dist/lib/tunnel/state.js +8 -3
  96. package/package.json +7 -6
  97. package/src/commander.ts +23 -0
  98. package/src/commands/agents.ts +50 -3
  99. package/src/commands/deinit.ts +5 -0
  100. package/src/commands/devtools.ts +284 -0
  101. package/src/commands/docs.ts +81 -1
  102. package/src/commands/doctor.ts +13 -4
  103. package/src/commands/env.ts +11 -77
  104. package/src/commands/init.ts +66 -15
  105. package/src/commands/scratch.ts +1 -1
  106. package/src/commands/tunnel.ts +316 -65
  107. package/src/commands/web-fetch.ts +1 -1
  108. package/src/core/agents/cli.ts +55 -0
  109. package/src/core/agents/coord-client.ts +34 -7
  110. package/src/core/agents/events/emit.ts +5 -0
  111. package/src/core/agents/rules/claim-conflict.ts +17 -6
  112. package/src/core/agents/state/heartbeat-projector.ts +11 -3
  113. package/src/core/config.ts +14 -0
  114. package/src/core/hooks/cli.ts +3 -3
  115. package/src/core/hooks/effects/index.ts +23 -17
  116. package/src/core/hooks/events/emit.ts +5 -0
  117. package/src/core/hooks/events/rotate.ts +151 -0
  118. package/src/core/hooks/harness/events.ts +30 -3
  119. package/src/core/hooks/harness/wiring.ts +46 -5
  120. package/src/{lib → core}/scratch/index.ts +2 -2
  121. package/src/lib/devtools.ts +1653 -0
  122. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  123. package/src/lib/docs-frontmatter.ts +151 -0
  124. package/src/lib/docs-index.ts +4 -5
  125. package/src/lib/docs-lint.ts +17 -11
  126. package/src/lib/docs-meta.ts +44 -0
  127. package/src/lib/docs-sweep.ts +104 -102
  128. package/src/lib/format.ts +2 -2
  129. package/src/lib/http/index.ts +1 -0
  130. package/src/lib/http/request.ts +154 -0
  131. package/src/lib/instructions/apply.ts +318 -0
  132. package/src/lib/instructions/splice.ts +148 -0
  133. package/src/lib/instructions/templates.ts +295 -0
  134. package/src/lib/tunnel/gate.ts +14 -9
  135. package/src/lib/tunnel/state.ts +19 -4
  136. package/dist/lib/scratch/index.d.ts.map +0 -1
  137. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -164,6 +164,39 @@ async function handleProject(root: string, rest: string[]): Promise<number> {
164
164
  return 0;
165
165
  }
166
166
 
167
+ /**
168
+ * Append a canonical `claim.release` event for a path dropped from an owner's
169
+ * files_touched. The path is canonicalized to repo-relative (matching the
170
+ * projector's normalization) so the subtraction matches on replay regardless
171
+ * of the form the caller passed. Soft-fails: a failed emit must never break
172
+ * the release/kill flow — the file mutation already happened.
173
+ */
174
+ async function emitClaimRelease(
175
+ root: string,
176
+ owner: string,
177
+ hb: { session_id?: string; platform?: string },
178
+ path: string,
179
+ reason: "explicit" | "heal",
180
+ ): Promise<void> {
181
+ try {
182
+ const { emit } = await import("./events/emit.ts");
183
+ const canonical = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
184
+ const platform = hb.platform;
185
+ const harness =
186
+ platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code";
187
+ emit(root, {
188
+ event_type: "claim.release",
189
+ instance_id: owner,
190
+ session_id: hb.session_id ?? owner,
191
+ harness,
192
+ source: "agent-coord",
193
+ data: { path: canonical, reason },
194
+ });
195
+ } catch {
196
+ /* soft-fail: never break the caller */
197
+ }
198
+ }
199
+
167
200
  async function handleStateAction(root: string, action: string, rest: string[]): Promise<number> {
168
201
  const writer = await import("./state/heartbeat-writer.ts");
169
202
  const [owner, ...args] = rest;
@@ -210,15 +243,37 @@ async function handleStateAction(root: string, action: string, rest: string[]):
210
243
  process.stderr.write("agent-coord release-claim: missing <path>\n");
211
244
  return 2;
212
245
  }
246
+ const before = writer.readHeartbeat(root, owner);
213
247
  const hb = writer.releaseClaim(root, owner, path);
214
248
  if (!hb) return 1;
249
+ // Durability: the projector rebuilds files_touched by replaying the
250
+ // permanent Edit/Write events, so a file-only release is silently
251
+ // reverted by the next full replay. Emitting claim.release puts the
252
+ // subtraction into the stream so every future replay honors it. Only
253
+ // emit when the release actually removed a held path (idempotent
254
+ // re-releases stay quiet).
255
+ const heldBefore = before?.files_touched?.length ?? 0;
256
+ const heldAfter = hb.files_touched?.length ?? 0;
257
+ if (heldBefore > heldAfter) {
258
+ await emitClaimRelease(root, owner, before ?? hb, path, "explicit");
259
+ }
215
260
  process.stdout.write(
216
261
  `${JSON.stringify({ instance_id: owner, files_touched: hb.files_touched })}\n`,
217
262
  );
218
263
  return 0;
219
264
  }
220
265
  case "kill-heartbeat": {
266
+ // Read held claims BEFORE the unlink so they can be released durably —
267
+ // killing only the file leaves the claims resurrectable from the
268
+ // permanent Edit/Write events on the next full replay (observed: a
269
+ // 6-day-dead agent's claims returning after its heartbeat was killed).
270
+ const before = writer.readHeartbeat(root, owner);
221
271
  const ok = writer.killHeartbeat(root, owner);
272
+ if (ok && before) {
273
+ for (const held of before.files_touched ?? []) {
274
+ await emitClaimRelease(root, owner, before, held, "heal");
275
+ }
276
+ }
222
277
  process.stdout.write(`${JSON.stringify({ instance_id: owner, removed: ok })}\n`);
223
278
  return ok ? 0 : 1;
224
279
  }
@@ -153,6 +153,17 @@ export function resolveOwnerWithSource(): {
153
153
 
154
154
  const root = monorepoRoot();
155
155
  if (!root) return { owner: null, source: "none" };
156
+
157
+ // Cursor's Glass/Agents UI can run several chats under one long-lived node
158
+ // process, so the pid-map row for that shared ancestor is last-writer-wins.
159
+ // Prefer the per-chat session id when Cursor exposes one in the tool env.
160
+ if (shouldPreferSessionEnv()) {
161
+ const bySession = resolveOwnerBySessionEnv(root);
162
+ if (bySession) {
163
+ return { owner: bySession, source: "session_env" };
164
+ }
165
+ }
166
+
156
167
  const pidmapDir = resolve(root, ".harnery", "pid-map");
157
168
  if (!existsSync(pidmapDir)) return { owner: null, source: "none" };
158
169
 
@@ -216,16 +227,32 @@ const SESSION_ID_ENV_VARS = [
216
227
  "HARNERY_AGENT_COORD_SESSION_ID", // explicit override, wins if set
217
228
  "CLAUDE_CODE_SESSION_ID",
218
229
  "CURSOR_SESSION_ID",
230
+ "CURSOR_CONVERSATION_ID",
219
231
  "CODEX_SESSION_ID",
220
232
  ] as const;
221
233
 
222
- /** Read the first non-empty harness session-id env var, or null. */
223
- function sessionIdFromEnv(): string | null {
234
+ /** Read normalized candidates from the first non-empty harness session-id env var. */
235
+ function sessionIdsFromEnv(): string[] {
224
236
  for (const key of SESSION_ID_ENV_VARS) {
225
237
  const v = process.env[key]?.trim();
226
- if (v) return v;
238
+ if (!v) continue;
239
+ if (key === "CURSOR_CONVERSATION_ID" && v.startsWith("bc-") && v.length > 3) {
240
+ return [v.slice(3), v];
241
+ }
242
+ return [v];
227
243
  }
228
- return null;
244
+ return [];
245
+ }
246
+
247
+ /** Read the first non-empty harness session-id env var, or null. */
248
+ function sessionIdFromEnv(): string | null {
249
+ return sessionIdsFromEnv()[0] ?? null;
250
+ }
251
+
252
+ function shouldPreferSessionEnv(): boolean {
253
+ if (!sessionIdFromEnv()) return false;
254
+ const platform = process.env.HARNERY_AGENT_COORD_PLATFORM?.trim();
255
+ return process.env.CURSOR_AGENT === "1" || platform === "cursor";
229
256
  }
230
257
 
231
258
  /**
@@ -239,8 +266,8 @@ function sessionIdFromEnv(): string | null {
239
266
  * Exported for unit testing with an injectable root.
240
267
  */
241
268
  export function resolveOwnerBySessionEnv(root: string): string | null {
242
- const sessionId = sessionIdFromEnv();
243
- if (!sessionId) return null;
269
+ const sessionIds = sessionIdsFromEnv();
270
+ if (sessionIds.length === 0) return null;
244
271
 
245
272
  const activeDir = resolve(root, ".harnery", "active");
246
273
  if (!existsSync(activeDir)) return null;
@@ -256,7 +283,7 @@ export function resolveOwnerBySessionEnv(root: string): string | null {
256
283
  if (!file.endsWith(".json")) continue;
257
284
  try {
258
285
  const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
259
- if (!parsed || parsed.session_id !== sessionId) continue;
286
+ if (!parsed || !sessionIds.includes(parsed.session_id)) continue;
260
287
  if (typeof parsed.instance_id !== "string") continue;
261
288
  const ts = Date.parse(parsed.last_heartbeat);
262
289
  if (Number.isFinite(ts) && ts >= cutoffMs) return parsed.instance_id;
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { appendFileSync, closeSync, mkdirSync, openSync } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
+ import { maybeRotateEventStream } from "../../hooks/events/rotate.ts";
14
15
  import { ulid } from "./ulid.ts";
15
16
 
16
17
  const SCHEMA_VERSION = 1 as const;
@@ -71,6 +72,10 @@ export function emit(coordRoot: string, input: EmitInput): Envelope {
71
72
  const streamPath = join(coordRoot, STREAM_FILE);
72
73
  const lockPath = join(coordRoot, LOCK_FILE);
73
74
 
75
+ // Size-triggered rotation (shared with the agent-hooks emitter): roll the
76
+ // active file to a dated archive when it crosses the cap. Fail-soft.
77
+ maybeRotateEventStream(coordRoot);
78
+
74
79
  ensureDir(dirname(streamPath));
75
80
  ensureFile(lockPath);
76
81
 
@@ -95,11 +95,22 @@ export function evaluateClaim(coordRoot: string, req: ClaimRequest): VerdictResu
95
95
 
96
96
  // Ordering check: if we hold any claim with path < req.path, fine.
97
97
  // Otherwise the new claim would create a backward-edge in the dependency
98
- // graph and risk deadlock. Only applies when there are OTHER fresh peers:
99
- // single-agent flow can't deadlock with itself, and the rule otherwise
100
- // forces release-and-reacquire cycles on every reverse-order edit pair.
101
- const hasFreshPeers = otherPeers.some(
102
- (p) => isFresh(p.last_heartbeat) && p.files_touched.length > 0,
98
+ // graph and risk deadlock. Only applies when a fresh peer genuinely CONTENDS
99
+ // with us — i.e. holds a path that also sits in our own footprint (held
100
+ // claims ∪ the path we're now requesting). A wait-for cycle is a
101
+ // strongly-connected set of agents linked by shared files; if no fresh peer
102
+ // shares any file with our footprint, we're in a disjoint component of the
103
+ // resource graph and cannot be part of any cycle, so sorted-order acquisition
104
+ // buys nothing and the block is pure false-positive friction. Sharing a file
105
+ // is the necessary condition for a cycle through this agent, so this narrowing
106
+ // leaves the deadlock-prevention invariant intact for genuine contention while
107
+ // removing the dominant real-world cost — a peer editing unrelated files
108
+ // walling off every backward-order edit. (Single-agent flow can't deadlock
109
+ // with itself and never arms, since there are no other peers to contend.)
110
+ const myFootprint = new Set<string>(myPeer?.files_touched ?? []);
111
+ myFootprint.add(req.path);
112
+ const hasContendingPeer = otherPeers.some(
113
+ (p) => isFresh(p.last_heartbeat) && p.files_touched.some((f) => myFootprint.has(f)),
103
114
  );
104
115
  // Re-editing a path already in our own files_touched acquires no new lock
105
116
  // edge, so it can't create a circular wait — the ordering rule must not block
@@ -109,7 +120,7 @@ export function evaluateClaim(coordRoot: string, req: ClaimRequest): VerdictResu
109
120
  // both agent-Gibson holding README.md and agent-Ophelia holding AGENTS.md were
110
121
  // blocked re-editing those held files after touching a higher path, 2026-07-03).
111
122
  const alreadyHeld = myPeer?.files_touched.includes(req.path) ?? false;
112
- if (hasFreshPeers && myPeer && myPeer.files_touched.length > 0 && !alreadyHeld) {
123
+ if (hasContendingPeer && myPeer && myPeer.files_touched.length > 0 && !alreadyHeld) {
113
124
  // Only ACTIVE (uncommitted) edits should constrain lock ordering. A claim on
114
125
  // a committed-clean file is a finished edit, not a held lock, so it must not
115
126
  // wall off a lower-sorted acquisition. Without this, a long session
@@ -88,7 +88,7 @@ export function projectHeartbeats(
88
88
  if (!existing && TERMINAL.has(ev.event_type)) continue;
89
89
  perOwner[ev.instance_id] = existing ?? seed(ev, coordRoot);
90
90
  }
91
- apply(perOwner[ev.instance_id]!, ev);
91
+ apply(perOwner[ev.instance_id]!, ev, coordRoot);
92
92
  }
93
93
 
94
94
  const written: string[] = [];
@@ -153,7 +153,7 @@ function seed(ev: CanonicalEvent, coordRoot: string): V2Heartbeat {
153
153
  return hb;
154
154
  }
155
155
 
156
- function apply(hb: V2Heartbeat, ev: CanonicalEvent): void {
156
+ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
157
157
  hb.last_heartbeat = ev.ts;
158
158
  hb.last_event_id = ev.event_id;
159
159
  hb.events_applied += 1;
@@ -285,7 +285,15 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent): void {
285
285
  case "claim.release": {
286
286
  const path = pickStr(d, "path");
287
287
  if (path && hb.files_touched) {
288
- hb.files_touched = hb.files_touched.filter((p) => p !== path);
288
+ // files_touched holds a mix of absolute-under-coordRoot and canonical
289
+ // repo-relative entries (Edit events report absolute; release-claim
290
+ // canonicalizes to relative). Normalize both sides so a release
291
+ // subtracts regardless of form — an exact-string compare silently
292
+ // no-ops on the mismatch and the claim resurrects on the next replay.
293
+ const norm = (p: string): string =>
294
+ p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
295
+ const target = norm(path);
296
+ hb.files_touched = hb.files_touched.filter((p) => norm(p) !== target);
289
297
  }
290
298
  break;
291
299
  }
@@ -118,6 +118,20 @@ export function resolveBinName(coordRoot?: string | null): string {
118
118
  return DEFAULT_BIN_NAME;
119
119
  }
120
120
 
121
+ /**
122
+ * The binName explicitly pinned in `<projectRoot>/.harnery/config.jsonc`, or
123
+ * null when absent. Unlike `resolveBinName()` this ignores `HARNERY_BIN` and
124
+ * never falls back to the default — it answers "did someone deliberately pin
125
+ * a name for THIS project?". `init` uses it so a re-run from a different host
126
+ * CLI can't silently re-stamp its own name over a committed pin (the harnery
127
+ * repo itself pins `"harn"` while living embedded in a host monorepo whose
128
+ * CLI would otherwise stamp the host's name into public, committed surfaces).
129
+ */
130
+ export function pinnedBinName(projectRoot: string): string | null {
131
+ const binName = readConfig(projectRoot).binName;
132
+ return typeof binName === "string" && binName.trim() ? binName.trim() : null;
133
+ }
134
+
121
135
  /**
122
136
  * The host's git-hook (re)install command, for the "commit guard not wired"
123
137
  * nudge. Returns the configured `hooksSetupHint` (e.g. "scripts/setup-hooks.sh")
@@ -33,12 +33,12 @@ import {
33
33
  imageJanitor,
34
34
  playSound,
35
35
  resetSoundCounters,
36
+ runSessionSyncExtension,
36
37
  runTurnSummary,
37
38
  scratchArchive,
38
39
  scratchJanitor,
39
40
  scratchRecoveryCue,
40
41
  soundForEvent,
41
- syncClaudeSessions,
42
42
  } from "./effects/index.ts";
43
43
  import { emit } from "./events/emit.ts";
44
44
  import type { Harness } from "./events/schema.ts";
@@ -508,7 +508,7 @@ async function main(): Promise<number> {
508
508
  // session-telemetry sync (via HARNERY_CLAUDE_SESSIONS_FORCE=1).
509
509
  if (harness === "claude-code") {
510
510
  scratchArchive(coordRoot, owner.instance_id);
511
- syncClaudeSessions(coordRoot, true);
511
+ runSessionSyncExtension(coordRoot, true);
512
512
  }
513
513
  }
514
514
 
@@ -603,7 +603,7 @@ async function main(): Promise<number> {
603
603
  // CC effects: rate-limited session-telemetry sync + turn-summary Haiku
604
604
  // auto-summary.
605
605
  if (harness === "claude-code") {
606
- syncClaudeSessions(coordRoot, false);
606
+ runSessionSyncExtension(coordRoot, false);
607
607
  runTurnSummary(coordRoot, owner.instance_id, sessionId, payload?.transcript_path);
608
608
  }
609
609
 
@@ -17,7 +17,6 @@ import { existsSync, readdirSync, rmSync } from "node:fs";
17
17
  import os from "node:os";
18
18
  import { join } from "node:path";
19
19
  import { applyDetection } from "../../../lib/presence.ts";
20
- import { resolveBinName } from "../../config.ts";
21
20
 
22
21
  export type { CaptureContext } from "./image-capture.ts";
23
22
  export { captureImages, imageJanitor } from "./image-capture.ts";
@@ -118,24 +117,31 @@ export function scratchArchive(repoRoot: string, owner: string): void {
118
117
  }
119
118
 
120
119
  /**
121
- * Sync Claude Code session JSONL → BigQuery (`harn claude-sessions sync`). Lives
122
- * in the host CLI (parses Claude Code's own ~/.claude/projects/**\/*.jsonl), so harnery
123
- * shells out to the harn binary rather than importing it. Detached + unref'd so a
124
- * slow BigQuery round-trip never blocks the hook. Stop-path syncs are rate-
125
- * limited inside `harn claude-sessions sync`; SessionEnd forces via env. Caller
126
- * gates to the claude-code harness.
120
+ * Fire the optional host session-sync extension on turn stop / session end.
121
+ * harnery core has no session-telemetry sink of its own; a host that wants one
122
+ * drops an executable at
123
+ * `scripts/hooks/harness/claude_code/extensions/session-sync.sh` under the coord
124
+ * root, and core runs it detached + unref'd so a slow sink never blocks the
125
+ * hook. `force` arrives as argv $1 ("1" on session end, "0" on turn stop) so the
126
+ * host can rate-limit the stop path and force-flush on end. No-op when the
127
+ * script is absent, so a plain public install spawns nothing. Mirrors
128
+ * `runTurnSummary`'s extension-script pattern. Caller gates to the claude-code
129
+ * harness.
127
130
  */
128
- export function syncClaudeSessions(repoRoot: string, force: boolean): void {
131
+ export function runSessionSyncExtension(repoRoot: string, force: boolean): void {
129
132
  try {
130
- const bin = join(repoRoot, "bin", resolveBinName(repoRoot));
131
- if (!existsSync(bin)) return;
132
- const env: Record<string, string | undefined> = {
133
- ...process.env,
134
- HARNERY_OUTPUT_SESSION_TEE: "0",
135
- };
136
- if (force) env.HARNERY_CLAUDE_SESSIONS_FORCE = "1";
137
- const child = spawn("bash", [bin, "claude-sessions", "sync", "--quiet"], {
138
- env,
133
+ const script = join(
134
+ repoRoot,
135
+ "scripts",
136
+ "hooks",
137
+ "harness",
138
+ "claude_code",
139
+ "extensions",
140
+ "session-sync.sh",
141
+ );
142
+ if (!existsSync(script)) return;
143
+ const child = spawn("bash", [script, force ? "1" : "0"], {
144
+ env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
139
145
  detached: true,
140
146
  stdio: "ignore",
141
147
  });
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, closeSync, mkdirSync, openSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { maybeRotateEventStream } from "./rotate.ts";
3
4
  import {
4
5
  type EventEnvelope,
5
6
  type EventType,
@@ -69,6 +70,10 @@ export function emit<TType extends EventType, TData>(
69
70
  const streamPath = join(coordRoot, STREAM_FILE);
70
71
  const lockPath = join(coordRoot, LOCK_FILE);
71
72
 
73
+ // Size-triggered rotation: roll the active file to a dated archive when it
74
+ // crosses the cap, so this append lands in a bounded fresh file. Fail-soft.
75
+ maybeRotateEventStream(coordRoot);
76
+
72
77
  ensureDir(dirname(streamPath));
73
78
  ensureFile(lockPath);
74
79
 
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Size-triggered rotation for the canonical event stream
3
+ * `.harnery/events.ndjson`.
4
+ *
5
+ * The stream is a deliberately-immutable, append-only ledger. Left unbounded it
6
+ * grows past V8's ~512MB max string length, at which point any code that does a
7
+ * whole-file `readFileSync` throws ("Cannot create a string longer than
8
+ * 0x1fffffe8 characters"). Rather than police every current + future reader,
9
+ * we bound the *active* file: once it crosses a byte cap we rename it to a dated
10
+ * archive (`events-YYYY-MM-DD.ndjson`) and start a fresh active file. Archives
11
+ * are kept — the audit trail is preserved, just spread across files that readers
12
+ * glob newest-first.
13
+ *
14
+ * Both independent append paths (agent-hooks `hooks/events/emit.ts` and
15
+ * agent-coord `agents/events/emit.ts`) call `maybeRotateEventStream` before
16
+ * appending, so a roll is triggered continuously, not only at session
17
+ * boundaries.
18
+ *
19
+ * Concurrency: many short-lived hook processes append at once. The rename itself
20
+ * is atomic — a concurrent appender opening the path by name lands its line in
21
+ * whichever inode the name currently resolves to (old archive or new active
22
+ * file), never nowhere. The only hazard is two processes both renaming (the
23
+ * second would move the fresh empty file to a second archive name), which an
24
+ * `O_EXCL` roll-lock prevents.
25
+ *
26
+ * Design + rationale: harnery ADR 0009; decision docket
27
+ * `should-harnery-adopt-a-retention-2026-07-07-ed07`.
28
+ */
29
+
30
+ import {
31
+ closeSync,
32
+ existsSync,
33
+ mkdirSync,
34
+ openSync,
35
+ renameSync,
36
+ statSync,
37
+ unlinkSync,
38
+ } from "node:fs";
39
+ import { dirname, join } from "node:path";
40
+ import { coordEnv } from "../../../lib/env.ts";
41
+
42
+ const STREAM_FILE = ".harnery/events.ndjson";
43
+ const ROLL_LOCK_FILE = ".harnery/events.ndjson.roll.lock";
44
+
45
+ /** Default active-file cap: 256 MiB — half of V8's ~512MB string cliff, so even
46
+ * a reader that loads the whole active file whole stays clear of the limit. */
47
+ const DEFAULT_ROLL_BYTES = 256 * 1024 * 1024;
48
+
49
+ /** A roll-lock older than this is treated as abandoned (a crashed roller) and
50
+ * stolen. Rolls are sub-millisecond, so 60s is orders of magnitude of slack. */
51
+ const STALE_LOCK_MS = 60_000;
52
+
53
+ /** Archive glob prefix. Readers enumerate `events-*.ndjson` newest-first.
54
+ * The pre-existing manual `events-legacy.ndjson` matches and is picked up for
55
+ * free. Kept in sync with the reader-side pattern in web/lib/coord-reader.ts. */
56
+ export const ARCHIVE_PREFIX = "events-";
57
+ export const ARCHIVE_SUFFIX = ".ndjson";
58
+
59
+ function resolveRollBytes(): number {
60
+ const env = coordEnv("EVENTS_ROLL_BYTES");
61
+ const n = env ? Number(env) : Number.NaN;
62
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_ROLL_BYTES;
63
+ }
64
+
65
+ /** `YYYY-MM-DD` (UTC) from a millisecond timestamp. Uses the file's own mtime
66
+ * rather than `Date.now()` so it's deterministic and independent of the harness
67
+ * runtime's clock surface. */
68
+ function utcDateStamp(mtimeMs: number): string {
69
+ const d = new Date(mtimeMs);
70
+ const y = d.getUTCFullYear();
71
+ const mo = String(d.getUTCMonth() + 1).padStart(2, "0");
72
+ const day = String(d.getUTCDate()).padStart(2, "0");
73
+ return `${y}-${mo}-${day}`;
74
+ }
75
+
76
+ /** First non-colliding `events-<stamp>[.N].ndjson` path under `coordDir`. */
77
+ function archivePathFor(coordDir: string, stamp: string): string {
78
+ const base = join(coordDir, `${ARCHIVE_PREFIX}${stamp}${ARCHIVE_SUFFIX}`);
79
+ if (!existsSync(base)) return base;
80
+ for (let n = 1; ; n++) {
81
+ const cand = join(coordDir, `${ARCHIVE_PREFIX}${stamp}.${n}${ARCHIVE_SUFFIX}`);
82
+ if (!existsSync(cand)) return cand;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Roll `events.ndjson` to a dated archive when it exceeds the byte cap. Cheap
88
+ * no-op (a single `statSync`) below the cap. Fail-soft: any error is swallowed
89
+ * so a rotation problem can never break the append that triggered it.
90
+ *
91
+ * `nowMs` is injectable for the stale-lock check in tests; production leaves it
92
+ * unset (the roll date always comes from the file's mtime, never `nowMs`).
93
+ */
94
+ export function maybeRotateEventStream(coordRoot: string, nowMs?: number): boolean {
95
+ try {
96
+ const streamPath = join(coordRoot, STREAM_FILE);
97
+ if (!existsSync(streamPath)) return false;
98
+ const cap = resolveRollBytes();
99
+ let st = statSync(streamPath);
100
+ if (st.size < cap) return false;
101
+
102
+ const coordDir = dirname(streamPath);
103
+ const lockPath = join(coordRoot, ROLL_LOCK_FILE);
104
+ mkdirSync(coordDir, { recursive: true });
105
+
106
+ if (!acquireRollLock(lockPath, nowMs)) return false; // another roller active
107
+ try {
108
+ // Re-check under the lock: a concurrent process may have just rolled.
109
+ if (!existsSync(streamPath)) return false;
110
+ st = statSync(streamPath);
111
+ if (st.size < cap) return false;
112
+
113
+ const archive = archivePathFor(coordDir, utcDateStamp(st.mtimeMs));
114
+ renameSync(streamPath, archive);
115
+ // Recreate an empty active file so the very next append (and any reader's
116
+ // existsSync) sees a valid, present stream.
117
+ closeSync(openSync(streamPath, "a"));
118
+ return true;
119
+ } finally {
120
+ try {
121
+ unlinkSync(lockPath);
122
+ } catch {
123
+ /* best-effort */
124
+ }
125
+ }
126
+ } catch {
127
+ return false; // fail-soft: never break the caller's append
128
+ }
129
+ }
130
+
131
+ /** Create the roll-lock with O_EXCL. Returns true on acquisition. On collision,
132
+ * steals a stale lock (crashed roller) once, else returns false. */
133
+ function acquireRollLock(lockPath: string, nowMs?: number): boolean {
134
+ try {
135
+ closeSync(openSync(lockPath, "wx"));
136
+ return true;
137
+ } catch {
138
+ // Exists: steal if abandoned.
139
+ try {
140
+ const age = (nowMs ?? Date.now()) - statSync(lockPath).mtimeMs;
141
+ if (age > STALE_LOCK_MS) {
142
+ unlinkSync(lockPath);
143
+ closeSync(openSync(lockPath, "wx"));
144
+ return true;
145
+ }
146
+ } catch {
147
+ /* lost the steal race — someone else holds it */
148
+ }
149
+ return false;
150
+ }
151
+ }
@@ -34,6 +34,12 @@ export interface HarnessSpec {
34
34
  entryShape: HookEntryShape;
35
35
  /** When set, ensure this top-level `version` key in the file (Cursor requires `1`). */
36
36
  rootVersion?: number;
37
+ /** Harness-owned entries from older specs that `init` should remove during migration. */
38
+ legacyEvents?: HookEvent[];
39
+ /** Strict top-level settings keys accepted by this harness, when its parser is closed. */
40
+ allowedTopLevelKeys?: string[];
41
+ /** Strict hook event keys accepted by this harness, including events harnery does not wire. */
42
+ allowedEventKeys?: string[];
37
43
  }
38
44
 
39
45
  /** Claude Code: `.claude/settings.json`. */
@@ -67,20 +73,38 @@ export const CURSOR_EVENTS: HookEvent[] = [
67
73
  { settingsKey: "stop", subcommand: "stop" },
68
74
  ];
69
75
 
70
- /** Codex: `.codex/hooks.json`. PascalCase keys + the same entry shape as Claude Code. */
76
+ /** Codex events that harnery uses from the current native lifecycle surface. */
71
77
  export const CODEX_EVENTS: HookEvent[] = [
72
78
  { settingsKey: "SessionStart", subcommand: "session-start" },
73
- { settingsKey: "SessionEnd", subcommand: "session-end" },
74
79
  { settingsKey: "PreToolUse", subcommand: "pre-tool-use" },
75
80
  { settingsKey: "PostToolUse", subcommand: "post-tool-use" },
76
- { settingsKey: "PostToolUseFailure", subcommand: "post-tool-use-failure" },
77
81
  { settingsKey: "UserPromptSubmit", subcommand: "user-prompt-submit" },
78
82
  { settingsKey: "SubagentStart", subcommand: "sub-agent-start" },
79
83
  { settingsKey: "SubagentStop", subcommand: "sub-agent-stop" },
80
84
  { settingsKey: "Stop", subcommand: "stop" },
85
+ ];
86
+
87
+ /** Entries written by harnery before Codex adopted a strict native hook schema. */
88
+ export const LEGACY_CODEX_EVENTS: HookEvent[] = [
89
+ { settingsKey: "SessionEnd", subcommand: "session-end" },
90
+ { settingsKey: "PostToolUseFailure", subcommand: "post-tool-use-failure" },
81
91
  { settingsKey: "StopFailure", subcommand: "stop-failure" },
82
92
  ];
83
93
 
94
+ /** Every hook key accepted by Codex 0.144, including events harnery does not consume. */
95
+ export const CODEX_ALLOWED_EVENT_KEYS = [
96
+ "SessionStart",
97
+ "PreToolUse",
98
+ "PermissionRequest",
99
+ "PostToolUse",
100
+ "PreCompact",
101
+ "PostCompact",
102
+ "UserPromptSubmit",
103
+ "SubagentStart",
104
+ "SubagentStop",
105
+ "Stop",
106
+ ];
107
+
84
108
  /** Every supported harness, fully wireable by `harn init`. */
85
109
  export const HARNESS_SPECS: Record<HarnessId, HarnessSpec> = {
86
110
  "claude-code": {
@@ -98,5 +122,8 @@ export const HARNESS_SPECS: Record<HarnessId, HarnessSpec> = {
98
122
  settingsFile: ".codex/hooks.json",
99
123
  events: CODEX_EVENTS,
100
124
  entryShape: "claude",
125
+ legacyEvents: LEGACY_CODEX_EVENTS,
126
+ allowedTopLevelKeys: ["description", "hooks"],
127
+ allowedEventKeys: CODEX_ALLOWED_EVENT_KEYS,
101
128
  },
102
129
  };