harnery 0.31.0 → 0.31.2

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 (42) hide show
  1. package/dist/commands/agents.d.ts.map +1 -1
  2. package/dist/commands/agents.js +124 -14
  3. package/dist/commands/grep.d.ts +1 -1
  4. package/dist/commands/grep.d.ts.map +1 -1
  5. package/dist/commands/grep.js +19 -2
  6. package/dist/commands/tunnel.d.ts.map +1 -1
  7. package/dist/commands/tunnel.js +20 -4
  8. package/dist/core/agents/canonical-emit.d.ts +5 -10
  9. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  10. package/dist/core/agents/canonical-emit.js +10 -39
  11. package/dist/core/agents/coord-bin.d.ts +33 -0
  12. package/dist/core/agents/coord-bin.d.ts.map +1 -0
  13. package/dist/core/agents/coord-bin.js +89 -0
  14. package/dist/core/agents/coord-client.d.ts +57 -6
  15. package/dist/core/agents/coord-client.d.ts.map +1 -1
  16. package/dist/core/agents/coord-client.js +181 -48
  17. package/dist/core/agents/paths.d.ts +5 -2
  18. package/dist/core/agents/paths.d.ts.map +1 -1
  19. package/dist/core/agents/paths.js +7 -13
  20. package/dist/core/hooks/adapter/detect.d.ts +4 -2
  21. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  22. package/dist/core/hooks/adapter/detect.js +9 -4
  23. package/dist/core/hooks/cli.js +14 -13
  24. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  25. package/dist/core/hooks/effects/index.js +2 -2
  26. package/dist/core/hooks/resolve/coord-root.d.ts +16 -11
  27. package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
  28. package/dist/core/hooks/resolve/coord-root.js +18 -43
  29. package/dist/lib/context/index.js +9 -2
  30. package/package.json +1 -1
  31. package/src/commands/agents.ts +129 -14
  32. package/src/commands/grep.ts +19 -2
  33. package/src/commands/tunnel.ts +19 -4
  34. package/src/core/agents/canonical-emit.ts +10 -37
  35. package/src/core/agents/coord-bin.ts +92 -0
  36. package/src/core/agents/coord-client.ts +183 -50
  37. package/src/core/agents/paths.ts +7 -11
  38. package/src/core/hooks/adapter/detect.ts +9 -4
  39. package/src/core/hooks/cli.ts +14 -13
  40. package/src/core/hooks/effects/index.ts +2 -2
  41. package/src/core/hooks/resolve/coord-root.ts +18 -40
  42. package/src/lib/context/index.ts +11 -1
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { spawnSync } from "node:child_process";
11
11
  import { existsSync, readdirSync, readFileSync } from "node:fs";
12
- import { dirname, resolve, sep } from "node:path";
12
+ import { dirname, join, resolve, sep } from "node:path";
13
13
 
14
14
  // NOTE: kept dependency-free (node builtins only); this file is vendored verbatim into
15
15
  // a downstream consumer, so it cannot import the coordEnv helper.
@@ -86,57 +86,173 @@ export interface Heartbeat {
86
86
  /**
87
87
  * Resolve the monorepo root for coord-state purposes.
88
88
  *
89
- * Resolves the coordination root,
90
- * including the `--git-common-dir` fallback that strips
91
- * `<superproject>/.git/modules/<name>/` to recover the superproject for
92
- * submodule worktrees. Without that fallback, a TS coord caller running
93
- * inside a `harn worktree add-submodule` checkout would resolve a different
94
- * `.harnery/` than the bash hooks and the two layers would silently diverge.
89
+ * Thin alias for `resolveCoordRoot()`; kept because it is the name the CLI
90
+ * command modules and the vendored downstream consumer already import.
95
91
  */
96
92
  export function monorepoRoot(): string | null {
97
- // Test-only override (matches bash HARNERY_COORD_ROOT_OVERRIDE). Lets tests
98
- // seed `.harnery/pid-map/<pid>` + `.harnery/active/<owner>.json` fixtures
99
- // in a tmpdir without needing a real git repo.
93
+ return resolveCoordRoot();
94
+ }
95
+
96
+ /**
97
+ * THE coordination-root resolution. Every surface — the hooks, the CLI's reads,
98
+ * and the CLI's canonical emits — resolves through this one function, because a
99
+ * root the two layers disagree about is a root that silently breaks the
100
+ * end-of-turn rules: the hook evaluates `state.status_checked` from the stream
101
+ * it reads, so an emit into a different `.harnery/events.ndjson` is invisible
102
+ * and rule 1/3 blocks a turn that did run `agents status`, with no sequence of
103
+ * CLI commands able to satisfy it.
104
+ *
105
+ * Precedence:
106
+ * 1. `HARNERY_COORD_ROOT_OVERRIDE` — explicit pin (tests, git hooks, and the
107
+ * root every coord-helper spawn pins for its child).
108
+ * 2. `CLAUDE_PROJECT_DIR` — the adapter stating which project it opened. Hook
109
+ * processes inherit the session's *shell* cwd, which follows `cd` into a
110
+ * subdirectory or submodule that may carry a `.harnery/` of its own (or
111
+ * none at all), so the adapter's own statement outranks the cwd walk.
112
+ * 3. The candidate root that already holds THIS session's heartbeat.
113
+ * 4. The nearest enclosing `.harnery/`, then a git-derived root.
114
+ *
115
+ * Step 3 is what makes CLI/hook disagreement structurally impossible rather
116
+ * than a coin flip. Choosing either root unconditionally is wrong in one
117
+ * direction each: preferring the git superproject strands a session whose
118
+ * adapter opened the submodule itself (its heartbeat lives in the submodule,
119
+ * so status/set-task wrote events the hook never read), while walking up from
120
+ * cwd alone strands the opposite case, a session opened on the superproject
121
+ * whose shell has cd'd into a submodule that carries its own `.harnery/`
122
+ * (regression-tested in tests/unit/coord-helper-root-pin.test.ts). The session's
123
+ * own heartbeat settles it: whichever root the hook registered this session in
124
+ * is the root the CLI must use, and the adapter-exported session id needed to
125
+ * recognize it is available to a plain tool-call subprocess even though
126
+ * `CLAUDE_PROJECT_DIR` is not.
127
+ */
128
+ export function resolveCoordRoot(start: string = process.cwd()): string | null {
100
129
  const rootOverride = process.env.HARNERY_COORD_ROOT_OVERRIDE;
101
- if (rootOverride) {
102
- return rootOverride;
130
+ if (rootOverride) return rootOverride;
131
+
132
+ const projectDir = process.env.CLAUDE_PROJECT_DIR;
133
+ if (projectDir) {
134
+ const fromProject = nearestCoordRoot(projectDir);
135
+ if (fromProject) return fromProject;
103
136
  }
104
137
 
105
- // 1. Superproject working tree (when running from inside a submodule).
106
- const sup = spawnSync("git", ["rev-parse", "--show-superproject-working-tree"], {
107
- encoding: "utf8",
108
- });
109
- if (sup.status === 0 && sup.stdout.trim() !== "") {
110
- return sup.stdout.trim();
138
+ // Nearest-first, so the fallback at the end keeps the historical cwd-walk
139
+ // behavior for a shell that has no registered session at all.
140
+ const ancestors = ancestorCoordRoots(start);
141
+ for (const candidate of ancestors) {
142
+ if (rootKnowsSession(candidate)) return candidate;
111
143
  }
112
144
 
113
- // 2. --git-common-dir fallback for submodule worktrees. `git worktree add`
114
- // inside a submodule produces a worktree whose --show-superproject-working-
115
- // tree is empty (the worktree has no submodule relationship of its own),
116
- // but --git-common-dir points at <superproject>/.git/modules/<name>/, so
117
- // we recover the superproject by stripping that suffix.
118
- const common = spawnSync("git", ["rev-parse", "--git-common-dir"], {
119
- encoding: "utf8",
120
- });
121
- if (common.status === 0) {
122
- const cd = common.stdout.trim();
123
- const idx = cd.indexOf("/.git/modules/");
124
- if (idx !== -1) {
125
- return cd.substring(0, idx);
126
- }
145
+ // Only reached when no enclosing root knows this session: a submodule
146
+ // worktree (`harn worktree add-submodule`) has no ancestor relationship with
147
+ // the superproject that registered the session, so ask git for it.
148
+ const gitRoots = gitCoordRoots(start).filter((r) => !ancestors.includes(r));
149
+ for (const candidate of gitRoots) {
150
+ if (rootKnowsSession(candidate)) return candidate;
127
151
  }
128
152
 
129
- // 3. Top-level fallback (regular checkout).
130
- const top = spawnSync("git", ["rev-parse", "--show-toplevel"], {
131
- encoding: "utf8",
132
- });
133
- if (top.status === 0 && top.stdout.trim() !== "") {
134
- return top.stdout.trim();
153
+ if (ancestors.length > 0) return ancestors[0] as string;
154
+ if (gitRoots.length > 0) return gitRoots[0] as string;
155
+ // Nothing carries `.harnery/` yet. Hand back the enclosing checkout so a
156
+ // first run (`harn init`, agent-hook's session.start) has somewhere to
157
+ // create it.
158
+ return gitToplevel(start);
159
+ }
160
+
161
+ /** Nearest enclosing directory carrying `.harnery/`, or null. */
162
+ export function nearestCoordRoot(start: string): string | null {
163
+ return ancestorCoordRoots(start)[0] ?? null;
164
+ }
165
+
166
+ /** Every enclosing directory carrying `.harnery/`, nearest first. */
167
+ function ancestorCoordRoots(start: string): string[] {
168
+ const found: string[] = [];
169
+ let dir = resolve(start);
170
+ while (true) {
171
+ if (existsSync(join(dir, ".harnery"))) found.push(dir);
172
+ const parent = dirname(dir);
173
+ if (parent === dir) break;
174
+ dir = parent;
135
175
  }
176
+ return found;
177
+ }
136
178
 
137
- return null;
179
+ /**
180
+ * Does this root's `.harnery/` already know the process asking?
181
+ *
182
+ * Two discriminators, both genuinely about *this* session: the adapter-exported
183
+ * session id matching a live heartbeat, and a pid-map row on our own ppid
184
+ * chain. Deliberately NOT the single-live-agent fallback that owner resolution
185
+ * ends with — a lone stranger in the wrong root is exactly how `whoami` came to
186
+ * report another agent's name and task as its own.
187
+ */
188
+ export function rootKnowsSession(root: string): boolean {
189
+ if (resolveOwnerBySessionEnv(root)) return true;
190
+ return resolveOwnerByPidmap(root).owner !== null;
138
191
  }
139
192
 
193
+ /**
194
+ * Git-derived candidate roots for `start`, in preference order, filtered to
195
+ * those that actually carry `.harnery/`.
196
+ *
197
+ * Spawns run with `cwd: start` rather than the process cwd so an injected
198
+ * start dir resolves its own repository — without that, a call about some
199
+ * unrelated directory inherits this process's repo and can resolve a root that
200
+ * has nothing to do with the question asked.
201
+ */
202
+ function gitCoordRoots(start: string): string[] {
203
+ const roots: string[] = [];
204
+ const push = (value: string | null) => {
205
+ if (value && existsSync(join(value, ".harnery")) && !roots.includes(value)) roots.push(value);
206
+ };
207
+
208
+ // Superproject working tree (running from inside a submodule).
209
+ push(gitRevParse(start, "--show-superproject-working-tree"));
210
+
211
+ // `--git-common-dir` fallback for submodule worktrees: `git worktree add`
212
+ // inside a submodule produces a worktree whose superproject working tree is
213
+ // empty (the worktree has no submodule relationship of its own), but the
214
+ // common dir points at `<superproject>/.git/modules/<name>/`, so the
215
+ // superproject is recoverable by stripping that suffix.
216
+ const common = gitRevParse(start, "--git-common-dir");
217
+ if (common) {
218
+ const idx = common.indexOf("/.git/modules/");
219
+ if (idx !== -1) push(common.substring(0, idx));
220
+ }
221
+
222
+ // Top-level (regular checkout).
223
+ push(gitRevParse(start, "--show-toplevel"));
224
+ return roots;
225
+ }
226
+
227
+ function gitToplevel(start: string): string | null {
228
+ return gitRevParse(start, "--show-toplevel");
229
+ }
230
+
231
+ function gitRevParse(cwd: string, flag: string): string | null {
232
+ const key = `${cwd}${flag}`;
233
+ const cached = gitRevParseCache.get(key);
234
+ if (cached !== undefined) return cached;
235
+ let value: string | null = null;
236
+ try {
237
+ // `cwd` must exist or the spawn itself fails; callers pass paths that may
238
+ // not (a project dir from a stale env var), so treat any failure as "no
239
+ // answer" rather than letting it throw.
240
+ const r = spawnSync("git", ["rev-parse", flag], { encoding: "utf8", cwd });
241
+ if (r.status === 0) value = r.stdout.trim() || null;
242
+ } catch {
243
+ value = null;
244
+ }
245
+ gitRevParseCache.set(key, value);
246
+ return value;
247
+ }
248
+
249
+ /**
250
+ * Memoized because git spawns are the expensive part of resolution and every
251
+ * CLI command resolves the root many times per invocation. Keyed by (cwd, flag);
252
+ * a repository's identity does not change under a running process.
253
+ */
254
+ const gitRevParseCache = new Map<string, string | null>();
255
+
140
256
  /** Parse owner from a pid-map row (`owner` or `owner\tplatform`). */
141
257
  export function parsePidmapRowOwner(row: string): string {
142
258
  const trimmed = row.trim();
@@ -296,6 +412,34 @@ export function resolveOwnerWithSource(): {
296
412
  }
297
413
  }
298
414
 
415
+ if (!existsSync(resolve(root, ".harnery", "pid-map"))) return { owner: null, source: "none" };
416
+
417
+ const byPidmap = resolveOwnerByPidmap(root);
418
+ if (byPidmap.owner) return byPidmap;
419
+
420
+ // Last resort: if exactly one agent is live in this coord root, it's
421
+ // unambiguously us — resolve to it. This is what lets the bare `agents
422
+ // status` / `set-task` the stop hook recommends work without a `--session-id`
423
+ // flag in the common single-agent case. With 0 or 2+ live agents it would be
424
+ // a guess, so we stay null and require the explicit flag.
425
+ const singleton = resolveSingleActiveOwner(root);
426
+ if (singleton) {
427
+ return { owner: singleton, source: "active_singleton" };
428
+ }
429
+
430
+ return { owner: null, source: "none" };
431
+ }
432
+
433
+ /**
434
+ * Walk our own ppid chain for a pid-map row in ONE given root.
435
+ *
436
+ * Root-parameterized (rather than resolving the root itself) so root resolution
437
+ * can use it as a discriminator without recursing back into itself.
438
+ */
439
+ export function resolveOwnerByPidmap(root: string): {
440
+ owner: string | null;
441
+ source: "pidmap" | "pidmap_fallback" | "none";
442
+ } {
299
443
  const pidmapDir = resolve(root, ".harnery", "pid-map");
300
444
  if (!existsSync(pidmapDir)) return { owner: null, source: "none" };
301
445
 
@@ -319,17 +463,6 @@ export function resolveOwnerWithSource(): {
319
463
  if (fallbackOwner) {
320
464
  return { owner: fallbackOwner, source: "pidmap_fallback" };
321
465
  }
322
-
323
- // Last resort: if exactly one agent is live in this coord root, it's
324
- // unambiguously us — resolve to it. This is what lets the bare `agents
325
- // status` / `set-task` the stop hook recommends work without a `--session-id`
326
- // flag in the common single-agent case. With 0 or 2+ live agents it would be
327
- // a guess, so we stay null and require the explicit flag.
328
- const singleton = resolveSingleActiveOwner(root);
329
- if (singleton) {
330
- return { owner: singleton, source: "active_singleton" };
331
- }
332
-
333
466
  return { owner: null, source: "none" };
334
467
  }
335
468
 
@@ -1,16 +1,12 @@
1
- import { existsSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
1
+ import { resolveCoordRoot } from "./coord-client.ts";
3
2
 
4
3
  /**
5
- * Walk up from `start` looking for a directory containing `.harnery/`, so
6
- * adapter + legacy code agree on the same monorepo root.
4
+ * Resolve the coordination root.
5
+ *
6
+ * Delegates to `resolveCoordRoot()`. This was a third, independent copy of the
7
+ * walk-up — the kind of duplication that let the hooks and the CLI drift onto
8
+ * different roots in the first place; keep resolution in one place.
7
9
  */
8
10
  export function findCoordRoot(start: string = process.cwd()): string | null {
9
- let dir = resolve(start);
10
- while (true) {
11
- if (existsSync(join(dir, ".harnery"))) return dir;
12
- const parent = dirname(dir);
13
- if (parent === dir) return null;
14
- dir = parent;
15
- }
11
+ return resolveCoordRoot(start);
16
12
  }
@@ -3,21 +3,26 @@ import type { Adapter } from "../events/schema.ts";
3
3
 
4
4
  /**
5
5
  * Resolve the adapter firing the hook. The dispatcher binary is always
6
- * invoked with `--adapter <name>` per the wiring in each adapter's settings
7
- * file (Phase 1). Returns null when the flag is missing; caller falls
6
+ * invoked with `--adapter <name>` per the current wiring in each adapter's
7
+ * settings file. `--harness` remains an accepted alias because settings files
8
+ * written by older releases survive a package upgrade until the consumer
9
+ * re-runs `harn init`. Returns null when neither flag is present; caller falls
8
10
  * through to the env-based fallback or skips emission.
9
11
  */
10
12
  export function detectAdapter(argv: readonly string[]): Adapter | null {
11
13
  for (let i = 0; i < argv.length; i++) {
12
14
  const a = argv[i]!;
13
- if (a === "--adapter") {
15
+ if (a === "--adapter" || a === "--harness") {
14
16
  return validate(argv[i + 1]);
15
17
  }
16
18
  if (a.startsWith("--adapter=")) {
17
19
  return validate(a.slice("--adapter=".length));
18
20
  }
21
+ if (a.startsWith("--harness=")) {
22
+ return validate(a.slice("--harness=".length));
23
+ }
19
24
  }
20
- return validate(coordEnv("AGENT_COORD_ADAPTER"));
25
+ return validate(coordEnv("AGENT_COORD_ADAPTER") ?? coordEnv("AGENT_COORD_HARNESS"));
21
26
  }
22
27
 
23
28
  function validate(v: string | undefined): Adapter | null {
@@ -23,6 +23,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
23
23
  import { dirname, join } from "node:path";
24
24
  import { coordEnv } from "../../lib/env.ts";
25
25
  import { replayCodexJsonl } from "../agents/codex-replay.ts";
26
+ import { coordBinPath } from "../agents/coord-bin.ts";
26
27
  import { consumeSince, writeCursor } from "../agents/events/consume.ts";
27
28
  import { evaluateStopHook } from "../agents/rules/stop-hook.ts";
28
29
  import { projectHeartbeats } from "../agents/state/heartbeat-projector.ts";
@@ -79,11 +80,11 @@ function parseArgv(argv: string[]): Argv {
79
80
  const out: Argv = { eventName: null, extra: [] };
80
81
  for (let i = 0; i < argv.length; i++) {
81
82
  const arg = argv[i]!;
82
- if (arg === "--adapter") {
83
+ if (arg === "--adapter" || arg === "--harness") {
83
84
  i++; // detectAdapter will re-parse; just consume the value here.
84
85
  continue;
85
86
  }
86
- if (arg.startsWith("--adapter=")) continue;
87
+ if (arg.startsWith("--adapter=") || arg.startsWith("--harness=")) continue;
87
88
  if (!out.eventName && !arg.startsWith("--")) {
88
89
  out.eventName = arg;
89
90
  } else {
@@ -160,7 +161,7 @@ function assignNameViaAgentCoord(
160
161
  instanceId: string,
161
162
  kind: "session" | "subagent" | "transient",
162
163
  ): { name: string; kind: string } | null {
163
- const binary = join(coordRoot, "harnery", "bin", "agent-coord");
164
+ const binary = coordBinPath("agent-coord", coordRoot) ?? "";
164
165
  if (!existsSync(binary)) return null;
165
166
  try {
166
167
  const result = spawnSync(binary, ["assign-name", instanceId, kind], {
@@ -719,7 +720,7 @@ async function main(): Promise<number> {
719
720
  // subagent (claude-code + cursor; codex doesn't fan out subagents today).
720
721
  if (norm.event_type === "subagent.start") {
721
722
  try {
722
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
723
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
723
724
  if (existsSync(agentCoordBin)) {
724
725
  spawnSync(agentCoordBin, ["project"], {
725
726
  encoding: "utf8",
@@ -747,7 +748,7 @@ async function main(): Promise<number> {
747
748
  if (norm.event_type === "subagent.stop") {
748
749
  try {
749
750
  cleanupSessionEnd(coordRoot, owner.instance_id, (data.reason as string) ?? "unknown");
750
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
751
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
751
752
  if (existsSync(agentCoordBin)) {
752
753
  spawnSync(
753
754
  agentCoordBin,
@@ -1037,7 +1038,7 @@ async function runPreToolUseGuard(
1037
1038
  .filter((p): p is string => p !== null);
1038
1039
  if (targets.length === 0) return;
1039
1040
 
1040
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1041
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1041
1042
  if (!existsSync(agentCoordBin)) return;
1042
1043
 
1043
1044
  // For apply_patch (multi-file), collect siblings so the deny reason names
@@ -1142,7 +1143,7 @@ function healHeartbeatViaCli(
1142
1143
  sessionId: string,
1143
1144
  adapter: string,
1144
1145
  ): void {
1145
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1146
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1146
1147
  if (!existsSync(agentCoordBin)) return;
1147
1148
  // Pass the detected adapter so a pruned Cursor/Codex heartbeat is recreated
1148
1149
  // with the correct platform; without it, healHeartbeat defaults to
@@ -1255,7 +1256,7 @@ function stampToolActivity(
1255
1256
  instanceId: string,
1256
1257
  data: Record<string, unknown>,
1257
1258
  ): void {
1258
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1259
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1259
1260
  if (!existsSync(agentCoordBin)) return;
1260
1261
  const toolName = (data.tool_name as string | undefined) ?? "";
1261
1262
  // Extract a 1-line target from the tool_input blob (file path / command head).
@@ -1327,7 +1328,7 @@ function releaseClaimOnFailure(
1327
1328
  : filePath;
1328
1329
  }
1329
1330
 
1330
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1331
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1331
1332
  if (!existsSync(agentCoordBin)) return;
1332
1333
  spawnSync(agentCoordBin, ["release-claim", instanceId, canonical], {
1333
1334
  encoding: "utf8",
@@ -1368,7 +1369,7 @@ function cleanupSessionEnd(coordRoot: string, instanceId: string, reason: string
1368
1369
  }
1369
1370
  }
1370
1371
  // Activity log
1371
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1372
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1372
1373
  if (existsSync(agentCoordBin)) {
1373
1374
  spawnSync(
1374
1375
  agentCoordBin,
@@ -1385,7 +1386,7 @@ async function emitUserPromptSubmitSystemMessage(
1385
1386
  adapter: Adapter,
1386
1387
  recoveryBriefing = "",
1387
1388
  ): Promise<boolean> {
1388
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1389
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1389
1390
  let additionalContext = "";
1390
1391
 
1391
1392
  if (existsSync(agentCoordBin)) {
@@ -1445,7 +1446,7 @@ function emitSubagentStartContext(
1445
1446
  // Render peer table inline since the subagent might want to know who else
1446
1447
  // is around. Reuse prompt-context (which dedups against the per-owner hash);
1447
1448
  // first call will always emit.
1448
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1449
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1449
1450
  let combined = message;
1450
1451
  if (existsSync(agentCoordBin)) {
1451
1452
  const result = spawnSync(
@@ -1477,7 +1478,7 @@ async function emitSessionStartSystemMessage(
1477
1478
  adapter: Adapter,
1478
1479
  recoveryBriefing = "",
1479
1480
  ): Promise<boolean> {
1480
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1481
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1481
1482
  let additionalContext = "";
1482
1483
  if (existsSync(agentCoordBin)) {
1483
1484
  // Sync-project so the heartbeat exists for downstream readers (peer table,
@@ -17,6 +17,7 @@ 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 { coordBinPath } from "../../agents/coord-bin.ts";
20
21
 
21
22
  export type { CaptureContext } from "./image-capture.ts";
22
23
  export { captureImages, imageJanitor } from "./image-capture.ts";
@@ -62,8 +63,7 @@ export function soundForEvent(eventName: string): { sound: string; maxPlays: num
62
63
  }
63
64
 
64
65
  function harnBin(repoRoot: string): string | null {
65
- const bin = join(repoRoot, "harnery", "bin", "harn");
66
- return existsSync(bin) ? bin : null;
66
+ return coordBinPath("harn", repoRoot);
67
67
  }
68
68
 
69
69
  /** Prune stale journal archives + sweep orphans (global, fast). Fire-and-forget. */
@@ -1,47 +1,25 @@
1
- import { existsSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
3
- import { coordEnv } from "../../../lib/env.ts";
1
+ import { resolveCoordRoot } from "../../agents/coord-client.ts";
4
2
 
5
3
  /**
6
- * Walk up from `start` looking for a directory containing `.harnery/`. The
7
- * single coord-root resolution so every adapter agrees on the same root.
4
+ * The hooks-side name for THE coordination-root resolution.
8
5
  *
9
- * Resolution precedence:
6
+ * Delegates to `resolveCoordRoot()` so the hooks and the CLI cannot resolve
7
+ * different roots. They used to: this walked up from `CLAUDE_PROJECT_DIR` or
8
+ * cwd while the CLI's `monorepoRoot()` asked git for the superproject first, so
9
+ * with a shell inside a submodule that carries its own `.harnery/`, the CLI's
10
+ * `state.status_checked` landed in a stream this side never read and rule 1/3
11
+ * blocked every turn.
12
+ *
13
+ * Resolution precedence (see `resolveCoordRoot` for the full rationale):
10
14
  * 1. HARNERY_COORD_ROOT_OVERRIDE — explicit pin (tests, git hooks).
11
- * 2. The adapter's project dir (CLAUDE_PROJECT_DIR) — hook processes
12
- * inherit the session's *shell* cwd, which follows `cd` into
13
- * subdirectories/submodules that may carry a `.harnery/` of their own
14
- * (or none at all, e.g. a journal dir under /tmp). The session's
15
- * coordination home is the project the adapter opened, not wherever the
16
- * shell happens to sit, so a project dir that resolves to a coord root
17
- * wins over the cwd walk.
18
- * 3. Walk up from `start` (default cwd).
15
+ * 2. The adapter's project dir (CLAUDE_PROJECT_DIR) — hook processes inherit
16
+ * the session's *shell* cwd, which follows `cd` into subdirectories or
17
+ * submodules that may carry a `.harnery/` of their own (or none at all,
18
+ * e.g. a journal dir under /tmp). The session's coordination home is the
19
+ * project the adapter opened, not wherever the shell happens to sit.
20
+ * 3. The root that already holds this session's heartbeat.
21
+ * 4. The nearest enclosing `.harnery/`, then a git-derived root.
19
22
  */
20
23
  export function findCoordRoot(start: string = process.cwd()): string | null {
21
- // HARNERY_COORD_ROOT_OVERRIDE: explicit pin matched by agent-coord and the
22
- // bash side. Lets the sandboxed coord-test suite run against a temp
23
- // directory rather than the real monorepo root. agent-hook's session.start
24
- // handler may be the FIRST thing to create .harnery/ in a fresh sandbox,
25
- // so the override is honored unconditionally (we don't require .harnery/
26
- // to pre-exist).
27
- const override = coordEnv("COORD_ROOT_OVERRIDE");
28
- if (override) return override;
29
- // Claude Code exports CLAUDE_PROJECT_DIR to every hook process; other
30
- // adapters don't set it, so this is inert outside claude-code hooks.
31
- const projectDir = process.env.CLAUDE_PROJECT_DIR;
32
- if (projectDir) {
33
- const fromProject = walkUp(projectDir);
34
- if (fromProject) return fromProject;
35
- }
36
- return walkUp(start);
37
- }
38
-
39
- function walkUp(start: string): string | null {
40
- let dir = resolve(start);
41
- while (true) {
42
- if (existsSync(join(dir, ".harnery"))) return dir;
43
- const parent = dirname(dir);
44
- if (parent === dir) return null;
45
- dir = parent;
46
- }
24
+ return resolveCoordRoot(start);
47
25
  }
@@ -156,7 +156,17 @@ function buildSelf(): SelfSection {
156
156
  const owner = resolveOwner();
157
157
  if (!owner) throw new Error("not in an agent session (no pid-map entry)");
158
158
  const hb = readHeartbeat(owner);
159
- if (!hb) throw new Error(`pid-map resolved owner=${owner.slice(0, 8)}… but no heartbeat`);
159
+ // Full id, never abbreviated: this message exists to hand the reader an id to
160
+ // pass back (to `agents heal --owner`, say), and an 8-char prefix is not that
161
+ // id. Healing the truncated form writes `.harnery/active/<prefix>.json`, a
162
+ // heartbeat no reader ever resolves — so the session still looks unhealable
163
+ // and now has a junk file shadowing it. Same rule as `noHeartbeatMessage` in
164
+ // the agents command.
165
+ if (!hb) {
166
+ throw new Error(
167
+ `pid-map resolved owner=${owner} but no heartbeat exists at .harnery/active/${owner}.json`,
168
+ );
169
+ }
160
170
  const startedMs = Date.parse(hb.started_at);
161
171
  const ageSecs = Number.isFinite(startedMs)
162
172
  ? Math.max(0, Math.floor((Date.now() - startedMs) / 1000))