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 +1 @@
1
- {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../src/commands/agents.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAeH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AA0E1E;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAA;CAAE,CAErF;AAgCD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,WAAW,EACtB,cAAc,CAAC,EAAE,qBAAqB,GACrC,IAAI,CA+PN;AAyJD,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAyO7D;AAwqBD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,gBAAgB,EAAE,MAAM,EAAE,GACzB;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAKvD"}
1
+ {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../src/commands/agents.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAeH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AA2E1E;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAA;CAAE,CAErF;AAsED,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,WAAW,EACtB,cAAc,CAAC,EAAE,qBAAqB,GACrC,IAAI,CA+PN;AAyJD,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAyO7D;AA2sBD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,gBAAgB,EAAE,MAAM,EAAE,GACzB;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAKvD"}
@@ -16,6 +16,7 @@ import { spawnSync } from "node:child_process";
16
16
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
17
17
  import { homedir, tmpdir } from "node:os";
18
18
  import { basename, join, resolve } from "node:path";
19
+ import { coordBinPath } from "../core/agents/coord-bin.js";
19
20
  import { readStreamTailBounded } from "../core/agents/events/consume.js";
20
21
  import { emitCanonical, monorepoRoot, normalizeAdapter, readHeartbeat, resolveOwner, resolveOwnerBySessionEnv, resolveOwnerWithSource, } from "../core/agents/index.js";
21
22
  import { coordFreshnessSeconds, resolveBinName } from "../core/config.js";
@@ -56,6 +57,41 @@ const SUBAGENT_NOTE = "Bash identity is process-level in v1; if you're running i
56
57
  export function coordHelperOpts(root) {
57
58
  return { cwd: root, env: { ...process.env, HARNERY_COORD_ROOT_OVERRIDE: root } };
58
59
  }
60
+ /**
61
+ * Resolve the bundled `agent-coord` helper, or exit with an error that names the
62
+ * problem. Every heartbeat mutation goes through that binary, so a caller who
63
+ * cannot find it has nothing useful to do — but it has to SAY so, because the
64
+ * alternative was spawning a path that did not exist and then crashing on the
65
+ * result (see `spawnFailureMessage`).
66
+ */
67
+ function agentCoordOrExit(root) {
68
+ const binary = coordBinPath("agent-coord", root);
69
+ if (!binary) {
70
+ emit.error({
71
+ code: "coord_helper_missing",
72
+ message: `agent-coord helper not found for coord root ${root}; reinstall or rebuild harnery`,
73
+ });
74
+ process.exit(1);
75
+ }
76
+ return binary;
77
+ }
78
+ /**
79
+ * Describe a failed `spawnSync` without assuming it ran.
80
+ *
81
+ * When the binary cannot be executed at all (ENOENT, EACCES) node reports
82
+ * `status: null` AND `stderr: null`, so the obvious `result.stderr.trim()`
83
+ * threw "null is not an object" — replacing a legible "helper not found" with a
84
+ * crash that pointed at the wrong layer entirely. `error` carries the real
85
+ * cause on that path, so prefer it.
86
+ */
87
+ function spawnFailureMessage(result, what) {
88
+ if (result.error)
89
+ return `${what}: ${result.error.message}`;
90
+ const stderr = result.stderr?.trim();
91
+ if (stderr)
92
+ return stderr;
93
+ return result.status === null ? `${what}: did not run` : `${what} exited ${result.status}`;
94
+ }
59
95
  function formatPlatformLabel(platform) {
60
96
  if (platform === "cursor")
61
97
  return "Cursor";
@@ -537,7 +573,7 @@ function runWhoami(opts) {
537
573
  if (!hb) {
538
574
  emit.error({
539
575
  code: "no_heartbeat",
540
- message: `pid-map resolved owner=${myOwner.slice(0, 8)} but no heartbeat exists`,
576
+ message: noHeartbeatMessage(myOwner),
541
577
  });
542
578
  process.exit(1);
543
579
  }
@@ -721,6 +757,42 @@ function normalizeKind(kind) {
721
757
  return "unknown";
722
758
  return kind;
723
759
  }
760
+ /**
761
+ * The `no_heartbeat` diagnostic, quoting the owner id in FULL.
762
+ *
763
+ * This id is actionable: the reader's next move is `agents heal --kind
764
+ * heartbeat --owner <id>`, and heartbeats are keyed by the whole
765
+ * `instance_id` (`.harnery/active/<instance_id>.json`). An abbreviated id
766
+ * here reads as complete, so it gets copy-pasted into `--owner` and mints a
767
+ * heartbeat at a filename no reader resolves — an orphan that leaves the
768
+ * session looking unhealable. Abbreviate ids for display elsewhere, never in
769
+ * a message whose whole purpose is to hand the reader an id to pass back.
770
+ */
771
+ function noHeartbeatMessage(owner) {
772
+ return `resolved owner=${owner} but no heartbeat exists at .harnery/active/${owner}.json`;
773
+ }
774
+ /**
775
+ * The instance_id of a live heartbeat that `prefix` strictly prefixes, or null.
776
+ *
777
+ * Used to recognize an abbreviated id handed back by a reader when the caller
778
+ * supplied no canonical id of their own. Ambiguity is treated as "no answer":
779
+ * two live sessions sharing the prefix means we cannot name the one intended,
780
+ * and refusing with the wrong id in the message would be worse than the orphan.
781
+ */
782
+ function liveIdWithPrefix(root, prefix) {
783
+ const activeDir = resolve(root, ".harnery", "active");
784
+ if (!existsSync(activeDir))
785
+ return null;
786
+ const matches = new Set();
787
+ for (const file of readdirSync(activeDir)) {
788
+ if (!file.endsWith(".json"))
789
+ continue;
790
+ const id = file.slice(0, -".json".length);
791
+ if (id !== prefix && id.startsWith(prefix))
792
+ matches.add(id);
793
+ }
794
+ return matches.size === 1 ? matches.values().next().value : null;
795
+ }
724
796
  async function runWatch(pollMs) {
725
797
  const root = monorepoRoot();
726
798
  if (!root) {
@@ -1012,8 +1084,8 @@ function ensureCursorSession(root) {
1012
1084
  const sessionId = cursorEnvSessionId();
1013
1085
  if (!sessionId)
1014
1086
  return;
1015
- const agentHook = resolve(root, "harnery", "bin", "agent-hook");
1016
- if (!existsSync(agentHook))
1087
+ const agentHook = coordBinPath("agent-hook", root);
1088
+ if (!agentHook)
1017
1089
  return;
1018
1090
  const payload = JSON.stringify({
1019
1091
  conversation_id: sessionId,
@@ -1060,7 +1132,7 @@ function runReleaseClaim(path) {
1060
1132
  let canonical = path;
1061
1133
  if (path.startsWith(`${root}/`))
1062
1134
  canonical = path.slice(root.length + 1);
1063
- const helper = resolve(root, "harnery", "bin", "agent-coord");
1135
+ const helper = agentCoordOrExit(root);
1064
1136
  const result = spawnSync(helper, ["release-claim", myOwner, canonical], {
1065
1137
  encoding: "utf8",
1066
1138
  ...coordHelperOpts(root),
@@ -1068,7 +1140,7 @@ function runReleaseClaim(path) {
1068
1140
  if (result.status !== 0) {
1069
1141
  emit.error({
1070
1142
  code: "release_claim_failed",
1071
- message: result.stderr.trim() || `agent-coord exited ${result.status}`,
1143
+ message: spawnFailureMessage(result, "agent-coord"),
1072
1144
  });
1073
1145
  process.exit(1);
1074
1146
  }
@@ -1106,7 +1178,7 @@ function runSetTask(task, opts) {
1106
1178
  const priorHb = readHeartbeat(myOwner);
1107
1179
  const firstOfSession = !priorHb?.task_updated_at;
1108
1180
  // Heartbeat mutation goes through agent-coord (atomic temp+rename).
1109
- const helper = resolve(root, "harnery", "bin", "agent-coord");
1181
+ const helper = agentCoordOrExit(root);
1110
1182
  const result = spawnSync(helper, ["set-task", myOwner, task], {
1111
1183
  encoding: "utf8",
1112
1184
  ...coordHelperOpts(root),
@@ -1114,7 +1186,7 @@ function runSetTask(task, opts) {
1114
1186
  if (result.status !== 0) {
1115
1187
  emit.error({
1116
1188
  code: "set_task_failed",
1117
- message: result.stderr.trim() || `agent-coord exited ${result.status}`,
1189
+ message: spawnFailureMessage(result, "agent-coord"),
1118
1190
  });
1119
1191
  process.exit(1);
1120
1192
  }
@@ -1232,7 +1304,7 @@ function runStatus(opts) {
1232
1304
  if (!hb) {
1233
1305
  emit.error({
1234
1306
  code: "no_heartbeat",
1235
- message: `pid-map resolved owner=${myOwner.slice(0, 8)} but no heartbeat exists`,
1307
+ message: noHeartbeatMessage(myOwner),
1236
1308
  });
1237
1309
  process.exit(1);
1238
1310
  }
@@ -1241,7 +1313,7 @@ function runStatus(opts) {
1241
1313
  // populated for back-compat with consumers reading v1 directly. The stamp
1242
1314
  // goes through agent-coord (atomic write).
1243
1315
  try {
1244
- const helper = resolve(root, "harnery", "bin", "agent-coord");
1316
+ const helper = agentCoordOrExit(root);
1245
1317
  spawnSync(helper, ["stamp-status-call", myOwner], {
1246
1318
  encoding: "utf8",
1247
1319
  timeout: 2000,
@@ -3025,6 +3097,41 @@ function runHeal(opts) {
3025
3097
  process.exit(1);
3026
3098
  }
3027
3099
  const action = kind === "pidmap" ? "heal-pidmap" : kind === "heartbeat" ? "heal-heartbeat" : "kill-heartbeat";
3100
+ // Refuse to mint a heartbeat at a TRUNCATED owner id.
3101
+ //
3102
+ // Heartbeats are keyed by the whole instance_id, so healing at an
3103
+ // abbreviated id writes `.harnery/active/<prefix>.json` while every reader
3104
+ // (`status`, `set-task`, `whoami`) resolves `<instance_id>.json`. The heal
3105
+ // reports success, the session stays broken, and an orphan file is left
3106
+ // behind that the singleton fallback can then mis-resolve other callers to.
3107
+ //
3108
+ // A distinct instance_id (subagent, workflow child) is never a prefix of its
3109
+ // own session_id, so a strict prefix is unambiguously an abbreviated id
3110
+ // rather than a legitimately different owner. Only guard the create path: an
3111
+ // existing heartbeat at `owner` means the id is real, whatever its shape.
3112
+ //
3113
+ // Two sources for the canonical id, because the reported failure arrives
3114
+ // without `--session-id`: someone copies a truncated id out of a diagnostic
3115
+ // and passes it as `--owner` alone. Comparing against `--session-id` alone
3116
+ // therefore misses the exact path that produced the orphan; a live heartbeat
3117
+ // whose instance_id this id is a prefix of settles it just as well, and is
3118
+ // present in precisely the case that matters (the session the reader was
3119
+ // trying to heal is registered, just not under the abbreviated name).
3120
+ if (kind === "heartbeat" && !readHeartbeat(owner)) {
3121
+ const canonical = opts.sessionId && opts.sessionId.trim() !== owner && opts.sessionId.trim().startsWith(owner)
3122
+ ? opts.sessionId.trim()
3123
+ : liveIdWithPrefix(root, owner);
3124
+ if (canonical) {
3125
+ const source = opts.sessionId?.trim() === canonical ? "--session-id" : "a live heartbeat";
3126
+ emit.error({
3127
+ code: "truncated_owner",
3128
+ message: `--owner ${owner} is a prefix of ${canonical} (${source}), and no heartbeat exists ` +
3129
+ `at .harnery/active/${owner}.json. Healing here would create a heartbeat no reader ` +
3130
+ `resolves. Re-run with --owner ${canonical}.`,
3131
+ });
3132
+ process.exit(1);
3133
+ }
3134
+ }
3028
3135
  // Build positional args. agent-coord's arg layout:
3029
3136
  // heal-pidmap <instance_id> [<pid>]
3030
3137
  // heal-heartbeat <instance_id> [<session_id>]
@@ -3035,8 +3142,8 @@ function runHeal(opts) {
3035
3142
  if (kind === "heartbeat" && opts.sessionId)
3036
3143
  helperArgs.push(opts.sessionId);
3037
3144
  // heal-pidmap / heal-heartbeat / kill-heartbeat are handled by the
3038
- // agent-coord binary at harnery/bin/agent-coord.
3039
- const helper = `${root}/harnery/bin/agent-coord`;
3145
+ // bundled agent-coord binary.
3146
+ const helper = agentCoordOrExit(root);
3040
3147
  const proc = spawnSync(helper, helperArgs, {
3041
3148
  encoding: "utf8",
3042
3149
  ...coordHelperOpts(root),
@@ -3044,7 +3151,7 @@ function runHeal(opts) {
3044
3151
  if (proc.status !== 0) {
3045
3152
  emit.error({
3046
3153
  code: "heal_failed",
3047
- message: proc.stderr?.trim() || `agent-coord ${action} exited non-zero`,
3154
+ message: spawnFailureMessage(proc, `agent-coord ${action}`),
3048
3155
  });
3049
3156
  process.exit(1);
3050
3157
  }
@@ -3079,7 +3186,10 @@ function runHeal(opts) {
3079
3186
  ],
3080
3187
  meta: {
3081
3188
  kind,
3082
- helper: "harnery/bin/agent-coord",
3189
+ // The path actually spawned, not a guess at the layout: the helper is
3190
+ // resolved from harnery's own package location, which differs between a
3191
+ // submodule, an installed dependency, and a standalone checkout.
3192
+ helper,
3083
3193
  },
3084
3194
  });
3085
3195
  if (!opts.json) {
@@ -3793,7 +3903,7 @@ function runCouncilContribute(id, opts) {
3793
3903
  if (!myHb?.name) {
3794
3904
  emit.error({
3795
3905
  code: "no_self_name",
3796
- message: `resolved owner ${myOwner.slice(0, 8)} has no name on heartbeat (pass --as <member> to override)`,
3906
+ message: `resolved owner ${myOwner} has no name on heartbeat (pass --as <member> to override)`,
3797
3907
  });
3798
3908
  process.exit(1);
3799
3909
  }
@@ -60,7 +60,7 @@ type DecodeMode = "content" | "count" | "filesOnly" | "plainLines";
60
60
  * chunk-boundary tests. Record shapes (both engines, pinned by the parity
61
61
  * suite):
62
62
  * content: <path>NUL<line>:<text>LF
63
- * count: <path>NUL<count>LF
63
+ * count: <path>NUL<count>LF (BSD grep ignores --null here: <path>:<count>LF)
64
64
  * filesOnly: <path>NUL (NUL is the record terminator)
65
65
  * plainLines: <path>LF (files mode: rg --files / find)
66
66
  *
@@ -1 +1 @@
1
- {"version":3,"file":"grep.d.ts","sourceRoot":"","sources":["../../src/commands/grep.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"grep.d.ts","sourceRoot":"","sources":["../../src/commands/grep.ts"],"names":[],"mappings":"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"}
@@ -546,7 +546,7 @@ function buildFindArgs(pattern, paths, opts, extraExcludeDirs) {
546
546
  * chunk-boundary tests. Record shapes (both engines, pinned by the parity
547
547
  * suite):
548
548
  * content: <path>NUL<line>:<text>LF
549
- * count: <path>NUL<count>LF
549
+ * count: <path>NUL<count>LF (BSD grep ignores --null here: <path>:<count>LF)
550
550
  * filesOnly: <path>NUL (NUL is the record terminator)
551
551
  * plainLines: <path>LF (files mode: rg --files / find)
552
552
  *
@@ -610,8 +610,25 @@ export class NulDecoder {
610
610
  return { file: normalizeFile(path), line: 0, text: "" };
611
611
  }
612
612
  const nul = record.indexOf(0);
613
- if (nul < 0)
613
+ if (nul < 0) {
614
+ // BSD grep honours --null for content and -l records but IGNORES it under
615
+ // -c, emitting `<path>:<count>` where GNU grep emits `<path>NUL<count>`.
616
+ // Without this fallback every count row is dropped as unframed chatter, so
617
+ // `grep -c` returns nothing on a host whose only engine is BSD grep. The
618
+ // count is always a terminal run of digits, so a greedy match on the LAST
619
+ // colon recovers the path even when the path itself contains colons.
620
+ if (this.mode === "count") {
621
+ const bsd = /^(.*):(\d+)$/.exec(record.toString("utf8"));
622
+ if (!bsd)
623
+ return null;
624
+ return {
625
+ file: normalizeFile(bsd[1]),
626
+ line: Number.parseInt(bsd[2], 10),
627
+ text: "",
628
+ };
629
+ }
614
630
  return null; // not a NUL-framed record (stray engine chatter)
631
+ }
615
632
  const file = normalizeFile(record.subarray(0, nul).toString("utf8"));
616
633
  const rest = record.subarray(nul + 1).toString("utf8");
617
634
  if (this.mode === "count") {
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/commands/tunnel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAa1E,OAAO,EAYL,KAAK,WAAW,EAGjB,MAAM,wBAAwB,CAAC;AAgrBhC;;;;;;;;;;GAUG;AACH,wBAAsB,SAAS,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAsE7F;AAgND,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,WAAW,EACtB,YAAY,CAAC,EAAE,qBAAqB,GACnC,IAAI,CA4EN"}
1
+ {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/commands/tunnel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAa1E,OAAO,EAYL,KAAK,WAAW,EAGjB,MAAM,wBAAwB,CAAC;AA+rBhC;;;;;;;;;;GAUG;AACH,wBAAsB,SAAS,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAsE7F;AAgND,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,OAAO,EAChB,SAAS,EAAE,WAAW,EACtB,YAAY,CAAC,EAAE,qBAAqB,GACnC,IAAI,CA4EN"}
@@ -146,12 +146,28 @@ function sweepStrays(gatePort, alreadyKilled) {
146
146
  return (killByPattern(`gate\\.ts.*--port ${gatePort}( |$)`, alreadyKilled) +
147
147
  killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled));
148
148
  }
149
- /** Ports currently bound by a LISTEN socket (best-effort via `ss`). */
149
+ /**
150
+ * Ports currently bound by a LISTEN socket (best-effort, platform-dependent).
151
+ *
152
+ * `ss` (iproute2) is Linux-only. Without the lsof fallback this returns an empty
153
+ * set on macOS/BSD. That does not fail loudly. It reports every port as free, so
154
+ * `allocateGatePort` can hand out an occupied port and the reload port-release
155
+ * check becomes inert.
156
+ */
150
157
  function listeningPorts() {
151
158
  const ports = new Set();
152
- const r = spawnSync("ss", ["-tlnH"], { encoding: "utf-8" });
153
- if (r.status === 0 && typeof r.stdout === "string") {
154
- for (const m of r.stdout.matchAll(/:(\d+)\s/g))
159
+ const ss = spawnSync("ss", ["-tlnH"], { encoding: "utf-8" });
160
+ if (ss.status === 0 && typeof ss.stdout === "string") {
161
+ for (const m of ss.stdout.matchAll(/:(\d+)\s/g))
162
+ ports.add(Number(m[1]));
163
+ return ports;
164
+ }
165
+ // Rows look like: `bun 123 user 4u IPv4 0x… 0t0 TCP 127.0.0.1:58055 (LISTEN)`.
166
+ // Status can be nonzero while stdout still holds usable rows (lsof reports a
167
+ // failure when any single process is unreadable), so judge it on the output.
168
+ const lsof = spawnSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf-8" });
169
+ if (typeof lsof.stdout === "string") {
170
+ for (const m of lsof.stdout.matchAll(/:(\d+) \(LISTEN\)$/gm))
155
171
  ports.add(Number(m[1]));
156
172
  }
157
173
  return ports;
@@ -21,16 +21,11 @@ export interface CanonicalEmitInput {
21
21
  /**
22
22
  * Resolve the coord root for a canonical emit.
23
23
  *
24
- * Order: explicit `HARNERY_COORD_ROOT_OVERRIDE` → git-superproject-aware
25
- * `monorepoRoot()` (must actually carry `.harnery/`) → cwd walk (non-git
26
- * hosts). The git-aware step is load-bearing: a shell cd'd into a nested
27
- * directory that carries its own `.harnery/` (e.g. an embedded harnery
28
- * checkout) made the old cwd-walk resolve the NESTED root, from which
29
- * `<root>/harnery/bin/agent-coord` doesn't exist — so `agents status` /
30
- * `set-task` emits silently vanished and the Stop-hook's rule 1/3
31
- * (`state.status_checked` in-turn) blocked turns that had done the ritual.
32
- * Same bug class as the coordHelperOpts root-pin fix; this closes the
33
- * emitCanonical instance of it.
24
+ * Delegates to `resolveCoordRoot()`, the single resolution the hooks and the
25
+ * CLI's reads also use. An emit resolved any other way is an emit the Stop hook
26
+ * cannot see: the hook reads `state.status_checked` from the stream under ITS
27
+ * root, so a divergent emit root blocks rule 1/3 on a turn that did run
28
+ * `agents status`, and no sequence of CLI commands can satisfy it.
34
29
  */
35
30
  export declare function resolveEmitRoot(start?: string): string | null;
36
31
  export declare function emitCanonical(input: CanonicalEmitInput): void;
@@ -1 +1 @@
1
- {"version":3,"file":"canonical-emit.d.ts","sourceRoot":"","sources":["../../../src/core/agents/canonical-emit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAOH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,aAAa,GAAG,QAAQ,GAAG,OAAO,CAAC;IAC5C,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAW5E;AAID,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI,CAsC7D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,aAAa,GAAG,QAAQ,GAAG,OAAO,CAIjG"}
1
+ {"version":3,"file":"canonical-emit.d.ts","sourceRoot":"","sources":["../../../src/core/agents/canonical-emit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,aAAa,GAAG,QAAQ,GAAG,OAAO,CAAC;IAC5C,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CAE5E;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI,CAsC7D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,aAAa,GAAG,QAAQ,GAAG,OAAO,CAIjG"}
@@ -9,44 +9,26 @@
9
9
  * latency budget per call.
10
10
  */
11
11
  import { spawnSync } from "node:child_process";
12
- import { existsSync } from "node:fs";
13
- import { dirname, join, resolve } from "node:path";
14
- import { monorepoRoot } from "./coord-client.js";
12
+ import { coordBinPath } from "./coord-bin.js";
13
+ import { resolveCoordRoot } from "./coord-client.js";
15
14
  /**
16
15
  * Resolve the coord root for a canonical emit.
17
16
  *
18
- * Order: explicit `HARNERY_COORD_ROOT_OVERRIDE` → git-superproject-aware
19
- * `monorepoRoot()` (must actually carry `.harnery/`) → cwd walk (non-git
20
- * hosts). The git-aware step is load-bearing: a shell cd'd into a nested
21
- * directory that carries its own `.harnery/` (e.g. an embedded harnery
22
- * checkout) made the old cwd-walk resolve the NESTED root, from which
23
- * `<root>/harnery/bin/agent-coord` doesn't exist — so `agents status` /
24
- * `set-task` emits silently vanished and the Stop-hook's rule 1/3
25
- * (`state.status_checked` in-turn) blocked turns that had done the ritual.
26
- * Same bug class as the coordHelperOpts root-pin fix; this closes the
27
- * emitCanonical instance of it.
17
+ * Delegates to `resolveCoordRoot()`, the single resolution the hooks and the
18
+ * CLI's reads also use. An emit resolved any other way is an emit the Stop hook
19
+ * cannot see: the hook reads `state.status_checked` from the stream under ITS
20
+ * root, so a divergent emit root blocks rule 1/3 on a turn that did run
21
+ * `agents status`, and no sequence of CLI commands can satisfy it.
28
22
  */
29
23
  export function resolveEmitRoot(start = process.cwd()) {
30
- const override = process.env.HARNERY_COORD_ROOT_OVERRIDE;
31
- if (override)
32
- return override;
33
- // Memoized per start-dir: the session-tee middleware resolves once per
34
- // emitted event, and monorepoRoot() spawns git — cache so a streaming
35
- // command doesn't pay 1-3 subprocess spawns per output line.
36
- if (cachedRoot && cachedRoot.start === start)
37
- return cachedRoot.root;
38
- const gitRoot = monorepoRoot();
39
- const root = gitRoot && existsSync(join(gitRoot, ".harnery")) ? gitRoot : findRepoRoot(start);
40
- cachedRoot = { start, root };
41
- return root;
24
+ return resolveCoordRoot(start);
42
25
  }
43
- let cachedRoot;
44
26
  export function emitCanonical(input) {
45
27
  const root = resolveEmitRoot();
46
28
  if (!root)
47
29
  return;
48
- const binary = resolve(root, "harnery", "bin", "agent-coord");
49
- if (!existsSync(binary))
30
+ const binary = coordBinPath("agent-coord", root);
31
+ if (!binary)
50
32
  return;
51
33
  try {
52
34
  const args = [
@@ -97,14 +79,3 @@ export function normalizeAdapter(platform) {
97
79
  return "codex";
98
80
  return "claude-code";
99
81
  }
100
- function findRepoRoot(start) {
101
- let dir = resolve(start);
102
- while (true) {
103
- if (existsSync(join(dir, ".harnery")))
104
- return dir;
105
- const parent = dirname(dir);
106
- if (parent === dir)
107
- return null;
108
- dir = parent;
109
- }
110
- }
@@ -0,0 +1,33 @@
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
+ export type CoordBinName = "agent-coord" | "agent-hook" | "harn";
24
+ /**
25
+ * Absolute path to one of harnery's bundled binaries, or null when no candidate
26
+ * exists on disk. Callers must handle null — a missing helper is a real state
27
+ * (a partially installed package) and silently spawning a nonexistent path is
28
+ * what produced the null-deref crash above.
29
+ */
30
+ export declare function coordBinPath(name: CoordBinName, coordRoot?: string | null): string | null;
31
+ /** Candidate paths for `name`, in resolution order. Exported for tests. */
32
+ export declare function coordBinCandidates(name: CoordBinName, coordRoot?: string | null): string[];
33
+ //# sourceMappingURL=coord-bin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coord-bin.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-bin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAOH,MAAM,MAAM,YAAY,GAAG,aAAa,GAAG,YAAY,GAAG,MAAM,CAAC;AAEjE;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAKzF;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,EAAE,CAuB1F"}
@@ -0,0 +1,89 @@
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
+ import { existsSync } from "node:fs";
24
+ import { dirname, join, resolve } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { coordEnv } from "../../lib/env.js";
27
+ /**
28
+ * Absolute path to one of harnery's bundled binaries, or null when no candidate
29
+ * exists on disk. Callers must handle null — a missing helper is a real state
30
+ * (a partially installed package) and silently spawning a nonexistent path is
31
+ * what produced the null-deref crash above.
32
+ */
33
+ export function coordBinPath(name, coordRoot) {
34
+ for (const candidate of coordBinCandidates(name, coordRoot)) {
35
+ if (existsSync(candidate))
36
+ return candidate;
37
+ }
38
+ return null;
39
+ }
40
+ /** Candidate paths for `name`, in resolution order. Exported for tests. */
41
+ export function coordBinCandidates(name, coordRoot) {
42
+ const candidates = [];
43
+ const push = (value) => {
44
+ if (value && !candidates.includes(value))
45
+ candidates.push(value);
46
+ };
47
+ // Explicit pin, for a host that relocates the binaries.
48
+ const pinned = coordEnv("BIN_DIR");
49
+ if (pinned)
50
+ push(join(pinned, name));
51
+ // The package this code is executing from.
52
+ const pkgRoot = packageRoot(name);
53
+ if (pkgRoot)
54
+ push(join(pkgRoot, "bin", name));
55
+ if (coordRoot) {
56
+ // Host layouts, in decreasing specificity: harnery as a submodule, as an
57
+ // installed dependency, or the coord root being harnery's own checkout.
58
+ push(resolve(coordRoot, "harnery", "bin", name));
59
+ push(resolve(coordRoot, "node_modules", "harnery", "bin", name));
60
+ push(resolve(coordRoot, "bin", name));
61
+ }
62
+ return candidates;
63
+ }
64
+ /**
65
+ * Walk up from this module to the directory holding `bin/<name>`.
66
+ *
67
+ * The walk keys on the binary itself rather than on `package.json`, because
68
+ * both `src/core/agents/` and the built `dist/core/agents/` sit under the same
69
+ * package root and only the binary's presence proves we found the right level.
70
+ */
71
+ function packageRoot(name) {
72
+ let dir;
73
+ try {
74
+ dir = dirname(fileURLToPath(import.meta.url));
75
+ }
76
+ catch {
77
+ // No import.meta (a CJS transpile); the coordRoot fallbacks still apply.
78
+ return null;
79
+ }
80
+ for (let hop = 0; hop < 8; hop++) {
81
+ if (existsSync(join(dir, "bin", name)))
82
+ return dir;
83
+ const parent = dirname(dir);
84
+ if (parent === dir)
85
+ break;
86
+ dir = parent;
87
+ }
88
+ return null;
89
+ }