harnery 0.31.0 → 0.31.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 (38) 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/cli.js +12 -11
  21. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  22. package/dist/core/hooks/effects/index.js +2 -2
  23. package/dist/core/hooks/resolve/coord-root.d.ts +16 -11
  24. package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
  25. package/dist/core/hooks/resolve/coord-root.js +18 -43
  26. package/dist/lib/context/index.js +9 -2
  27. package/package.json +1 -1
  28. package/src/commands/agents.ts +129 -14
  29. package/src/commands/grep.ts +19 -2
  30. package/src/commands/tunnel.ts +19 -4
  31. package/src/core/agents/canonical-emit.ts +10 -37
  32. package/src/core/agents/coord-bin.ts +92 -0
  33. package/src/core/agents/coord-client.ts +183 -50
  34. package/src/core/agents/paths.ts +7 -11
  35. package/src/core/hooks/cli.ts +12 -11
  36. package/src/core/hooks/effects/index.ts +2 -2
  37. package/src/core/hooks/resolve/coord-root.ts +18 -40
  38. package/src/lib/context/index.ts +11 -1
@@ -50,14 +50,55 @@ export interface Heartbeat {
50
50
  /**
51
51
  * Resolve the monorepo root for coord-state purposes.
52
52
  *
53
- * Resolves the coordination root,
54
- * including the `--git-common-dir` fallback that strips
55
- * `<superproject>/.git/modules/<name>/` to recover the superproject for
56
- * submodule worktrees. Without that fallback, a TS coord caller running
57
- * inside a `harn worktree add-submodule` checkout would resolve a different
58
- * `.harnery/` than the bash hooks and the two layers would silently diverge.
53
+ * Thin alias for `resolveCoordRoot()`; kept because it is the name the CLI
54
+ * command modules and the vendored downstream consumer already import.
59
55
  */
60
56
  export declare function monorepoRoot(): string | null;
57
+ /**
58
+ * THE coordination-root resolution. Every surface — the hooks, the CLI's reads,
59
+ * and the CLI's canonical emits — resolves through this one function, because a
60
+ * root the two layers disagree about is a root that silently breaks the
61
+ * end-of-turn rules: the hook evaluates `state.status_checked` from the stream
62
+ * it reads, so an emit into a different `.harnery/events.ndjson` is invisible
63
+ * and rule 1/3 blocks a turn that did run `agents status`, with no sequence of
64
+ * CLI commands able to satisfy it.
65
+ *
66
+ * Precedence:
67
+ * 1. `HARNERY_COORD_ROOT_OVERRIDE` — explicit pin (tests, git hooks, and the
68
+ * root every coord-helper spawn pins for its child).
69
+ * 2. `CLAUDE_PROJECT_DIR` — the adapter stating which project it opened. Hook
70
+ * processes inherit the session's *shell* cwd, which follows `cd` into a
71
+ * subdirectory or submodule that may carry a `.harnery/` of its own (or
72
+ * none at all), so the adapter's own statement outranks the cwd walk.
73
+ * 3. The candidate root that already holds THIS session's heartbeat.
74
+ * 4. The nearest enclosing `.harnery/`, then a git-derived root.
75
+ *
76
+ * Step 3 is what makes CLI/hook disagreement structurally impossible rather
77
+ * than a coin flip. Choosing either root unconditionally is wrong in one
78
+ * direction each: preferring the git superproject strands a session whose
79
+ * adapter opened the submodule itself (its heartbeat lives in the submodule,
80
+ * so status/set-task wrote events the hook never read), while walking up from
81
+ * cwd alone strands the opposite case, a session opened on the superproject
82
+ * whose shell has cd'd into a submodule that carries its own `.harnery/`
83
+ * (regression-tested in tests/unit/coord-helper-root-pin.test.ts). The session's
84
+ * own heartbeat settles it: whichever root the hook registered this session in
85
+ * is the root the CLI must use, and the adapter-exported session id needed to
86
+ * recognize it is available to a plain tool-call subprocess even though
87
+ * `CLAUDE_PROJECT_DIR` is not.
88
+ */
89
+ export declare function resolveCoordRoot(start?: string): string | null;
90
+ /** Nearest enclosing directory carrying `.harnery/`, or null. */
91
+ export declare function nearestCoordRoot(start: string): string | null;
92
+ /**
93
+ * Does this root's `.harnery/` already know the process asking?
94
+ *
95
+ * Two discriminators, both genuinely about *this* session: the adapter-exported
96
+ * session id matching a live heartbeat, and a pid-map row on our own ppid
97
+ * chain. Deliberately NOT the single-live-agent fallback that owner resolution
98
+ * ends with — a lone stranger in the wrong root is exactly how `whoami` came to
99
+ * report another agent's name and task as its own.
100
+ */
101
+ export declare function rootKnowsSession(root: string): boolean;
61
102
  /** Parse owner from a pid-map row (`owner` or `owner\tplatform`). */
62
103
  export declare function parsePidmapRowOwner(row: string): string;
63
104
  /** Parse platform from a pid-map row; legacy rows default to `claude_code`. */
@@ -105,6 +146,16 @@ export declare function resolveOwnerWithSource(): {
105
146
  owner: string | null;
106
147
  source: "env" | "pidmap" | "pidmap_fallback" | "session_env" | "active_singleton" | "none";
107
148
  };
149
+ /**
150
+ * Walk our own ppid chain for a pid-map row in ONE given root.
151
+ *
152
+ * Root-parameterized (rather than resolving the root itself) so root resolution
153
+ * can use it as a discriminator without recursing back into itself.
154
+ */
155
+ export declare function resolveOwnerByPidmap(root: string): {
156
+ owner: string | null;
157
+ source: "pidmap" | "pidmap_fallback" | "none";
158
+ };
108
159
  /**
109
160
  * Resolve the owner by matching the adapter session-id env var against the
110
161
  * `session_id` of a live heartbeat in this coord root. Returns the matching
@@ -1 +1 @@
1
- {"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAWH;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAWhE;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,MAAM,CAI5E;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAOhF;AAED,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oGAAoG;IACpG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yIAAyI;IACzI,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,mEAAmE;IACnE,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CA0C5C;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,mFAAmF;AACnF,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA2CxD;AAgCD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,aAAa,GAAG,kBAAkB,GAAG,MAAM,CAAC;CAC5F,CA2DA;AA0DD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA2BpE;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyBpE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAMlE;AAgBD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAOtD;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAIxC"}
1
+ {"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"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"}
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { existsSync, readdirSync, readFileSync } from "node:fs";
11
- import { dirname, resolve, sep } from "node:path";
11
+ import { dirname, join, resolve, sep } from "node:path";
12
12
  // NOTE: kept dependency-free (node builtins only); this file is vendored verbatim into
13
13
  // a downstream consumer, so it cannot import the coordEnv helper.
14
14
  const MAX_INSTANCE_ID_LENGTH = 128;
@@ -53,52 +53,171 @@ export function resolveContainedFile(directory, fileName) {
53
53
  /**
54
54
  * Resolve the monorepo root for coord-state purposes.
55
55
  *
56
- * Resolves the coordination root,
57
- * including the `--git-common-dir` fallback that strips
58
- * `<superproject>/.git/modules/<name>/` to recover the superproject for
59
- * submodule worktrees. Without that fallback, a TS coord caller running
60
- * inside a `harn worktree add-submodule` checkout would resolve a different
61
- * `.harnery/` than the bash hooks and the two layers would silently diverge.
56
+ * Thin alias for `resolveCoordRoot()`; kept because it is the name the CLI
57
+ * command modules and the vendored downstream consumer already import.
62
58
  */
63
59
  export function monorepoRoot() {
64
- // Test-only override (matches bash HARNERY_COORD_ROOT_OVERRIDE). Lets tests
65
- // seed `.harnery/pid-map/<pid>` + `.harnery/active/<owner>.json` fixtures
66
- // in a tmpdir without needing a real git repo.
60
+ return resolveCoordRoot();
61
+ }
62
+ /**
63
+ * THE coordination-root resolution. Every surface — the hooks, the CLI's reads,
64
+ * and the CLI's canonical emits — resolves through this one function, because a
65
+ * root the two layers disagree about is a root that silently breaks the
66
+ * end-of-turn rules: the hook evaluates `state.status_checked` from the stream
67
+ * it reads, so an emit into a different `.harnery/events.ndjson` is invisible
68
+ * and rule 1/3 blocks a turn that did run `agents status`, with no sequence of
69
+ * CLI commands able to satisfy it.
70
+ *
71
+ * Precedence:
72
+ * 1. `HARNERY_COORD_ROOT_OVERRIDE` — explicit pin (tests, git hooks, and the
73
+ * root every coord-helper spawn pins for its child).
74
+ * 2. `CLAUDE_PROJECT_DIR` — the adapter stating which project it opened. Hook
75
+ * processes inherit the session's *shell* cwd, which follows `cd` into a
76
+ * subdirectory or submodule that may carry a `.harnery/` of its own (or
77
+ * none at all), so the adapter's own statement outranks the cwd walk.
78
+ * 3. The candidate root that already holds THIS session's heartbeat.
79
+ * 4. The nearest enclosing `.harnery/`, then a git-derived root.
80
+ *
81
+ * Step 3 is what makes CLI/hook disagreement structurally impossible rather
82
+ * than a coin flip. Choosing either root unconditionally is wrong in one
83
+ * direction each: preferring the git superproject strands a session whose
84
+ * adapter opened the submodule itself (its heartbeat lives in the submodule,
85
+ * so status/set-task wrote events the hook never read), while walking up from
86
+ * cwd alone strands the opposite case, a session opened on the superproject
87
+ * whose shell has cd'd into a submodule that carries its own `.harnery/`
88
+ * (regression-tested in tests/unit/coord-helper-root-pin.test.ts). The session's
89
+ * own heartbeat settles it: whichever root the hook registered this session in
90
+ * is the root the CLI must use, and the adapter-exported session id needed to
91
+ * recognize it is available to a plain tool-call subprocess even though
92
+ * `CLAUDE_PROJECT_DIR` is not.
93
+ */
94
+ export function resolveCoordRoot(start = process.cwd()) {
67
95
  const rootOverride = process.env.HARNERY_COORD_ROOT_OVERRIDE;
68
- if (rootOverride) {
96
+ if (rootOverride)
69
97
  return rootOverride;
98
+ const projectDir = process.env.CLAUDE_PROJECT_DIR;
99
+ if (projectDir) {
100
+ const fromProject = nearestCoordRoot(projectDir);
101
+ if (fromProject)
102
+ return fromProject;
103
+ }
104
+ // Nearest-first, so the fallback at the end keeps the historical cwd-walk
105
+ // behavior for a shell that has no registered session at all.
106
+ const ancestors = ancestorCoordRoots(start);
107
+ for (const candidate of ancestors) {
108
+ if (rootKnowsSession(candidate))
109
+ return candidate;
110
+ }
111
+ // Only reached when no enclosing root knows this session: a submodule
112
+ // worktree (`harn worktree add-submodule`) has no ancestor relationship with
113
+ // the superproject that registered the session, so ask git for it.
114
+ const gitRoots = gitCoordRoots(start).filter((r) => !ancestors.includes(r));
115
+ for (const candidate of gitRoots) {
116
+ if (rootKnowsSession(candidate))
117
+ return candidate;
118
+ }
119
+ if (ancestors.length > 0)
120
+ return ancestors[0];
121
+ if (gitRoots.length > 0)
122
+ return gitRoots[0];
123
+ // Nothing carries `.harnery/` yet. Hand back the enclosing checkout so a
124
+ // first run (`harn init`, agent-hook's session.start) has somewhere to
125
+ // create it.
126
+ return gitToplevel(start);
127
+ }
128
+ /** Nearest enclosing directory carrying `.harnery/`, or null. */
129
+ export function nearestCoordRoot(start) {
130
+ return ancestorCoordRoots(start)[0] ?? null;
131
+ }
132
+ /** Every enclosing directory carrying `.harnery/`, nearest first. */
133
+ function ancestorCoordRoots(start) {
134
+ const found = [];
135
+ let dir = resolve(start);
136
+ while (true) {
137
+ if (existsSync(join(dir, ".harnery")))
138
+ found.push(dir);
139
+ const parent = dirname(dir);
140
+ if (parent === dir)
141
+ break;
142
+ dir = parent;
70
143
  }
71
- // 1. Superproject working tree (when running from inside a submodule).
72
- const sup = spawnSync("git", ["rev-parse", "--show-superproject-working-tree"], {
73
- encoding: "utf8",
74
- });
75
- if (sup.status === 0 && sup.stdout.trim() !== "") {
76
- return sup.stdout.trim();
77
- }
78
- // 2. --git-common-dir fallback for submodule worktrees. `git worktree add`
79
- // inside a submodule produces a worktree whose --show-superproject-working-
80
- // tree is empty (the worktree has no submodule relationship of its own),
81
- // but --git-common-dir points at <superproject>/.git/modules/<name>/, so
82
- // we recover the superproject by stripping that suffix.
83
- const common = spawnSync("git", ["rev-parse", "--git-common-dir"], {
84
- encoding: "utf8",
85
- });
86
- if (common.status === 0) {
87
- const cd = common.stdout.trim();
88
- const idx = cd.indexOf("/.git/modules/");
89
- if (idx !== -1) {
90
- return cd.substring(0, idx);
91
- }
144
+ return found;
145
+ }
146
+ /**
147
+ * Does this root's `.harnery/` already know the process asking?
148
+ *
149
+ * Two discriminators, both genuinely about *this* session: the adapter-exported
150
+ * session id matching a live heartbeat, and a pid-map row on our own ppid
151
+ * chain. Deliberately NOT the single-live-agent fallback that owner resolution
152
+ * ends with — a lone stranger in the wrong root is exactly how `whoami` came to
153
+ * report another agent's name and task as its own.
154
+ */
155
+ export function rootKnowsSession(root) {
156
+ if (resolveOwnerBySessionEnv(root))
157
+ return true;
158
+ return resolveOwnerByPidmap(root).owner !== null;
159
+ }
160
+ /**
161
+ * Git-derived candidate roots for `start`, in preference order, filtered to
162
+ * those that actually carry `.harnery/`.
163
+ *
164
+ * Spawns run with `cwd: start` rather than the process cwd so an injected
165
+ * start dir resolves its own repository — without that, a call about some
166
+ * unrelated directory inherits this process's repo and can resolve a root that
167
+ * has nothing to do with the question asked.
168
+ */
169
+ function gitCoordRoots(start) {
170
+ const roots = [];
171
+ const push = (value) => {
172
+ if (value && existsSync(join(value, ".harnery")) && !roots.includes(value))
173
+ roots.push(value);
174
+ };
175
+ // Superproject working tree (running from inside a submodule).
176
+ push(gitRevParse(start, "--show-superproject-working-tree"));
177
+ // `--git-common-dir` fallback for submodule worktrees: `git worktree add`
178
+ // inside a submodule produces a worktree whose superproject working tree is
179
+ // empty (the worktree has no submodule relationship of its own), but the
180
+ // common dir points at `<superproject>/.git/modules/<name>/`, so the
181
+ // superproject is recoverable by stripping that suffix.
182
+ const common = gitRevParse(start, "--git-common-dir");
183
+ if (common) {
184
+ const idx = common.indexOf("/.git/modules/");
185
+ if (idx !== -1)
186
+ push(common.substring(0, idx));
187
+ }
188
+ // Top-level (regular checkout).
189
+ push(gitRevParse(start, "--show-toplevel"));
190
+ return roots;
191
+ }
192
+ function gitToplevel(start) {
193
+ return gitRevParse(start, "--show-toplevel");
194
+ }
195
+ function gitRevParse(cwd, flag) {
196
+ const key = `${cwd}${flag}`;
197
+ const cached = gitRevParseCache.get(key);
198
+ if (cached !== undefined)
199
+ return cached;
200
+ let value = null;
201
+ try {
202
+ // `cwd` must exist or the spawn itself fails; callers pass paths that may
203
+ // not (a project dir from a stale env var), so treat any failure as "no
204
+ // answer" rather than letting it throw.
205
+ const r = spawnSync("git", ["rev-parse", flag], { encoding: "utf8", cwd });
206
+ if (r.status === 0)
207
+ value = r.stdout.trim() || null;
92
208
  }
93
- // 3. Top-level fallback (regular checkout).
94
- const top = spawnSync("git", ["rev-parse", "--show-toplevel"], {
95
- encoding: "utf8",
96
- });
97
- if (top.status === 0 && top.stdout.trim() !== "") {
98
- return top.stdout.trim();
209
+ catch {
210
+ value = null;
99
211
  }
100
- return null;
212
+ gitRevParseCache.set(key, value);
213
+ return value;
101
214
  }
215
+ /**
216
+ * Memoized because git spawns are the expensive part of resolution and every
217
+ * CLI command resolves the root many times per invocation. Keyed by (cwd, flag);
218
+ * a repository's identity does not change under a running process.
219
+ */
220
+ const gitRevParseCache = new Map();
102
221
  /** Parse owner from a pid-map row (`owner` or `owner\tplatform`). */
103
222
  export function parsePidmapRowOwner(row) {
104
223
  const trimmed = row.trim();
@@ -255,6 +374,29 @@ export function resolveOwnerWithSource() {
255
374
  return { owner: bySession, source: "session_env" };
256
375
  }
257
376
  }
377
+ if (!existsSync(resolve(root, ".harnery", "pid-map")))
378
+ return { owner: null, source: "none" };
379
+ const byPidmap = resolveOwnerByPidmap(root);
380
+ if (byPidmap.owner)
381
+ return byPidmap;
382
+ // Last resort: if exactly one agent is live in this coord root, it's
383
+ // unambiguously us — resolve to it. This is what lets the bare `agents
384
+ // status` / `set-task` the stop hook recommends work without a `--session-id`
385
+ // flag in the common single-agent case. With 0 or 2+ live agents it would be
386
+ // a guess, so we stay null and require the explicit flag.
387
+ const singleton = resolveSingleActiveOwner(root);
388
+ if (singleton) {
389
+ return { owner: singleton, source: "active_singleton" };
390
+ }
391
+ return { owner: null, source: "none" };
392
+ }
393
+ /**
394
+ * Walk our own ppid chain for a pid-map row in ONE given root.
395
+ *
396
+ * Root-parameterized (rather than resolving the root itself) so root resolution
397
+ * can use it as a discriminator without recursing back into itself.
398
+ */
399
+ export function resolveOwnerByPidmap(root) {
258
400
  const pidmapDir = resolve(root, ".harnery", "pid-map");
259
401
  if (!existsSync(pidmapDir))
260
402
  return { owner: null, source: "none" };
@@ -279,15 +421,6 @@ export function resolveOwnerWithSource() {
279
421
  if (fallbackOwner) {
280
422
  return { owner: fallbackOwner, source: "pidmap_fallback" };
281
423
  }
282
- // Last resort: if exactly one agent is live in this coord root, it's
283
- // unambiguously us — resolve to it. This is what lets the bare `agents
284
- // status` / `set-task` the stop hook recommends work without a `--session-id`
285
- // flag in the common single-agent case. With 0 or 2+ live agents it would be
286
- // a guess, so we stay null and require the explicit flag.
287
- const singleton = resolveSingleActiveOwner(root);
288
- if (singleton) {
289
- return { owner: singleton, source: "active_singleton" };
290
- }
291
424
  return { owner: null, source: "none" };
292
425
  }
293
426
  /**
@@ -1,6 +1,9 @@
1
1
  /**
2
- * Walk up from `start` looking for a directory containing `.harnery/`, so
3
- * adapter + legacy code agree on the same monorepo root.
2
+ * Resolve the coordination root.
3
+ *
4
+ * Delegates to `resolveCoordRoot()`. This was a third, independent copy of the
5
+ * walk-up — the kind of duplication that let the hooks and the CLI drift onto
6
+ * different roots in the first place; keep resolution in one place.
4
7
  */
5
8
  export declare function findCoordRoot(start?: string): string | null;
6
9
  //# sourceMappingURL=paths.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../../src/core/agents/paths.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAQ1E"}
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../../src/core/agents/paths.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAE1E"}
@@ -1,17 +1,11 @@
1
- import { existsSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
1
+ import { resolveCoordRoot } from "./coord-client.js";
3
2
  /**
4
- * Walk up from `start` looking for a directory containing `.harnery/`, so
5
- * adapter + legacy code agree on the same monorepo root.
3
+ * Resolve the coordination root.
4
+ *
5
+ * Delegates to `resolveCoordRoot()`. This was a third, independent copy of the
6
+ * walk-up — the kind of duplication that let the hooks and the CLI drift onto
7
+ * different roots in the first place; keep resolution in one place.
6
8
  */
7
9
  export function findCoordRoot(start = process.cwd()) {
8
- let dir = resolve(start);
9
- while (true) {
10
- if (existsSync(join(dir, ".harnery")))
11
- return dir;
12
- const parent = dirname(dir);
13
- if (parent === dir)
14
- return null;
15
- dir = parent;
16
- }
10
+ return resolveCoordRoot(start);
17
11
  }
@@ -22,6 +22,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
22
22
  import { dirname, join } from "node:path";
23
23
  import { coordEnv } from "../../lib/env.js";
24
24
  import { replayCodexJsonl } from "../agents/codex-replay.js";
25
+ import { coordBinPath } from "../agents/coord-bin.js";
25
26
  import { consumeSince, writeCursor } from "../agents/events/consume.js";
26
27
  import { evaluateStopHook } from "../agents/rules/stop-hook.js";
27
28
  import { projectHeartbeats } from "../agents/state/heartbeat-projector.js";
@@ -118,7 +119,7 @@ function logError(coordRoot, err, context) {
118
119
  * separation: we spawn rather than import.
119
120
  */
120
121
  function assignNameViaAgentCoord(coordRoot, instanceId, kind) {
121
- const binary = join(coordRoot, "harnery", "bin", "agent-coord");
122
+ const binary = coordBinPath("agent-coord", coordRoot) ?? "";
122
123
  if (!existsSync(binary))
123
124
  return null;
124
125
  try {
@@ -620,7 +621,7 @@ async function main() {
620
621
  // subagent (claude-code + cursor; codex doesn't fan out subagents today).
621
622
  if (norm.event_type === "subagent.start") {
622
623
  try {
623
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
624
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
624
625
  if (existsSync(agentCoordBin)) {
625
626
  spawnSync(agentCoordBin, ["project"], {
626
627
  encoding: "utf8",
@@ -644,7 +645,7 @@ async function main() {
644
645
  if (norm.event_type === "subagent.stop") {
645
646
  try {
646
647
  cleanupSessionEnd(coordRoot, owner.instance_id, data.reason ?? "unknown");
647
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
648
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
648
649
  if (existsSync(agentCoordBin)) {
649
650
  spawnSync(agentCoordBin, [
650
651
  "log",
@@ -907,7 +908,7 @@ async function runPreToolUseGuard(coordRoot, instanceId, sessionId, data, adapte
907
908
  .filter((p) => p !== null);
908
909
  if (targets.length === 0)
909
910
  return;
910
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
911
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
911
912
  if (!existsSync(agentCoordBin))
912
913
  return;
913
914
  // For apply_patch (multi-file), collect siblings so the deny reason names
@@ -1008,7 +1009,7 @@ function parseApplyPatchPaths(data) {
1008
1009
  return out;
1009
1010
  }
1010
1011
  function healHeartbeatViaCli(coordRoot, instanceId, sessionId, adapter) {
1011
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1012
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1012
1013
  if (!existsSync(agentCoordBin))
1013
1014
  return;
1014
1015
  // Pass the detected adapter so a pruned Cursor/Codex heartbeat is recreated
@@ -1118,7 +1119,7 @@ function refreshPidmap(coordRoot, instanceId, adapter, payloadPid) {
1118
1119
  writePidmapViaAgentCoord(coordRoot, pid, instanceId, adapterPlatform(adapter));
1119
1120
  }
1120
1121
  function stampToolActivity(coordRoot, instanceId, data) {
1121
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1122
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1122
1123
  if (!existsSync(agentCoordBin))
1123
1124
  return;
1124
1125
  const toolName = data.tool_name ?? "";
@@ -1188,7 +1189,7 @@ function releaseClaimOnFailure(coordRoot, instanceId, data, rawPayload) {
1188
1189
  ? filePath.slice(coordRoot.length + 1)
1189
1190
  : filePath;
1190
1191
  }
1191
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1192
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1192
1193
  if (!existsSync(agentCoordBin))
1193
1194
  return;
1194
1195
  spawnSync(agentCoordBin, ["release-claim", instanceId, canonical], {
@@ -1232,13 +1233,13 @@ function cleanupSessionEnd(coordRoot, instanceId, reason) {
1232
1233
  }
1233
1234
  }
1234
1235
  // Activity log
1235
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1236
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1236
1237
  if (existsSync(agentCoordBin)) {
1237
1238
  spawnSync(agentCoordBin, ["log", `SESSION_END reason=${reason}`, "--instance", instanceId], { encoding: "utf8", timeout: 2000 });
1238
1239
  }
1239
1240
  }
1240
1241
  async function emitUserPromptSubmitSystemMessage(coordRoot, instanceId, sessionId, adapter, recoveryBriefing = "") {
1241
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1242
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1242
1243
  let additionalContext = "";
1243
1244
  if (existsSync(agentCoordBin)) {
1244
1245
  // Look up the agent's name from its heartbeat (for council pending rendering).
@@ -1293,7 +1294,7 @@ function emitSubagentStartContext(coordRoot, instanceId, sessionId, data, adapte
1293
1294
  // Render peer table inline since the subagent might want to know who else
1294
1295
  // is around. Reuse prompt-context (which dedups against the per-owner hash);
1295
1296
  // first call will always emit.
1296
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1297
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1297
1298
  let combined = message;
1298
1299
  if (existsSync(agentCoordBin)) {
1299
1300
  const result = spawnSync(agentCoordBin, ["prompt-context", "--instance", instanceId, "--session", sessionId, "--name", subagentName], { encoding: "utf8", timeout: 3000 });
@@ -1313,7 +1314,7 @@ function adapterPlatform(adapter) {
1313
1314
  return adapter;
1314
1315
  }
1315
1316
  async function emitSessionStartSystemMessage(coordRoot, instanceId, sessionId, emittedData, adapter, recoveryBriefing = "") {
1316
- const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
1317
+ const agentCoordBin = coordBinPath("agent-coord", coordRoot) ?? "";
1317
1318
  let additionalContext = "";
1318
1319
  if (existsSync(agentCoordBin)) {
1319
1320
  // Sync-project so the heartbeat exists for downstream readers (peer table,
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/effects/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAQH,YAAY,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEjE;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,QAAQ,SAAI,GACX,IAAI,CAYN;AAED,mFAAmF;AACnF,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAW3F;AAOD,oFAAoF;AACpF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAYrD;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAa3D;AAED,2DAA2D;AAC3D,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAYpE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAqB9E;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GAAG,SAAS,GACjC,IAAI,CAqBN;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAa1D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAOnD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/effects/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AASH,YAAY,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEjE;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,QAAQ,SAAI,GACX,IAAI,CAYN;AAED,mFAAmF;AACnF,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAW3F;AAMD,oFAAoF;AACpF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAYrD;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAa3D;AAED,2DAA2D;AAC3D,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAYpE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAqB9E;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GAAG,SAAS,GACjC,IAAI,CAqBN;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAa1D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAOnD"}
@@ -16,6 +16,7 @@ import { existsSync, readdirSync, rmSync } from "node:fs";
16
16
  import os from "node:os";
17
17
  import { join } from "node:path";
18
18
  import { applyDetection } from "../../../lib/presence.js";
19
+ import { coordBinPath } from "../../agents/coord-bin.js";
19
20
  export { captureImages, imageJanitor } from "./image-capture.js";
20
21
  /**
21
22
  * Play a notification sound via the cross-platform utility
@@ -53,8 +54,7 @@ export function soundForEvent(eventName) {
53
54
  }
54
55
  }
55
56
  function harnBin(repoRoot) {
56
- const bin = join(repoRoot, "harnery", "bin", "harn");
57
- return existsSync(bin) ? bin : null;
57
+ return coordBinPath("harn", repoRoot);
58
58
  }
59
59
  /** Prune stale journal archives + sweep orphans (global, fast). Fire-and-forget. */
60
60
  export function journalJanitor(repoRoot) {
@@ -1,17 +1,22 @@
1
1
  /**
2
- * Walk up from `start` looking for a directory containing `.harnery/`. The
3
- * single coord-root resolution so every adapter agrees on the same root.
2
+ * The hooks-side name for THE coordination-root resolution.
4
3
  *
5
- * Resolution precedence:
4
+ * Delegates to `resolveCoordRoot()` so the hooks and the CLI cannot resolve
5
+ * different roots. They used to: this walked up from `CLAUDE_PROJECT_DIR` or
6
+ * cwd while the CLI's `monorepoRoot()` asked git for the superproject first, so
7
+ * with a shell inside a submodule that carries its own `.harnery/`, the CLI's
8
+ * `state.status_checked` landed in a stream this side never read and rule 1/3
9
+ * blocked every turn.
10
+ *
11
+ * Resolution precedence (see `resolveCoordRoot` for the full rationale):
6
12
  * 1. HARNERY_COORD_ROOT_OVERRIDE — explicit pin (tests, git hooks).
7
- * 2. The adapter's project dir (CLAUDE_PROJECT_DIR) — hook processes
8
- * inherit the session's *shell* cwd, which follows `cd` into
9
- * subdirectories/submodules that may carry a `.harnery/` of their own
10
- * (or none at all, e.g. a journal dir under /tmp). The session's
11
- * coordination home is the project the adapter opened, not wherever the
12
- * shell happens to sit, so a project dir that resolves to a coord root
13
- * wins over the cwd walk.
14
- * 3. Walk up from `start` (default cwd).
13
+ * 2. The adapter's project dir (CLAUDE_PROJECT_DIR) — hook processes inherit
14
+ * the session's *shell* cwd, which follows `cd` into subdirectories or
15
+ * submodules that may carry a `.harnery/` of their own (or none at all,
16
+ * e.g. a journal dir under /tmp). The session's coordination home is the
17
+ * project the adapter opened, not wherever the shell happens to sit.
18
+ * 3. The root that already holds this session's heartbeat.
19
+ * 4. The nearest enclosing `.harnery/`, then a git-derived root.
15
20
  */
16
21
  export declare function findCoordRoot(start?: string): string | null;
17
22
  //# sourceMappingURL=coord-root.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"coord-root.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/coord-root.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAiB1E"}
1
+ {"version":3,"file":"coord-root.d.ts","sourceRoot":"","sources":["../../../../src/core/hooks/resolve/coord-root.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,aAAa,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAE1E"}
@@ -1,49 +1,24 @@
1
- import { existsSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
3
- import { coordEnv } from "../../../lib/env.js";
1
+ import { resolveCoordRoot } from "../../agents/coord-client.js";
4
2
  /**
5
- * Walk up from `start` looking for a directory containing `.harnery/`. The
6
- * single coord-root resolution so every adapter agrees on the same root.
3
+ * The hooks-side name for THE coordination-root resolution.
7
4
  *
8
- * Resolution precedence:
5
+ * Delegates to `resolveCoordRoot()` so the hooks and the CLI cannot resolve
6
+ * different roots. They used to: this walked up from `CLAUDE_PROJECT_DIR` or
7
+ * cwd while the CLI's `monorepoRoot()` asked git for the superproject first, so
8
+ * with a shell inside a submodule that carries its own `.harnery/`, the CLI's
9
+ * `state.status_checked` landed in a stream this side never read and rule 1/3
10
+ * blocked every turn.
11
+ *
12
+ * Resolution precedence (see `resolveCoordRoot` for the full rationale):
9
13
  * 1. HARNERY_COORD_ROOT_OVERRIDE — explicit pin (tests, git hooks).
10
- * 2. The adapter's project dir (CLAUDE_PROJECT_DIR) — hook processes
11
- * inherit the session's *shell* cwd, which follows `cd` into
12
- * subdirectories/submodules that may carry a `.harnery/` of their own
13
- * (or none at all, e.g. a journal dir under /tmp). The session's
14
- * coordination home is the project the adapter opened, not wherever the
15
- * shell happens to sit, so a project dir that resolves to a coord root
16
- * wins over the cwd walk.
17
- * 3. Walk up from `start` (default cwd).
14
+ * 2. The adapter's project dir (CLAUDE_PROJECT_DIR) — hook processes inherit
15
+ * the session's *shell* cwd, which follows `cd` into subdirectories or
16
+ * submodules that may carry a `.harnery/` of their own (or none at all,
17
+ * e.g. a journal dir under /tmp). The session's coordination home is the
18
+ * project the adapter opened, not wherever the shell happens to sit.
19
+ * 3. The root that already holds this session's heartbeat.
20
+ * 4. The nearest enclosing `.harnery/`, then a git-derived root.
18
21
  */
19
22
  export function findCoordRoot(start = process.cwd()) {
20
- // HARNERY_COORD_ROOT_OVERRIDE: explicit pin matched by agent-coord and the
21
- // bash side. Lets the sandboxed coord-test suite run against a temp
22
- // directory rather than the real monorepo root. agent-hook's session.start
23
- // handler may be the FIRST thing to create .harnery/ in a fresh sandbox,
24
- // so the override is honored unconditionally (we don't require .harnery/
25
- // to pre-exist).
26
- const override = coordEnv("COORD_ROOT_OVERRIDE");
27
- if (override)
28
- 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)
35
- return fromProject;
36
- }
37
- return walkUp(start);
38
- }
39
- function walkUp(start) {
40
- let dir = resolve(start);
41
- while (true) {
42
- if (existsSync(join(dir, ".harnery")))
43
- return dir;
44
- const parent = dirname(dir);
45
- if (parent === dir)
46
- return null;
47
- dir = parent;
48
- }
23
+ return resolveCoordRoot(start);
49
24
  }