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