harnery 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/web-fetch.d.ts +10 -0
- package/dist/commands/web-fetch.d.ts.map +1 -0
- package/dist/commands/web-fetch.js +100 -0
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +33 -26
- package/dist/core/agents/coord-client.d.ts +22 -1
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +137 -5
- package/package.json +2 -1
- package/src/commands/web-fetch.ts +117 -0
- package/src/commands/web.ts +34 -29
- package/src/core/agents/coord-client.ts +130 -6
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EmitContext } from "../commander.js";
|
|
2
|
+
/** bun if available (preferred), else npm. Both support `<runner> install` and `<runner> run <script>`. */
|
|
3
|
+
export declare function webRunner(): "bun" | "npm";
|
|
4
|
+
/**
|
|
5
|
+
* Ensure a cached harnery checkout's `web/` exists for this version and return
|
|
6
|
+
* its path, fetching (clone + install) on first use. Returns null on failure
|
|
7
|
+
* (after emitting an actionable error).
|
|
8
|
+
*/
|
|
9
|
+
export declare function lazyFetchWebRoot(emit: EmitContext): string | null;
|
|
10
|
+
//# sourceMappingURL=web-fetch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web-fetch.d.ts","sourceRoot":"","sources":["../../src/commands/web-fetch.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAiDnD,2GAA2G;AAC3G,wBAAgB,SAAS,IAAI,KAAK,GAAG,KAAK,CAGzC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CAmDjE"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
/**
|
|
7
|
+
* Lazy-fetch of the web dashboard for npm consumers.
|
|
8
|
+
*
|
|
9
|
+
* The published npm package ships the CLI + coord engine but deliberately
|
|
10
|
+
* excludes `web/` (ADR 0001/0003: keep the core lean for CLI-only users). The
|
|
11
|
+
* dashboard also imports harnery's `src/` directly (see `web/next.config.ts`),
|
|
12
|
+
* so it can't be lifted out as a standalone folder. Instead, the first time
|
|
13
|
+
* `harn web up` runs from an install without a local `web/`, we clone the
|
|
14
|
+
* harnery repo at the *matching version tag* into a cache dir and install the
|
|
15
|
+
* web app's deps. Subsequent runs reuse the cache.
|
|
16
|
+
*
|
|
17
|
+
* The only thing `web/` pulls from `src/` is `harnery/lib/scratch` (a single,
|
|
18
|
+
* dependency-free module), so the install is `web/` deps only: no root install,
|
|
19
|
+
* no Playwright browser download.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_REPO_URL = "https://github.com/ryanjkelly/harnery.git";
|
|
22
|
+
/** harnery's own package root: src/commands/ -> ../.. ; valid in both src/ and dist/. */
|
|
23
|
+
function packageRoot() {
|
|
24
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
return path.resolve(here, "..", "..");
|
|
26
|
+
}
|
|
27
|
+
function readPkg() {
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(path.join(packageRoot(), "package.json"), "utf8"));
|
|
30
|
+
const raw = typeof pkg.repository === "string" ? pkg.repository : (pkg.repository?.url ?? "");
|
|
31
|
+
const repoUrl = raw.replace(/^git\+/, "") || DEFAULT_REPO_URL;
|
|
32
|
+
return { version: typeof pkg.version === "string" ? pkg.version : "0.0.0", repoUrl };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { version: "0.0.0", repoUrl: DEFAULT_REPO_URL };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The git ref to fetch: HARNERY_WEB_REF override, else the version tag (`v<version>`). */
|
|
39
|
+
function resolveRef(version) {
|
|
40
|
+
return process.env.HARNERY_WEB_REF || `v${version}`;
|
|
41
|
+
}
|
|
42
|
+
/** Per-ref cache dir under the XDG cache (matches harnery's `~/.cache/harnery/...`). */
|
|
43
|
+
function webCacheRoot(ref) {
|
|
44
|
+
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
45
|
+
const safeRef = ref.replace(/[^\w.-]/g, "_");
|
|
46
|
+
return path.join(base, "harnery", "web", safeRef);
|
|
47
|
+
}
|
|
48
|
+
/** bun if available (preferred), else npm. Both support `<runner> install` and `<runner> run <script>`. */
|
|
49
|
+
export function webRunner() {
|
|
50
|
+
const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
|
|
51
|
+
return r.status === 0 ? "bun" : "npm";
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Ensure a cached harnery checkout's `web/` exists for this version and return
|
|
55
|
+
* its path, fetching (clone + install) on first use. Returns null on failure
|
|
56
|
+
* (after emitting an actionable error).
|
|
57
|
+
*/
|
|
58
|
+
export function lazyFetchWebRoot(emit) {
|
|
59
|
+
const { version, repoUrl } = readPkg();
|
|
60
|
+
const ref = resolveRef(version);
|
|
61
|
+
const cacheRoot = webCacheRoot(ref);
|
|
62
|
+
const webDir = path.join(cacheRoot, "web");
|
|
63
|
+
// node_modules in the cached web/ is the "fetched + installed" marker.
|
|
64
|
+
if (existsSync(path.join(webDir, "node_modules"))) {
|
|
65
|
+
emit.log(`harn web · using cached dashboard (${ref})`, "info");
|
|
66
|
+
return webDir;
|
|
67
|
+
}
|
|
68
|
+
emit.log(`harn web · dashboard is not bundled in the npm package; fetching harnery ${ref} (one-time)`, "info");
|
|
69
|
+
emit.log(` into ${cacheRoot} · clone + web/ deps; pass --no-fetch to skip`, "info");
|
|
70
|
+
// Clear any partial/stale state so a retry is clean.
|
|
71
|
+
if (existsSync(cacheRoot))
|
|
72
|
+
rmSync(cacheRoot, { recursive: true, force: true });
|
|
73
|
+
mkdirSync(path.dirname(cacheRoot), { recursive: true });
|
|
74
|
+
const clone = spawnSync("git", ["clone", "--depth", "1", "--branch", ref, repoUrl, cacheRoot], {
|
|
75
|
+
stdio: "inherit",
|
|
76
|
+
});
|
|
77
|
+
if (clone.status !== 0) {
|
|
78
|
+
emit.error({
|
|
79
|
+
code: "web_fetch_failed",
|
|
80
|
+
message: `git clone of ${repoUrl} @ ${ref} failed${clone.error ? `: ${clone.error.message}` : ` (status ${clone.status})`}`,
|
|
81
|
+
hint: "Fetching the dashboard needs git + network. Do it manually: " +
|
|
82
|
+
`git clone ${repoUrl} && cd harnery/web && ${webRunner()} install, then run it pointed at your project via HARNERY_COORD_ROOT. ` +
|
|
83
|
+
"See https://harnery.com/cli/web/.",
|
|
84
|
+
});
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const runner = webRunner();
|
|
88
|
+
emit.log(`harn web · installing dashboard deps (${runner} install in web/)`, "info");
|
|
89
|
+
const install = spawnSync(runner, ["install"], { cwd: webDir, stdio: "inherit" });
|
|
90
|
+
if (install.status !== 0) {
|
|
91
|
+
emit.error({
|
|
92
|
+
code: "web_fetch_failed",
|
|
93
|
+
message: `${runner} install in ${webDir} failed (status ${install.status})`,
|
|
94
|
+
hint: `Try manually: cd ${webDir} && ${runner} install`,
|
|
95
|
+
});
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
emit.log(`harn web · dashboard ready (cached at ${webDir})`, "info");
|
|
99
|
+
return webDir;
|
|
100
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../../src/commands/web.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../../src/commands/web.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAwDnD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAwI5E"}
|
package/dist/commands/web.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { lazyFetchWebRoot, webRunner } from "./web-fetch.js";
|
|
5
6
|
/**
|
|
6
7
|
* `harn web`: Next.js dashboard for harnery's coord state.
|
|
7
8
|
*
|
|
@@ -15,6 +16,9 @@ import { fileURLToPath } from "node:url";
|
|
|
15
16
|
* passing cwd here lets the dashboard show the right project regardless
|
|
16
17
|
* of where the user invokes `harn web up` from.
|
|
17
18
|
*
|
|
19
|
+
* The dashboard is not bundled in the npm package (ADR 0001/0003), so on an
|
|
20
|
+
* npm install `resolveWebRoot` lazy-fetches it on first use (see web-fetch.ts).
|
|
21
|
+
*
|
|
18
22
|
* Localhost-only by default. Network exposure is intentionally out of
|
|
19
23
|
* scope for v1.
|
|
20
24
|
*/
|
|
@@ -23,25 +27,25 @@ function webRoot() {
|
|
|
23
27
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
24
28
|
return path.resolve(here, "..", "..", "web");
|
|
25
29
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the web app root. A local `web/` (source checkout / submodule) wins.
|
|
32
|
+
* Otherwise, on an npm install, lazy-fetch it (clone + install) unless the
|
|
33
|
+
* caller passed --no-fetch, in which case emit the honest manual hint.
|
|
34
|
+
*/
|
|
35
|
+
function resolveWebRoot(emit, allowFetch) {
|
|
36
|
+
const local = webRoot();
|
|
37
|
+
if (existsSync(path.join(local, "package.json")))
|
|
38
|
+
return local;
|
|
39
|
+
if (allowFetch)
|
|
40
|
+
return lazyFetchWebRoot(emit);
|
|
41
|
+
emit.error({
|
|
42
|
+
code: "web_missing",
|
|
43
|
+
message: `harnery/web/ not found at ${local}`,
|
|
44
|
+
hint: "The dashboard is not bundled in the npm package. Re-run without --no-fetch to fetch it " +
|
|
45
|
+
"automatically, or clone https://github.com/ryanjkelly/harnery and run it from there. " +
|
|
46
|
+
"See https://harnery.com/cli/web/.",
|
|
47
|
+
});
|
|
48
|
+
return null;
|
|
45
49
|
}
|
|
46
50
|
export function registerWebCommand(program, emit) {
|
|
47
51
|
const web = program
|
|
@@ -54,8 +58,9 @@ export function registerWebCommand(program, emit) {
|
|
|
54
58
|
.option("-p, --port <port>", "Listen port", "9000")
|
|
55
59
|
.option("--coord-root <dir>", "Override the coord root (default: cwd; web walks up looking for .harnery/)")
|
|
56
60
|
.option("--prod", "Use next start instead of next dev (requires prior build)")
|
|
61
|
+
.option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
|
|
57
62
|
.action(async (opts) => {
|
|
58
|
-
const root =
|
|
63
|
+
const root = resolveWebRoot(emit, opts.fetch !== false);
|
|
59
64
|
if (!root) {
|
|
60
65
|
process.exitCode = 1;
|
|
61
66
|
return;
|
|
@@ -77,7 +82,7 @@ export function registerWebCommand(program, emit) {
|
|
|
77
82
|
}
|
|
78
83
|
emit.log(`harn web · http://localhost:${port} (${mode})`, "info");
|
|
79
84
|
emit.log(`reading .harnery/ from: ${coordRoot}`, "info");
|
|
80
|
-
const child = spawn(
|
|
85
|
+
const child = spawn(webRunner(), ["run", mode], {
|
|
81
86
|
cwd: root,
|
|
82
87
|
env: {
|
|
83
88
|
...process.env,
|
|
@@ -105,14 +110,15 @@ export function registerWebCommand(program, emit) {
|
|
|
105
110
|
web
|
|
106
111
|
.command("build")
|
|
107
112
|
.description("Build the production bundle (next build).")
|
|
108
|
-
.
|
|
109
|
-
|
|
113
|
+
.option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
|
|
114
|
+
.action((opts) => {
|
|
115
|
+
const root = resolveWebRoot(emit, opts.fetch !== false);
|
|
110
116
|
if (!root) {
|
|
111
117
|
process.exitCode = 1;
|
|
112
118
|
return;
|
|
113
119
|
}
|
|
114
120
|
emit.log("running next build…", "info");
|
|
115
|
-
const r = spawnSync(
|
|
121
|
+
const r = spawnSync(webRunner(), ["run", "build"], {
|
|
116
122
|
cwd: root,
|
|
117
123
|
stdio: "inherit",
|
|
118
124
|
});
|
|
@@ -123,8 +129,9 @@ export function registerWebCommand(program, emit) {
|
|
|
123
129
|
.description("Start the production server (next start). Requires prior `harn web build`.")
|
|
124
130
|
.option("-p, --port <port>", "Listen port", "9000")
|
|
125
131
|
.option("--coord-root <dir>", "Override the coord root")
|
|
132
|
+
.option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
|
|
126
133
|
.action((opts) => {
|
|
127
|
-
const root =
|
|
134
|
+
const root = resolveWebRoot(emit, opts.fetch !== false);
|
|
128
135
|
if (!root) {
|
|
129
136
|
process.exitCode = 1;
|
|
130
137
|
return;
|
|
@@ -143,7 +150,7 @@ export function registerWebCommand(program, emit) {
|
|
|
143
150
|
const port = String(opts.port);
|
|
144
151
|
emit.log(`harn web · http://localhost:${port} (start)`, "info");
|
|
145
152
|
emit.log(`reading .harnery/ from: ${coordRoot}`, "info");
|
|
146
|
-
const child = spawn(
|
|
153
|
+
const child = spawn(webRunner(), ["run", "start"], {
|
|
147
154
|
cwd: root,
|
|
148
155
|
env: {
|
|
149
156
|
...process.env,
|
|
@@ -69,8 +69,29 @@ export declare function resolveOwner(): string | null;
|
|
|
69
69
|
*/
|
|
70
70
|
export declare function resolveOwnerWithSource(): {
|
|
71
71
|
owner: string | null;
|
|
72
|
-
source: "env" | "pidmap" | "pidmap_fallback" | "none";
|
|
72
|
+
source: "env" | "pidmap" | "pidmap_fallback" | "session_env" | "active_singleton" | "none";
|
|
73
73
|
};
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the owner by matching the harness session-id env var against the
|
|
76
|
+
* `session_id` of a live heartbeat in this coord root. Returns the matching
|
|
77
|
+
* `instance_id`, or null if there's no session-id env var or no live heartbeat
|
|
78
|
+
* carries it. "Live" reuses the same 10-minute freshness window the singleton
|
|
79
|
+
* fallback applies, so a stale heartbeat from a prior session of the same id
|
|
80
|
+
* doesn't resolve.
|
|
81
|
+
*
|
|
82
|
+
* Exported for unit testing with an injectable root.
|
|
83
|
+
*/
|
|
84
|
+
export declare function resolveOwnerBySessionEnv(root: string): string | null;
|
|
85
|
+
/**
|
|
86
|
+
* Return the instance_id of the sole live agent in this coord root, or null
|
|
87
|
+
* if there are zero or more than one. "Live" reuses the 10-minute heartbeat
|
|
88
|
+
* freshness window the rest of the agents surface applies (kept inline as a
|
|
89
|
+
* literal so this file stays node-builtins-only for vendored downstream use).
|
|
90
|
+
*
|
|
91
|
+
* Exported for unit testing with an injectable root (the caller in
|
|
92
|
+
* `resolveOwnerWithSource` passes `monorepoRoot()`).
|
|
93
|
+
*/
|
|
94
|
+
export declare function resolveSingleActiveOwner(root: string): string | null;
|
|
74
95
|
/**
|
|
75
96
|
* Read and parse a heartbeat file. Returns null if the file is missing,
|
|
76
97
|
* unreadable, or contains malformed JSON. Does not throw.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oGAAoG;IACpG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0IAA0I;IAC1I,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,mEAAmE;IACnE,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CA0C5C;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI1D;AAaD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oGAAoG;IACpG,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0IAA0I;IAC1I,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,mEAAmE;IACnE,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CA0C5C;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI1D;AAaD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,aAAa,GAAG,kBAAkB,GAAG,MAAM,CAAC;CAC5F,CAuDA;AA4BD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA2BpE;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyBpE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAKlE;AAgBD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAOtD;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAIxC"}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* either side without divergence.
|
|
8
8
|
*/
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
/**
|
|
13
13
|
* Resolve the monorepo root for coord-state purposes.
|
|
@@ -133,10 +133,142 @@ export function resolveOwnerWithSource() {
|
|
|
133
133
|
}
|
|
134
134
|
pid = readPpid(pid);
|
|
135
135
|
}
|
|
136
|
-
|
|
137
|
-
owner: fallbackOwner,
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
if (fallbackOwner) {
|
|
137
|
+
return { owner: fallbackOwner, source: "pidmap_fallback" };
|
|
138
|
+
}
|
|
139
|
+
// The ppid walk found nothing (e.g. a Bash-tool subshell whose process tree
|
|
140
|
+
// doesn't climb back to the harness anchor). Before guessing, try the
|
|
141
|
+
// harness-provided session id from the environment: every supported harness
|
|
142
|
+
// exports its session id into the tool subprocess env, and each heartbeat
|
|
143
|
+
// records the `session_id` it was minted under. Matching the two resolves us
|
|
144
|
+
// unambiguously even with multiple live agents, which is the case the
|
|
145
|
+
// singleton fallback below cannot handle.
|
|
146
|
+
const bySession = resolveOwnerBySessionEnv(root);
|
|
147
|
+
if (bySession) {
|
|
148
|
+
return { owner: bySession, source: "session_env" };
|
|
149
|
+
}
|
|
150
|
+
// Last resort: if exactly one agent is live in this coord root, it's
|
|
151
|
+
// unambiguously us — resolve to it. This is what lets the bare `agents
|
|
152
|
+
// status` / `set-task` the stop hook recommends work without a `--session-id`
|
|
153
|
+
// flag in the common single-agent case. With 0 or 2+ live agents it would be
|
|
154
|
+
// a guess, so we stay null and require the explicit flag.
|
|
155
|
+
const singleton = resolveSingleActiveOwner(root);
|
|
156
|
+
if (singleton) {
|
|
157
|
+
return { owner: singleton, source: "active_singleton" };
|
|
158
|
+
}
|
|
159
|
+
return { owner: null, source: "none" };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Harness-exported session-id environment variables, in precedence order. Each
|
|
163
|
+
* supported harness propagates its session id into the env of the subprocess it
|
|
164
|
+
* spawns for a tool call (Claude Code's Bash tool, Cursor's terminal, Codex's
|
|
165
|
+
* shell). A coord CLI invoked as such a tool can therefore recover its own
|
|
166
|
+
* identity from the env even when the ppid walk misses.
|
|
167
|
+
*
|
|
168
|
+
* Kept inline (no shared-helper import) so this file stays node-builtins-only
|
|
169
|
+
* for the vendored downstream consumer.
|
|
170
|
+
*/
|
|
171
|
+
const SESSION_ID_ENV_VARS = [
|
|
172
|
+
"HARNERY_AGENT_COORD_SESSION_ID", // explicit override, wins if set
|
|
173
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
174
|
+
"CURSOR_SESSION_ID",
|
|
175
|
+
"CODEX_SESSION_ID",
|
|
176
|
+
];
|
|
177
|
+
/** Read the first non-empty harness session-id env var, or null. */
|
|
178
|
+
function sessionIdFromEnv() {
|
|
179
|
+
for (const key of SESSION_ID_ENV_VARS) {
|
|
180
|
+
const v = process.env[key]?.trim();
|
|
181
|
+
if (v)
|
|
182
|
+
return v;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Resolve the owner by matching the harness session-id env var against the
|
|
188
|
+
* `session_id` of a live heartbeat in this coord root. Returns the matching
|
|
189
|
+
* `instance_id`, or null if there's no session-id env var or no live heartbeat
|
|
190
|
+
* carries it. "Live" reuses the same 10-minute freshness window the singleton
|
|
191
|
+
* fallback applies, so a stale heartbeat from a prior session of the same id
|
|
192
|
+
* doesn't resolve.
|
|
193
|
+
*
|
|
194
|
+
* Exported for unit testing with an injectable root.
|
|
195
|
+
*/
|
|
196
|
+
export function resolveOwnerBySessionEnv(root) {
|
|
197
|
+
const sessionId = sessionIdFromEnv();
|
|
198
|
+
if (!sessionId)
|
|
199
|
+
return null;
|
|
200
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
201
|
+
if (!existsSync(activeDir))
|
|
202
|
+
return null;
|
|
203
|
+
const FRESHNESS_SECS = 600;
|
|
204
|
+
const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
|
|
205
|
+
let files;
|
|
206
|
+
try {
|
|
207
|
+
files = readdirSync(activeDir);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
if (!file.endsWith(".json"))
|
|
214
|
+
continue;
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
217
|
+
if (!parsed || parsed.session_id !== sessionId)
|
|
218
|
+
continue;
|
|
219
|
+
if (typeof parsed.instance_id !== "string")
|
|
220
|
+
continue;
|
|
221
|
+
const ts = Date.parse(parsed.last_heartbeat);
|
|
222
|
+
if (Number.isFinite(ts) && ts >= cutoffMs)
|
|
223
|
+
return parsed.instance_id;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// skip malformed
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Return the instance_id of the sole live agent in this coord root, or null
|
|
233
|
+
* if there are zero or more than one. "Live" reuses the 10-minute heartbeat
|
|
234
|
+
* freshness window the rest of the agents surface applies (kept inline as a
|
|
235
|
+
* literal so this file stays node-builtins-only for vendored downstream use).
|
|
236
|
+
*
|
|
237
|
+
* Exported for unit testing with an injectable root (the caller in
|
|
238
|
+
* `resolveOwnerWithSource` passes `monorepoRoot()`).
|
|
239
|
+
*/
|
|
240
|
+
export function resolveSingleActiveOwner(root) {
|
|
241
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
242
|
+
if (!existsSync(activeDir))
|
|
243
|
+
return null;
|
|
244
|
+
const FRESHNESS_SECS = 600;
|
|
245
|
+
const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
|
|
246
|
+
const live = [];
|
|
247
|
+
let files;
|
|
248
|
+
try {
|
|
249
|
+
files = readdirSync(activeDir);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
for (const file of files) {
|
|
255
|
+
if (!file.endsWith(".json"))
|
|
256
|
+
continue;
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
259
|
+
if (!parsed || typeof parsed.instance_id !== "string")
|
|
260
|
+
continue;
|
|
261
|
+
const ts = Date.parse(parsed.last_heartbeat);
|
|
262
|
+
if (Number.isFinite(ts) && ts >= cutoffMs)
|
|
263
|
+
live.push(parsed.instance_id);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// skip malformed
|
|
267
|
+
}
|
|
268
|
+
if (live.length > 1)
|
|
269
|
+
return null; // ambiguous; bail early
|
|
270
|
+
}
|
|
271
|
+
return live.length === 1 ? live[0] : null;
|
|
140
272
|
}
|
|
141
273
|
/**
|
|
142
274
|
* Read and parse a heartbeat file. Returns null if the file is missing,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harnery",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Multi-agent coordination + harness adapters + portable CLI utilities for Claude Code / Cursor / Codex.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ryan Kelly",
|
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
"test": "bun test src tests",
|
|
133
133
|
"test:web": "cd web && bun install && bun test",
|
|
134
134
|
"test:integration": "bash tests/integration/run.sh",
|
|
135
|
+
"smoke": "node scripts/smoke-test.mjs",
|
|
135
136
|
"docs:dev": "bun --cwd docs run dev",
|
|
136
137
|
"docs:build": "bun --cwd docs run build",
|
|
137
138
|
"brand:showcase": "node scripts/build-brand-showcase.mjs"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import type { EmitContext } from "../commander.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lazy-fetch of the web dashboard for npm consumers.
|
|
10
|
+
*
|
|
11
|
+
* The published npm package ships the CLI + coord engine but deliberately
|
|
12
|
+
* excludes `web/` (ADR 0001/0003: keep the core lean for CLI-only users). The
|
|
13
|
+
* dashboard also imports harnery's `src/` directly (see `web/next.config.ts`),
|
|
14
|
+
* so it can't be lifted out as a standalone folder. Instead, the first time
|
|
15
|
+
* `harn web up` runs from an install without a local `web/`, we clone the
|
|
16
|
+
* harnery repo at the *matching version tag* into a cache dir and install the
|
|
17
|
+
* web app's deps. Subsequent runs reuse the cache.
|
|
18
|
+
*
|
|
19
|
+
* The only thing `web/` pulls from `src/` is `harnery/lib/scratch` (a single,
|
|
20
|
+
* dependency-free module), so the install is `web/` deps only: no root install,
|
|
21
|
+
* no Playwright browser download.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DEFAULT_REPO_URL = "https://github.com/ryanjkelly/harnery.git";
|
|
25
|
+
|
|
26
|
+
/** harnery's own package root: src/commands/ -> ../.. ; valid in both src/ and dist/. */
|
|
27
|
+
function packageRoot(): string {
|
|
28
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
return path.resolve(here, "..", "..");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readPkg(): { version: string; repoUrl: string } {
|
|
33
|
+
try {
|
|
34
|
+
const pkg = JSON.parse(readFileSync(path.join(packageRoot(), "package.json"), "utf8"));
|
|
35
|
+
const raw = typeof pkg.repository === "string" ? pkg.repository : (pkg.repository?.url ?? "");
|
|
36
|
+
const repoUrl = raw.replace(/^git\+/, "") || DEFAULT_REPO_URL;
|
|
37
|
+
return { version: typeof pkg.version === "string" ? pkg.version : "0.0.0", repoUrl };
|
|
38
|
+
} catch {
|
|
39
|
+
return { version: "0.0.0", repoUrl: DEFAULT_REPO_URL };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The git ref to fetch: HARNERY_WEB_REF override, else the version tag (`v<version>`). */
|
|
44
|
+
function resolveRef(version: string): string {
|
|
45
|
+
return process.env.HARNERY_WEB_REF || `v${version}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Per-ref cache dir under the XDG cache (matches harnery's `~/.cache/harnery/...`). */
|
|
49
|
+
function webCacheRoot(ref: string): string {
|
|
50
|
+
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
51
|
+
const safeRef = ref.replace(/[^\w.-]/g, "_");
|
|
52
|
+
return path.join(base, "harnery", "web", safeRef);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** bun if available (preferred), else npm. Both support `<runner> install` and `<runner> run <script>`. */
|
|
56
|
+
export function webRunner(): "bun" | "npm" {
|
|
57
|
+
const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
|
|
58
|
+
return r.status === 0 ? "bun" : "npm";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ensure a cached harnery checkout's `web/` exists for this version and return
|
|
63
|
+
* its path, fetching (clone + install) on first use. Returns null on failure
|
|
64
|
+
* (after emitting an actionable error).
|
|
65
|
+
*/
|
|
66
|
+
export function lazyFetchWebRoot(emit: EmitContext): string | null {
|
|
67
|
+
const { version, repoUrl } = readPkg();
|
|
68
|
+
const ref = resolveRef(version);
|
|
69
|
+
const cacheRoot = webCacheRoot(ref);
|
|
70
|
+
const webDir = path.join(cacheRoot, "web");
|
|
71
|
+
|
|
72
|
+
// node_modules in the cached web/ is the "fetched + installed" marker.
|
|
73
|
+
if (existsSync(path.join(webDir, "node_modules"))) {
|
|
74
|
+
emit.log(`harn web · using cached dashboard (${ref})`, "info");
|
|
75
|
+
return webDir;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
emit.log(
|
|
79
|
+
`harn web · dashboard is not bundled in the npm package; fetching harnery ${ref} (one-time)`,
|
|
80
|
+
"info",
|
|
81
|
+
);
|
|
82
|
+
emit.log(` into ${cacheRoot} · clone + web/ deps; pass --no-fetch to skip`, "info");
|
|
83
|
+
|
|
84
|
+
// Clear any partial/stale state so a retry is clean.
|
|
85
|
+
if (existsSync(cacheRoot)) rmSync(cacheRoot, { recursive: true, force: true });
|
|
86
|
+
mkdirSync(path.dirname(cacheRoot), { recursive: true });
|
|
87
|
+
|
|
88
|
+
const clone = spawnSync("git", ["clone", "--depth", "1", "--branch", ref, repoUrl, cacheRoot], {
|
|
89
|
+
stdio: "inherit",
|
|
90
|
+
});
|
|
91
|
+
if (clone.status !== 0) {
|
|
92
|
+
emit.error({
|
|
93
|
+
code: "web_fetch_failed",
|
|
94
|
+
message: `git clone of ${repoUrl} @ ${ref} failed${clone.error ? `: ${clone.error.message}` : ` (status ${clone.status})`}`,
|
|
95
|
+
hint:
|
|
96
|
+
"Fetching the dashboard needs git + network. Do it manually: " +
|
|
97
|
+
`git clone ${repoUrl} && cd harnery/web && ${webRunner()} install, then run it pointed at your project via HARNERY_COORD_ROOT. ` +
|
|
98
|
+
"See https://harnery.com/cli/web/.",
|
|
99
|
+
});
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const runner = webRunner();
|
|
104
|
+
emit.log(`harn web · installing dashboard deps (${runner} install in web/)`, "info");
|
|
105
|
+
const install = spawnSync(runner, ["install"], { cwd: webDir, stdio: "inherit" });
|
|
106
|
+
if (install.status !== 0) {
|
|
107
|
+
emit.error({
|
|
108
|
+
code: "web_fetch_failed",
|
|
109
|
+
message: `${runner} install in ${webDir} failed (status ${install.status})`,
|
|
110
|
+
hint: `Try manually: cd ${webDir} && ${runner} install`,
|
|
111
|
+
});
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
emit.log(`harn web · dashboard ready (cached at ${webDir})`, "info");
|
|
116
|
+
return webDir;
|
|
117
|
+
}
|
package/src/commands/web.ts
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import type { Command } from "commander";
|
|
6
6
|
import type { EmitContext } from "../commander.ts";
|
|
7
|
+
import { lazyFetchWebRoot, webRunner } from "./web-fetch.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* `harn web`: Next.js dashboard for harnery's coord state.
|
|
@@ -18,6 +19,9 @@ import type { EmitContext } from "../commander.ts";
|
|
|
18
19
|
* passing cwd here lets the dashboard show the right project regardless
|
|
19
20
|
* of where the user invokes `harn web up` from.
|
|
20
21
|
*
|
|
22
|
+
* The dashboard is not bundled in the npm package (ADR 0001/0003), so on an
|
|
23
|
+
* npm install `resolveWebRoot` lazy-fetches it on first use (see web-fetch.ts).
|
|
24
|
+
*
|
|
21
25
|
* Localhost-only by default. Network exposure is intentionally out of
|
|
22
26
|
* scope for v1.
|
|
23
27
|
*/
|
|
@@ -28,33 +32,31 @@ function webRoot(): string {
|
|
|
28
32
|
return path.resolve(here, "..", "..", "web");
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// Prefer bun when available; fall back to npx/npm for plain Node installs.
|
|
50
|
-
const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
|
|
51
|
-
return r.status === 0 ? "bun" : "npx";
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the web app root. A local `web/` (source checkout / submodule) wins.
|
|
37
|
+
* Otherwise, on an npm install, lazy-fetch it (clone + install) unless the
|
|
38
|
+
* caller passed --no-fetch, in which case emit the honest manual hint.
|
|
39
|
+
*/
|
|
40
|
+
function resolveWebRoot(emit: EmitContext, allowFetch: boolean): string | null {
|
|
41
|
+
const local = webRoot();
|
|
42
|
+
if (existsSync(path.join(local, "package.json"))) return local;
|
|
43
|
+
if (allowFetch) return lazyFetchWebRoot(emit);
|
|
44
|
+
emit.error({
|
|
45
|
+
code: "web_missing",
|
|
46
|
+
message: `harnery/web/ not found at ${local}`,
|
|
47
|
+
hint:
|
|
48
|
+
"The dashboard is not bundled in the npm package. Re-run without --no-fetch to fetch it " +
|
|
49
|
+
"automatically, or clone https://github.com/ryanjkelly/harnery and run it from there. " +
|
|
50
|
+
"See https://harnery.com/cli/web/.",
|
|
51
|
+
});
|
|
52
|
+
return null;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
interface UpOpts {
|
|
55
56
|
port: string;
|
|
56
57
|
coordRoot?: string;
|
|
57
58
|
prod?: boolean;
|
|
59
|
+
fetch?: boolean;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
export function registerWebCommand(program: Command, emit: EmitContext): void {
|
|
@@ -74,8 +76,9 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
|
|
|
74
76
|
"Override the coord root (default: cwd; web walks up looking for .harnery/)",
|
|
75
77
|
)
|
|
76
78
|
.option("--prod", "Use next start instead of next dev (requires prior build)")
|
|
79
|
+
.option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
|
|
77
80
|
.action(async (opts: UpOpts) => {
|
|
78
|
-
const root =
|
|
81
|
+
const root = resolveWebRoot(emit, opts.fetch !== false);
|
|
79
82
|
if (!root) {
|
|
80
83
|
process.exitCode = 1;
|
|
81
84
|
return;
|
|
@@ -101,7 +104,7 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
|
|
|
101
104
|
emit.log(`harn web · http://localhost:${port} (${mode})`, "info");
|
|
102
105
|
emit.log(`reading .harnery/ from: ${coordRoot}`, "info");
|
|
103
106
|
|
|
104
|
-
const child = spawn(
|
|
107
|
+
const child = spawn(webRunner(), ["run", mode], {
|
|
105
108
|
cwd: root,
|
|
106
109
|
env: {
|
|
107
110
|
...process.env,
|
|
@@ -130,14 +133,15 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
|
|
|
130
133
|
web
|
|
131
134
|
.command("build")
|
|
132
135
|
.description("Build the production bundle (next build).")
|
|
133
|
-
.
|
|
134
|
-
|
|
136
|
+
.option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
|
|
137
|
+
.action((opts: { fetch?: boolean }) => {
|
|
138
|
+
const root = resolveWebRoot(emit, opts.fetch !== false);
|
|
135
139
|
if (!root) {
|
|
136
140
|
process.exitCode = 1;
|
|
137
141
|
return;
|
|
138
142
|
}
|
|
139
143
|
emit.log("running next build…", "info");
|
|
140
|
-
const r = spawnSync(
|
|
144
|
+
const r = spawnSync(webRunner(), ["run", "build"], {
|
|
141
145
|
cwd: root,
|
|
142
146
|
stdio: "inherit",
|
|
143
147
|
});
|
|
@@ -149,8 +153,9 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
|
|
|
149
153
|
.description("Start the production server (next start). Requires prior `harn web build`.")
|
|
150
154
|
.option("-p, --port <port>", "Listen port", "9000")
|
|
151
155
|
.option("--coord-root <dir>", "Override the coord root")
|
|
152
|
-
.
|
|
153
|
-
|
|
156
|
+
.option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
|
|
157
|
+
.action((opts: { port: string; coordRoot?: string; fetch?: boolean }) => {
|
|
158
|
+
const root = resolveWebRoot(emit, opts.fetch !== false);
|
|
154
159
|
if (!root) {
|
|
155
160
|
process.exitCode = 1;
|
|
156
161
|
return;
|
|
@@ -170,7 +175,7 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
|
|
|
170
175
|
emit.log(`harn web · http://localhost:${port} (start)`, "info");
|
|
171
176
|
emit.log(`reading .harnery/ from: ${coordRoot}`, "info");
|
|
172
177
|
|
|
173
|
-
const child = spawn(
|
|
178
|
+
const child = spawn(webRunner(), ["run", "start"], {
|
|
174
179
|
cwd: root,
|
|
175
180
|
env: {
|
|
176
181
|
...process.env,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
13
|
// NOTE: kept dependency-free (node builtins only); this file is vendored verbatim into
|
|
14
14
|
// a downstream consumer, so it cannot import the coordEnv helper.
|
|
@@ -144,7 +144,7 @@ export function resolveOwner(): string | null {
|
|
|
144
144
|
*/
|
|
145
145
|
export function resolveOwnerWithSource(): {
|
|
146
146
|
owner: string | null;
|
|
147
|
-
source: "env" | "pidmap" | "pidmap_fallback" | "none";
|
|
147
|
+
source: "env" | "pidmap" | "pidmap_fallback" | "session_env" | "active_singleton" | "none";
|
|
148
148
|
} {
|
|
149
149
|
const envOwner = process.env.HARNERY_AGENT_COORD_OWNER?.trim();
|
|
150
150
|
if (envOwner) {
|
|
@@ -173,10 +173,134 @@ export function resolveOwnerWithSource(): {
|
|
|
173
173
|
}
|
|
174
174
|
pid = readPpid(pid);
|
|
175
175
|
}
|
|
176
|
-
|
|
177
|
-
owner: fallbackOwner,
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
if (fallbackOwner) {
|
|
177
|
+
return { owner: fallbackOwner, source: "pidmap_fallback" };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The ppid walk found nothing (e.g. a Bash-tool subshell whose process tree
|
|
181
|
+
// doesn't climb back to the harness anchor). Before guessing, try the
|
|
182
|
+
// harness-provided session id from the environment: every supported harness
|
|
183
|
+
// exports its session id into the tool subprocess env, and each heartbeat
|
|
184
|
+
// records the `session_id` it was minted under. Matching the two resolves us
|
|
185
|
+
// unambiguously even with multiple live agents, which is the case the
|
|
186
|
+
// singleton fallback below cannot handle.
|
|
187
|
+
const bySession = resolveOwnerBySessionEnv(root);
|
|
188
|
+
if (bySession) {
|
|
189
|
+
return { owner: bySession, source: "session_env" };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Last resort: if exactly one agent is live in this coord root, it's
|
|
193
|
+
// unambiguously us — resolve to it. This is what lets the bare `agents
|
|
194
|
+
// status` / `set-task` the stop hook recommends work without a `--session-id`
|
|
195
|
+
// flag in the common single-agent case. With 0 or 2+ live agents it would be
|
|
196
|
+
// a guess, so we stay null and require the explicit flag.
|
|
197
|
+
const singleton = resolveSingleActiveOwner(root);
|
|
198
|
+
if (singleton) {
|
|
199
|
+
return { owner: singleton, source: "active_singleton" };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { owner: null, source: "none" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Harness-exported session-id environment variables, in precedence order. Each
|
|
207
|
+
* supported harness propagates its session id into the env of the subprocess it
|
|
208
|
+
* spawns for a tool call (Claude Code's Bash tool, Cursor's terminal, Codex's
|
|
209
|
+
* shell). A coord CLI invoked as such a tool can therefore recover its own
|
|
210
|
+
* identity from the env even when the ppid walk misses.
|
|
211
|
+
*
|
|
212
|
+
* Kept inline (no shared-helper import) so this file stays node-builtins-only
|
|
213
|
+
* for the vendored downstream consumer.
|
|
214
|
+
*/
|
|
215
|
+
const SESSION_ID_ENV_VARS = [
|
|
216
|
+
"HARNERY_AGENT_COORD_SESSION_ID", // explicit override, wins if set
|
|
217
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
218
|
+
"CURSOR_SESSION_ID",
|
|
219
|
+
"CODEX_SESSION_ID",
|
|
220
|
+
] as const;
|
|
221
|
+
|
|
222
|
+
/** Read the first non-empty harness session-id env var, or null. */
|
|
223
|
+
function sessionIdFromEnv(): string | null {
|
|
224
|
+
for (const key of SESSION_ID_ENV_VARS) {
|
|
225
|
+
const v = process.env[key]?.trim();
|
|
226
|
+
if (v) return v;
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve the owner by matching the harness session-id env var against the
|
|
233
|
+
* `session_id` of a live heartbeat in this coord root. Returns the matching
|
|
234
|
+
* `instance_id`, or null if there's no session-id env var or no live heartbeat
|
|
235
|
+
* carries it. "Live" reuses the same 10-minute freshness window the singleton
|
|
236
|
+
* fallback applies, so a stale heartbeat from a prior session of the same id
|
|
237
|
+
* doesn't resolve.
|
|
238
|
+
*
|
|
239
|
+
* Exported for unit testing with an injectable root.
|
|
240
|
+
*/
|
|
241
|
+
export function resolveOwnerBySessionEnv(root: string): string | null {
|
|
242
|
+
const sessionId = sessionIdFromEnv();
|
|
243
|
+
if (!sessionId) return null;
|
|
244
|
+
|
|
245
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
246
|
+
if (!existsSync(activeDir)) return null;
|
|
247
|
+
const FRESHNESS_SECS = 600;
|
|
248
|
+
const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
|
|
249
|
+
let files: string[];
|
|
250
|
+
try {
|
|
251
|
+
files = readdirSync(activeDir);
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
for (const file of files) {
|
|
256
|
+
if (!file.endsWith(".json")) continue;
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
259
|
+
if (!parsed || parsed.session_id !== sessionId) continue;
|
|
260
|
+
if (typeof parsed.instance_id !== "string") continue;
|
|
261
|
+
const ts = Date.parse(parsed.last_heartbeat);
|
|
262
|
+
if (Number.isFinite(ts) && ts >= cutoffMs) return parsed.instance_id;
|
|
263
|
+
} catch {
|
|
264
|
+
// skip malformed
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Return the instance_id of the sole live agent in this coord root, or null
|
|
272
|
+
* if there are zero or more than one. "Live" reuses the 10-minute heartbeat
|
|
273
|
+
* freshness window the rest of the agents surface applies (kept inline as a
|
|
274
|
+
* literal so this file stays node-builtins-only for vendored downstream use).
|
|
275
|
+
*
|
|
276
|
+
* Exported for unit testing with an injectable root (the caller in
|
|
277
|
+
* `resolveOwnerWithSource` passes `monorepoRoot()`).
|
|
278
|
+
*/
|
|
279
|
+
export function resolveSingleActiveOwner(root: string): string | null {
|
|
280
|
+
const activeDir = resolve(root, ".harnery", "active");
|
|
281
|
+
if (!existsSync(activeDir)) return null;
|
|
282
|
+
const FRESHNESS_SECS = 600;
|
|
283
|
+
const cutoffMs = Date.now() - FRESHNESS_SECS * 1000;
|
|
284
|
+
const live: string[] = [];
|
|
285
|
+
let files: string[];
|
|
286
|
+
try {
|
|
287
|
+
files = readdirSync(activeDir);
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
for (const file of files) {
|
|
292
|
+
if (!file.endsWith(".json")) continue;
|
|
293
|
+
try {
|
|
294
|
+
const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
|
|
295
|
+
if (!parsed || typeof parsed.instance_id !== "string") continue;
|
|
296
|
+
const ts = Date.parse(parsed.last_heartbeat);
|
|
297
|
+
if (Number.isFinite(ts) && ts >= cutoffMs) live.push(parsed.instance_id);
|
|
298
|
+
} catch {
|
|
299
|
+
// skip malformed
|
|
300
|
+
}
|
|
301
|
+
if (live.length > 1) return null; // ambiguous; bail early
|
|
302
|
+
}
|
|
303
|
+
return live.length === 1 ? live[0]! : null;
|
|
180
304
|
}
|
|
181
305
|
|
|
182
306
|
/**
|