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
@@ -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
  }
@@ -49,8 +49,15 @@ function buildSelf() {
49
49
  if (!owner)
50
50
  throw new Error("not in an agent session (no pid-map entry)");
51
51
  const hb = readHeartbeat(owner);
52
- if (!hb)
53
- throw new Error(`pid-map resolved owner=${owner.slice(0, 8)}… but no heartbeat`);
52
+ // Full id, never abbreviated: this message exists to hand the reader an id to
53
+ // pass back (to `agents heal --owner`, say), and an 8-char prefix is not that
54
+ // id. Healing the truncated form writes `.harnery/active/<prefix>.json`, a
55
+ // heartbeat no reader ever resolves — so the session still looks unhealable
56
+ // and now has a junk file shadowing it. Same rule as `noHeartbeatMessage` in
57
+ // the agents command.
58
+ if (!hb) {
59
+ throw new Error(`pid-map resolved owner=${owner} but no heartbeat exists at .harnery/active/${owner}.json`);
60
+ }
54
61
  const startedMs = Date.parse(hb.started_at);
55
62
  const ageSecs = Number.isFinite(startedMs)
56
63
  ? Math.max(0, Math.floor((Date.now() - startedMs) / 1000))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.31.0",
3
+ "version": "0.31.2",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -28,6 +28,7 @@ import { homedir, tmpdir } from "node:os";
28
28
  import { basename, join, resolve } from "node:path";
29
29
  import type { Command } from "commander";
30
30
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
31
+ import { coordBinPath } from "../core/agents/coord-bin.ts";
31
32
  import { readStreamTailBounded } from "../core/agents/events/consume.ts";
32
33
  import {
33
34
  emitCanonical,
@@ -116,6 +117,44 @@ export function coordHelperOpts(root: string): { cwd: string; env: NodeJS.Proces
116
117
  return { cwd: root, env: { ...process.env, HARNERY_COORD_ROOT_OVERRIDE: root } };
117
118
  }
118
119
 
120
+ /**
121
+ * Resolve the bundled `agent-coord` helper, or exit with an error that names the
122
+ * problem. Every heartbeat mutation goes through that binary, so a caller who
123
+ * cannot find it has nothing useful to do — but it has to SAY so, because the
124
+ * alternative was spawning a path that did not exist and then crashing on the
125
+ * result (see `spawnFailureMessage`).
126
+ */
127
+ function agentCoordOrExit(root: string): string {
128
+ const binary = coordBinPath("agent-coord", root);
129
+ if (!binary) {
130
+ emit.error({
131
+ code: "coord_helper_missing",
132
+ message: `agent-coord helper not found for coord root ${root}; reinstall or rebuild harnery`,
133
+ });
134
+ process.exit(1);
135
+ }
136
+ return binary;
137
+ }
138
+
139
+ /**
140
+ * Describe a failed `spawnSync` without assuming it ran.
141
+ *
142
+ * When the binary cannot be executed at all (ENOENT, EACCES) node reports
143
+ * `status: null` AND `stderr: null`, so the obvious `result.stderr.trim()`
144
+ * threw "null is not an object" — replacing a legible "helper not found" with a
145
+ * crash that pointed at the wrong layer entirely. `error` carries the real
146
+ * cause on that path, so prefer it.
147
+ */
148
+ function spawnFailureMessage(
149
+ result: { status: number | null; stderr: string | null; error?: Error },
150
+ what: string,
151
+ ): string {
152
+ if (result.error) return `${what}: ${result.error.message}`;
153
+ const stderr = result.stderr?.trim();
154
+ if (stderr) return stderr;
155
+ return result.status === null ? `${what}: did not run` : `${what} exited ${result.status}`;
156
+ }
157
+
119
158
  function formatPlatformLabel(platform?: string | null): string {
120
159
  if (platform === "cursor") return "Cursor";
121
160
  if (platform === "codex") return "Codex";
@@ -820,7 +859,7 @@ function runWhoami(opts: { json?: boolean }): void {
820
859
  if (!hb) {
821
860
  emit.error({
822
861
  code: "no_heartbeat",
823
- message: `pid-map resolved owner=${myOwner.slice(0, 8)} but no heartbeat exists`,
862
+ message: noHeartbeatMessage(myOwner),
824
863
  });
825
864
  process.exit(1);
826
865
  }
@@ -1020,6 +1059,41 @@ function normalizeKind(kind: string | undefined | null): string {
1020
1059
  return kind;
1021
1060
  }
1022
1061
 
1062
+ /**
1063
+ * The `no_heartbeat` diagnostic, quoting the owner id in FULL.
1064
+ *
1065
+ * This id is actionable: the reader's next move is `agents heal --kind
1066
+ * heartbeat --owner <id>`, and heartbeats are keyed by the whole
1067
+ * `instance_id` (`.harnery/active/<instance_id>.json`). An abbreviated id
1068
+ * here reads as complete, so it gets copy-pasted into `--owner` and mints a
1069
+ * heartbeat at a filename no reader resolves — an orphan that leaves the
1070
+ * session looking unhealable. Abbreviate ids for display elsewhere, never in
1071
+ * a message whose whole purpose is to hand the reader an id to pass back.
1072
+ */
1073
+ function noHeartbeatMessage(owner: string): string {
1074
+ return `resolved owner=${owner} but no heartbeat exists at .harnery/active/${owner}.json`;
1075
+ }
1076
+
1077
+ /**
1078
+ * The instance_id of a live heartbeat that `prefix` strictly prefixes, or null.
1079
+ *
1080
+ * Used to recognize an abbreviated id handed back by a reader when the caller
1081
+ * supplied no canonical id of their own. Ambiguity is treated as "no answer":
1082
+ * two live sessions sharing the prefix means we cannot name the one intended,
1083
+ * and refusing with the wrong id in the message would be worse than the orphan.
1084
+ */
1085
+ function liveIdWithPrefix(root: string, prefix: string): string | null {
1086
+ const activeDir = resolve(root, ".harnery", "active");
1087
+ if (!existsSync(activeDir)) return null;
1088
+ const matches = new Set<string>();
1089
+ for (const file of readdirSync(activeDir)) {
1090
+ if (!file.endsWith(".json")) continue;
1091
+ const id = file.slice(0, -".json".length);
1092
+ if (id !== prefix && id.startsWith(prefix)) matches.add(id);
1093
+ }
1094
+ return matches.size === 1 ? (matches.values().next().value as string) : null;
1095
+ }
1096
+
1023
1097
  async function runWatch(pollMs: number): Promise<void> {
1024
1098
  const root = monorepoRoot();
1025
1099
  if (!root) {
@@ -1333,8 +1407,8 @@ function ensureCursorSession(root: string): void {
1333
1407
 
1334
1408
  const sessionId = cursorEnvSessionId();
1335
1409
  if (!sessionId) return;
1336
- const agentHook = resolve(root, "harnery", "bin", "agent-hook");
1337
- if (!existsSync(agentHook)) return;
1410
+ const agentHook = coordBinPath("agent-hook", root);
1411
+ if (!agentHook) return;
1338
1412
 
1339
1413
  const payload = JSON.stringify({
1340
1414
  conversation_id: sessionId,
@@ -1383,7 +1457,7 @@ function runReleaseClaim(path: string): void {
1383
1457
  let canonical = path;
1384
1458
  if (path.startsWith(`${root}/`)) canonical = path.slice(root.length + 1);
1385
1459
 
1386
- const helper = resolve(root, "harnery", "bin", "agent-coord");
1460
+ const helper = agentCoordOrExit(root);
1387
1461
  const result = spawnSync(helper, ["release-claim", myOwner, canonical], {
1388
1462
  encoding: "utf8",
1389
1463
  ...coordHelperOpts(root),
@@ -1391,7 +1465,7 @@ function runReleaseClaim(path: string): void {
1391
1465
  if (result.status !== 0) {
1392
1466
  emit.error({
1393
1467
  code: "release_claim_failed",
1394
- message: result.stderr.trim() || `agent-coord exited ${result.status}`,
1468
+ message: spawnFailureMessage(result, "agent-coord"),
1395
1469
  });
1396
1470
  process.exit(1);
1397
1471
  }
@@ -1432,7 +1506,7 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1432
1506
  const firstOfSession = !priorHb?.task_updated_at;
1433
1507
 
1434
1508
  // Heartbeat mutation goes through agent-coord (atomic temp+rename).
1435
- const helper = resolve(root, "harnery", "bin", "agent-coord");
1509
+ const helper = agentCoordOrExit(root);
1436
1510
  const result = spawnSync(helper, ["set-task", myOwner, task], {
1437
1511
  encoding: "utf8",
1438
1512
  ...coordHelperOpts(root),
@@ -1440,7 +1514,7 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1440
1514
  if (result.status !== 0) {
1441
1515
  emit.error({
1442
1516
  code: "set_task_failed",
1443
- message: result.stderr.trim() || `agent-coord exited ${result.status}`,
1517
+ message: spawnFailureMessage(result, "agent-coord"),
1444
1518
  });
1445
1519
  process.exit(1);
1446
1520
  }
@@ -1577,7 +1651,7 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1577
1651
  if (!hb) {
1578
1652
  emit.error({
1579
1653
  code: "no_heartbeat",
1580
- message: `pid-map resolved owner=${myOwner.slice(0, 8)} but no heartbeat exists`,
1654
+ message: noHeartbeatMessage(myOwner),
1581
1655
  });
1582
1656
  process.exit(1);
1583
1657
  }
@@ -1587,7 +1661,7 @@ function runStatus(opts: { json?: boolean; sessionId?: string }): void {
1587
1661
  // populated for back-compat with consumers reading v1 directly. The stamp
1588
1662
  // goes through agent-coord (atomic write).
1589
1663
  try {
1590
- const helper = resolve(root, "harnery", "bin", "agent-coord");
1664
+ const helper = agentCoordOrExit(root);
1591
1665
  spawnSync(helper, ["stamp-status-call", myOwner], {
1592
1666
  encoding: "utf8",
1593
1667
  timeout: 2000,
@@ -3578,6 +3652,44 @@ function runHeal(opts: {
3578
3652
  const action =
3579
3653
  kind === "pidmap" ? "heal-pidmap" : kind === "heartbeat" ? "heal-heartbeat" : "kill-heartbeat";
3580
3654
 
3655
+ // Refuse to mint a heartbeat at a TRUNCATED owner id.
3656
+ //
3657
+ // Heartbeats are keyed by the whole instance_id, so healing at an
3658
+ // abbreviated id writes `.harnery/active/<prefix>.json` while every reader
3659
+ // (`status`, `set-task`, `whoami`) resolves `<instance_id>.json`. The heal
3660
+ // reports success, the session stays broken, and an orphan file is left
3661
+ // behind that the singleton fallback can then mis-resolve other callers to.
3662
+ //
3663
+ // A distinct instance_id (subagent, workflow child) is never a prefix of its
3664
+ // own session_id, so a strict prefix is unambiguously an abbreviated id
3665
+ // rather than a legitimately different owner. Only guard the create path: an
3666
+ // existing heartbeat at `owner` means the id is real, whatever its shape.
3667
+ //
3668
+ // Two sources for the canonical id, because the reported failure arrives
3669
+ // without `--session-id`: someone copies a truncated id out of a diagnostic
3670
+ // and passes it as `--owner` alone. Comparing against `--session-id` alone
3671
+ // therefore misses the exact path that produced the orphan; a live heartbeat
3672
+ // whose instance_id this id is a prefix of settles it just as well, and is
3673
+ // present in precisely the case that matters (the session the reader was
3674
+ // trying to heal is registered, just not under the abbreviated name).
3675
+ if (kind === "heartbeat" && !readHeartbeat(owner)) {
3676
+ const canonical =
3677
+ opts.sessionId && opts.sessionId.trim() !== owner && opts.sessionId.trim().startsWith(owner)
3678
+ ? opts.sessionId.trim()
3679
+ : liveIdWithPrefix(root, owner);
3680
+ if (canonical) {
3681
+ const source = opts.sessionId?.trim() === canonical ? "--session-id" : "a live heartbeat";
3682
+ emit.error({
3683
+ code: "truncated_owner",
3684
+ message:
3685
+ `--owner ${owner} is a prefix of ${canonical} (${source}), and no heartbeat exists ` +
3686
+ `at .harnery/active/${owner}.json. Healing here would create a heartbeat no reader ` +
3687
+ `resolves. Re-run with --owner ${canonical}.`,
3688
+ });
3689
+ process.exit(1);
3690
+ }
3691
+ }
3692
+
3581
3693
  // Build positional args. agent-coord's arg layout:
3582
3694
  // heal-pidmap <instance_id> [<pid>]
3583
3695
  // heal-heartbeat <instance_id> [<session_id>]
@@ -3587,8 +3699,8 @@ function runHeal(opts: {
3587
3699
  if (kind === "heartbeat" && opts.sessionId) helperArgs.push(opts.sessionId);
3588
3700
 
3589
3701
  // heal-pidmap / heal-heartbeat / kill-heartbeat are handled by the
3590
- // agent-coord binary at harnery/bin/agent-coord.
3591
- const helper = `${root}/harnery/bin/agent-coord`;
3702
+ // bundled agent-coord binary.
3703
+ const helper = agentCoordOrExit(root);
3592
3704
  const proc = spawnSync(helper, helperArgs, {
3593
3705
  encoding: "utf8",
3594
3706
  ...coordHelperOpts(root),
@@ -3597,7 +3709,7 @@ function runHeal(opts: {
3597
3709
  if (proc.status !== 0) {
3598
3710
  emit.error({
3599
3711
  code: "heal_failed",
3600
- message: proc.stderr?.trim() || `agent-coord ${action} exited non-zero`,
3712
+ message: spawnFailureMessage(proc, `agent-coord ${action}`),
3601
3713
  });
3602
3714
  process.exit(1);
3603
3715
  }
@@ -3635,7 +3747,10 @@ function runHeal(opts: {
3635
3747
  ],
3636
3748
  meta: {
3637
3749
  kind,
3638
- helper: "harnery/bin/agent-coord",
3750
+ // The path actually spawned, not a guess at the layout: the helper is
3751
+ // resolved from harnery's own package location, which differs between a
3752
+ // submodule, an installed dependency, and a standalone checkout.
3753
+ helper,
3639
3754
  },
3640
3755
  });
3641
3756
  if (!opts.json) {
@@ -4430,7 +4545,7 @@ function runCouncilContribute(
4430
4545
  if (!myHb?.name) {
4431
4546
  emit.error({
4432
4547
  code: "no_self_name",
4433
- message: `resolved owner ${myOwner.slice(0, 8)} has no name on heartbeat (pass --as <member> to override)`,
4548
+ message: `resolved owner ${myOwner} has no name on heartbeat (pass --as <member> to override)`,
4434
4549
  });
4435
4550
  process.exit(1);
4436
4551
  }
@@ -741,7 +741,7 @@ function buildFindArgs(
741
741
  * chunk-boundary tests. Record shapes (both engines, pinned by the parity
742
742
  * suite):
743
743
  * content: <path>NUL<line>:<text>LF
744
- * count: <path>NUL<count>LF
744
+ * count: <path>NUL<count>LF (BSD grep ignores --null here: <path>:<count>LF)
745
745
  * filesOnly: <path>NUL (NUL is the record terminator)
746
746
  * plainLines: <path>LF (files mode: rg --files / find)
747
747
  *
@@ -801,7 +801,24 @@ export class NulDecoder {
801
801
  return { file: normalizeFile(path), line: 0, text: "" };
802
802
  }
803
803
  const nul = record.indexOf(0);
804
- if (nul < 0) return null; // not a NUL-framed record (stray engine chatter)
804
+ if (nul < 0) {
805
+ // BSD grep honours --null for content and -l records but IGNORES it under
806
+ // -c, emitting `<path>:<count>` where GNU grep emits `<path>NUL<count>`.
807
+ // Without this fallback every count row is dropped as unframed chatter, so
808
+ // `grep -c` returns nothing on a host whose only engine is BSD grep. The
809
+ // count is always a terminal run of digits, so a greedy match on the LAST
810
+ // colon recovers the path even when the path itself contains colons.
811
+ if (this.mode === "count") {
812
+ const bsd = /^(.*):(\d+)$/.exec(record.toString("utf8"));
813
+ if (!bsd) return null;
814
+ return {
815
+ file: normalizeFile(bsd[1] as string),
816
+ line: Number.parseInt(bsd[2] as string, 10),
817
+ text: "",
818
+ };
819
+ }
820
+ return null; // not a NUL-framed record (stray engine chatter)
821
+ }
805
822
  const file = normalizeFile(record.subarray(0, nul).toString("utf8"));
806
823
  const rest = record.subarray(nul + 1).toString("utf8");
807
824
  if (this.mode === "count") {
@@ -209,12 +209,27 @@ function sweepStrays(gatePort: number, alreadyKilled: Set<number>): number {
209
209
  );
210
210
  }
211
211
 
212
- /** Ports currently bound by a LISTEN socket (best-effort via `ss`). */
212
+ /**
213
+ * Ports currently bound by a LISTEN socket (best-effort, platform-dependent).
214
+ *
215
+ * `ss` (iproute2) is Linux-only. Without the lsof fallback this returns an empty
216
+ * set on macOS/BSD. That does not fail loudly. It reports every port as free, so
217
+ * `allocateGatePort` can hand out an occupied port and the reload port-release
218
+ * check becomes inert.
219
+ */
213
220
  function listeningPorts(): Set<number> {
214
221
  const ports = new Set<number>();
215
- const r = spawnSync("ss", ["-tlnH"], { encoding: "utf-8" });
216
- if (r.status === 0 && typeof r.stdout === "string") {
217
- for (const m of r.stdout.matchAll(/:(\d+)\s/g)) ports.add(Number(m[1]));
222
+ const ss = spawnSync("ss", ["-tlnH"], { encoding: "utf-8" });
223
+ if (ss.status === 0 && typeof ss.stdout === "string") {
224
+ for (const m of ss.stdout.matchAll(/:(\d+)\s/g)) ports.add(Number(m[1]));
225
+ return ports;
226
+ }
227
+ // Rows look like: `bun 123 user 4u IPv4 0x… 0t0 TCP 127.0.0.1:58055 (LISTEN)`.
228
+ // Status can be nonzero while stdout still holds usable rows (lsof reports a
229
+ // failure when any single process is unreadable), so judge it on the output.
230
+ const lsof = spawnSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf-8" });
231
+ if (typeof lsof.stdout === "string") {
232
+ for (const m of lsof.stdout.matchAll(/:(\d+) \(LISTEN\)$/gm)) ports.add(Number(m[1]));
218
233
  }
219
234
  return ports;
220
235
  }
@@ -10,9 +10,8 @@
10
10
  */
11
11
 
12
12
  import { spawnSync } from "node:child_process";
13
- import { existsSync } from "node:fs";
14
- import { dirname, join, resolve } from "node:path";
15
- import { monorepoRoot } from "./coord-client.ts";
13
+ import { coordBinPath } from "./coord-bin.ts";
14
+ import { resolveCoordRoot } from "./coord-client.ts";
16
15
 
17
16
  export interface CanonicalEmitInput {
18
17
  type: string;
@@ -28,37 +27,21 @@ export interface CanonicalEmitInput {
28
27
  /**
29
28
  * Resolve the coord root for a canonical emit.
30
29
  *
31
- * Order: explicit `HARNERY_COORD_ROOT_OVERRIDE` → git-superproject-aware
32
- * `monorepoRoot()` (must actually carry `.harnery/`) → cwd walk (non-git
33
- * hosts). The git-aware step is load-bearing: a shell cd'd into a nested
34
- * directory that carries its own `.harnery/` (e.g. an embedded harnery
35
- * checkout) made the old cwd-walk resolve the NESTED root, from which
36
- * `<root>/harnery/bin/agent-coord` doesn't exist — so `agents status` /
37
- * `set-task` emits silently vanished and the Stop-hook's rule 1/3
38
- * (`state.status_checked` in-turn) blocked turns that had done the ritual.
39
- * Same bug class as the coordHelperOpts root-pin fix; this closes the
40
- * emitCanonical instance of it.
30
+ * Delegates to `resolveCoordRoot()`, the single resolution the hooks and the
31
+ * CLI's reads also use. An emit resolved any other way is an emit the Stop hook
32
+ * cannot see: the hook reads `state.status_checked` from the stream under ITS
33
+ * root, so a divergent emit root blocks rule 1/3 on a turn that did run
34
+ * `agents status`, and no sequence of CLI commands can satisfy it.
41
35
  */
42
36
  export function resolveEmitRoot(start: string = process.cwd()): string | null {
43
- const override = process.env.HARNERY_COORD_ROOT_OVERRIDE;
44
- if (override) return override;
45
- // Memoized per start-dir: the session-tee middleware resolves once per
46
- // emitted event, and monorepoRoot() spawns git — cache so a streaming
47
- // command doesn't pay 1-3 subprocess spawns per output line.
48
- if (cachedRoot && cachedRoot.start === start) return cachedRoot.root;
49
- const gitRoot = monorepoRoot();
50
- const root = gitRoot && existsSync(join(gitRoot, ".harnery")) ? gitRoot : findRepoRoot(start);
51
- cachedRoot = { start, root };
52
- return root;
37
+ return resolveCoordRoot(start);
53
38
  }
54
39
 
55
- let cachedRoot: { start: string; root: string | null } | undefined;
56
-
57
40
  export function emitCanonical(input: CanonicalEmitInput): void {
58
41
  const root = resolveEmitRoot();
59
42
  if (!root) return;
60
- const binary = resolve(root, "harnery", "bin", "agent-coord");
61
- if (!existsSync(binary)) return;
43
+ const binary = coordBinPath("agent-coord", root);
44
+ if (!binary) return;
62
45
  try {
63
46
  const args = [
64
47
  "emit-event",
@@ -103,13 +86,3 @@ export function normalizeAdapter(platform: string | undefined): "claude-code" |
103
86
  if (platform === "codex") return "codex";
104
87
  return "claude-code";
105
88
  }
106
-
107
- function findRepoRoot(start: string): string | null {
108
- let dir = resolve(start);
109
- while (true) {
110
- if (existsSync(join(dir, ".harnery"))) return dir;
111
- const parent = dirname(dir);
112
- if (parent === dir) return null;
113
- dir = parent;
114
- }
115
- }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Locate harnery's own bundled coordination binaries (`agent-coord`,
3
+ * `agent-hook`, `harn`).
4
+ *
5
+ * Every coord-helper spawn used to build its path as
6
+ * `<coordRoot>/harnery/bin/<name>`, which only exists in one host layout: a
7
+ * superproject carrying harnery as a submodule at `harnery/`. That assumption
8
+ * was load-bearing in the worst way — because the path missed whenever the
9
+ * resolved root was anything else, root resolution itself was bent toward the
10
+ * git superproject to keep the spawns working, which is what split the CLI's
11
+ * root from the hook's and blocked end-of-turn rule 1/3. The same missing path
12
+ * also surfaced as a crash rather than an error: `spawnSync` on a nonexistent
13
+ * binary reports `status: null` and `stderr: null`, so a caller checking
14
+ * `status !== 0` then reading `stderr.trim()` threw "null is not an object".
15
+ *
16
+ * The binaries ship inside the harnery package, so the package is what knows
17
+ * where they are. Resolution walks up from this module to the package root,
18
+ * which covers every install shape: `src/` under Bun, `dist/` on Node, and
19
+ * `node_modules/harnery/`. The layout-specific paths remain as fallbacks so a
20
+ * host whose harnery copy is not the one executing (a vendored tree, a shim)
21
+ * still resolves.
22
+ */
23
+
24
+ import { existsSync } from "node:fs";
25
+ import { dirname, join, resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { coordEnv } from "../../lib/env.ts";
28
+
29
+ export type CoordBinName = "agent-coord" | "agent-hook" | "harn";
30
+
31
+ /**
32
+ * Absolute path to one of harnery's bundled binaries, or null when no candidate
33
+ * exists on disk. Callers must handle null — a missing helper is a real state
34
+ * (a partially installed package) and silently spawning a nonexistent path is
35
+ * what produced the null-deref crash above.
36
+ */
37
+ export function coordBinPath(name: CoordBinName, coordRoot?: string | null): string | null {
38
+ for (const candidate of coordBinCandidates(name, coordRoot)) {
39
+ if (existsSync(candidate)) return candidate;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** Candidate paths for `name`, in resolution order. Exported for tests. */
45
+ export function coordBinCandidates(name: CoordBinName, coordRoot?: string | null): string[] {
46
+ const candidates: string[] = [];
47
+ const push = (value: string | null | undefined) => {
48
+ if (value && !candidates.includes(value)) candidates.push(value);
49
+ };
50
+
51
+ // Explicit pin, for a host that relocates the binaries.
52
+ const pinned = coordEnv("BIN_DIR");
53
+ if (pinned) push(join(pinned, name));
54
+
55
+ // The package this code is executing from.
56
+ const pkgRoot = packageRoot(name);
57
+ if (pkgRoot) push(join(pkgRoot, "bin", name));
58
+
59
+ if (coordRoot) {
60
+ // Host layouts, in decreasing specificity: harnery as a submodule, as an
61
+ // installed dependency, or the coord root being harnery's own checkout.
62
+ push(resolve(coordRoot, "harnery", "bin", name));
63
+ push(resolve(coordRoot, "node_modules", "harnery", "bin", name));
64
+ push(resolve(coordRoot, "bin", name));
65
+ }
66
+
67
+ return candidates;
68
+ }
69
+
70
+ /**
71
+ * Walk up from this module to the directory holding `bin/<name>`.
72
+ *
73
+ * The walk keys on the binary itself rather than on `package.json`, because
74
+ * both `src/core/agents/` and the built `dist/core/agents/` sit under the same
75
+ * package root and only the binary's presence proves we found the right level.
76
+ */
77
+ function packageRoot(name: CoordBinName): string | null {
78
+ let dir: string;
79
+ try {
80
+ dir = dirname(fileURLToPath(import.meta.url));
81
+ } catch {
82
+ // No import.meta (a CJS transpile); the coordRoot fallbacks still apply.
83
+ return null;
84
+ }
85
+ for (let hop = 0; hop < 8; hop++) {
86
+ if (existsSync(join(dir, "bin", name))) return dir;
87
+ const parent = dirname(dir);
88
+ if (parent === dir) break;
89
+ dir = parent;
90
+ }
91
+ return null;
92
+ }