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.
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +124 -14
- package/dist/commands/grep.d.ts +1 -1
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +19 -2
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +20 -4
- package/dist/core/agents/canonical-emit.d.ts +5 -10
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +10 -39
- package/dist/core/agents/coord-bin.d.ts +33 -0
- package/dist/core/agents/coord-bin.d.ts.map +1 -0
- package/dist/core/agents/coord-bin.js +89 -0
- package/dist/core/agents/coord-client.d.ts +57 -6
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +181 -48
- package/dist/core/agents/paths.d.ts +5 -2
- package/dist/core/agents/paths.d.ts.map +1 -1
- package/dist/core/agents/paths.js +7 -13
- package/dist/core/hooks/adapter/detect.d.ts +4 -2
- package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
- package/dist/core/hooks/adapter/detect.js +9 -4
- package/dist/core/hooks/cli.js +14 -13
- package/dist/core/hooks/effects/index.d.ts.map +1 -1
- package/dist/core/hooks/effects/index.js +2 -2
- package/dist/core/hooks/resolve/coord-root.d.ts +16 -11
- package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
- package/dist/core/hooks/resolve/coord-root.js +18 -43
- package/dist/lib/context/index.js +9 -2
- package/package.json +1 -1
- package/src/commands/agents.ts +129 -14
- package/src/commands/grep.ts +19 -2
- package/src/commands/tunnel.ts +19 -4
- package/src/core/agents/canonical-emit.ts +10 -37
- package/src/core/agents/coord-bin.ts +92 -0
- package/src/core/agents/coord-client.ts +183 -50
- package/src/core/agents/paths.ts +7 -11
- package/src/core/hooks/adapter/detect.ts +9 -4
- package/src/core/hooks/cli.ts +14 -13
- package/src/core/hooks/effects/index.ts +2 -2
- package/src/core/hooks/resolve/coord-root.ts +18 -40
- package/src/lib/context/index.ts +11 -1
|
@@ -1,49 +1,24 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
package/src/commands/agents.ts
CHANGED
|
@@ -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:
|
|
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 =
|
|
1337
|
-
if (!
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
|
3591
|
-
const helper =
|
|
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
|
|
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
|
-
|
|
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
|
|
4548
|
+
message: `resolved owner ${myOwner} has no name on heartbeat (pass --as <member> to override)`,
|
|
4434
4549
|
});
|
|
4435
4550
|
process.exit(1);
|
|
4436
4551
|
}
|
package/src/commands/grep.ts
CHANGED
|
@@ -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)
|
|
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") {
|
package/src/commands/tunnel.ts
CHANGED
|
@@ -209,12 +209,27 @@ function sweepStrays(gatePort: number, alreadyKilled: Set<number>): number {
|
|
|
209
209
|
);
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
/**
|
|
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
|
|
216
|
-
if (
|
|
217
|
-
for (const m of
|
|
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 {
|
|
14
|
-
import {
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
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 =
|
|
61
|
-
if (!
|
|
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
|
+
}
|