tryharness 0.3.0 → 0.4.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/README.md +32 -13
- package/assets/opencode-plugin.js +27 -0
- package/assets/pi-extension.ts +92 -0
- package/cli.mjs +237 -55
- package/lib/adapters.mjs +81 -0
- package/lib/commands.mjs +57 -0
- package/lib/control-client.mjs +31 -0
- package/lib/daemon.mjs +339 -0
- package/lib/files.mjs +59 -0
- package/lib/mcp-stdio.mjs +96 -0
- package/lib/parsers.mjs +162 -0
- package/lib/paths.mjs +35 -0
- package/lib/runner.mjs +89 -0
- package/lib/runtime-install.mjs +75 -0
- package/lib/service.mjs +99 -0
- package/lib/store.mjs +199 -0
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -1,22 +1,41 @@
|
|
|
1
1
|
# tryharness
|
|
2
2
|
|
|
3
|
-
Connect this machine to [Harness](https://tryharness.ai)
|
|
3
|
+
Connect the coding agents installed on this machine to [Harness](https://tryharness.ai):
|
|
4
4
|
|
|
5
|
-
```
|
|
5
|
+
```sh
|
|
6
6
|
npx tryharness
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
Supports the Node.js 18, 20, 22, and 24 release lines.
|
|
10
|
+
|
|
11
|
+
That one command pairs the machine, detects Claude Code, Codex, pi, and opencode,
|
|
12
|
+
configures every installed agent, installs the Harness daemon as a user-level login
|
|
13
|
+
service, validates readiness, and starts indexing existing coding sessions locally in
|
|
14
|
+
SQLite. Setup returns as soon as the daemon is ready; indexing continues in the
|
|
15
|
+
background and does not require an open Harness tab.
|
|
16
|
+
|
|
17
|
+
The command is idempotent. Run it again after installing another agent or to repair its
|
|
18
|
+
configuration.
|
|
13
19
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
## Operations
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npx tryharness status
|
|
24
|
+
npx tryharness doctor
|
|
25
|
+
npx tryharness sync --agent codex
|
|
26
|
+
npx tryharness logs --follow
|
|
27
|
+
npx tryharness handoff codex --file ./implementation-brief.md
|
|
28
|
+
npx tryharness disconnect
|
|
29
|
+
```
|
|
17
30
|
|
|
18
|
-
|
|
19
|
-
|
|
31
|
+
`status` is the normal health view. `doctor` verifies pairing, the login service, the
|
|
32
|
+
daemon, the bridge, the database, and agent discovery. `sync` requests a background
|
|
33
|
+
rescan; it is diagnostic, not part of normal setup.
|
|
20
34
|
|
|
21
|
-
|
|
22
|
-
|
|
35
|
+
The daemon dials out to Harness over one authenticated WebSocket. Its local control
|
|
36
|
+
socket is permissioned to the current user. When OpenCode is installed, the daemon also
|
|
37
|
+
starts OpenCode's own helper on a random loopback-only port with a random per-process
|
|
38
|
+
password so it can index OpenCode history; no fixed or unauthenticated port is used.
|
|
39
|
+
Connection tokens stay in `~/.harness/config.json` with owner-only permissions. Coding
|
|
40
|
+
history and handoffs stay in the local SQLite database unless an explicit operation
|
|
41
|
+
sends content through Harness.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Installed globally by tryharness. It only nudges the local daemon; indexing and retry
|
|
2
|
+
// state stay in the daemon's SQLite database and never block opencode's idle event.
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const socketPath = process.platform === "win32"
|
|
8
|
+
? "\\\\.\\pipe\\tryharness-daemon"
|
|
9
|
+
: path.join(os.homedir(), ".harness", "daemon.sock");
|
|
10
|
+
|
|
11
|
+
function notify(message) {
|
|
12
|
+
try {
|
|
13
|
+
const socket = net.createConnection(socketPath);
|
|
14
|
+
socket.setTimeout(1_000);
|
|
15
|
+
socket.on("connect", () => socket.end(JSON.stringify(message) + "\n"));
|
|
16
|
+
socket.on("error", () => {});
|
|
17
|
+
socket.on("timeout", () => socket.destroy());
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const TryHarnessPlugin = async ({ directory }) => ({
|
|
22
|
+
event: async ({ event }) => {
|
|
23
|
+
if (event?.type !== "session.idle") return;
|
|
24
|
+
const sessionId = event?.properties?.sessionID || event?.properties?.sessionId;
|
|
25
|
+
if (sessionId) notify({ command: "sync", agent: "opencode", sessionId, cwd: directory });
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// Installed globally by tryharness. pi has no native MCP client, so the extension maps
|
|
3
|
+
// the connected Harness MCP surface into normal pi tools without blocking pi startup.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const CONFIG_PATH = path.join(os.homedir(), ".harness", "config.json");
|
|
9
|
+
let nextId = 1;
|
|
10
|
+
let typeApi = null;
|
|
11
|
+
|
|
12
|
+
function config() {
|
|
13
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function rpc(method, params = {}) {
|
|
17
|
+
const current = config();
|
|
18
|
+
if (!current.origin || !current.token) throw new Error("Harness is not connected. Run: npx tryharness");
|
|
19
|
+
const response = await fetch(String(current.origin).replace(/\/$/, "") + "/api/mcp", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "content-type": "application/json", authorization: "Bearer " + current.token },
|
|
22
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }),
|
|
23
|
+
signal: AbortSignal.timeout(90_000),
|
|
24
|
+
});
|
|
25
|
+
const payload = await response.json().catch(() => null);
|
|
26
|
+
if (!response.ok || payload?.error) throw new Error(payload?.error?.message || "Harness returned HTTP " + response.status);
|
|
27
|
+
return payload?.result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function types() {
|
|
31
|
+
if (typeApi) return typeApi;
|
|
32
|
+
try { typeApi = (await import("@sinclair/typebox")).Type; }
|
|
33
|
+
catch { const loaded = await import("typebox"); typeApi = loaded.Type || loaded; }
|
|
34
|
+
return typeApi;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toType(schema, Type) {
|
|
38
|
+
if (!schema || typeof schema !== "object") return Type.Any();
|
|
39
|
+
const opts = typeof schema.description === "string" ? { description: schema.description } : {};
|
|
40
|
+
if (Array.isArray(schema.enum) && schema.enum.length) return Type.Union(schema.enum.map((value) => Type.Literal(value)), opts);
|
|
41
|
+
if (Array.isArray(schema.anyOf || schema.oneOf)) return Type.Union((schema.anyOf || schema.oneOf).map((entry) => toType(entry, Type)), opts);
|
|
42
|
+
if (schema.type === "object") {
|
|
43
|
+
const required = new Set(schema.required || []);
|
|
44
|
+
const properties = {};
|
|
45
|
+
for (const [name, child] of Object.entries(schema.properties || {})) {
|
|
46
|
+
const converted = toType(child, Type);
|
|
47
|
+
properties[name] = required.has(name) ? converted : Type.Optional(converted);
|
|
48
|
+
}
|
|
49
|
+
return Type.Object(properties, { ...opts, additionalProperties: schema.additionalProperties === true });
|
|
50
|
+
}
|
|
51
|
+
if (schema.type === "array") return Type.Array(toType(schema.items, Type), opts);
|
|
52
|
+
if (schema.type === "string") return Type.String(opts);
|
|
53
|
+
if (schema.type === "integer") return Type.Integer(opts);
|
|
54
|
+
if (schema.type === "number") return Type.Number(opts);
|
|
55
|
+
if (schema.type === "boolean") return Type.Boolean(opts);
|
|
56
|
+
if (schema.type === "null") return Type.Null(opts);
|
|
57
|
+
return Type.Any(opts);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default function tryHarness(pi) {
|
|
61
|
+
const installed = new Set();
|
|
62
|
+
let connecting = null;
|
|
63
|
+
const connect = async (ctx) => {
|
|
64
|
+
if (connecting) return connecting;
|
|
65
|
+
connecting = (async () => {
|
|
66
|
+
const Type = await types();
|
|
67
|
+
const listing = await rpc("tools/list");
|
|
68
|
+
for (const def of listing?.tools || []) {
|
|
69
|
+
if (!def?.name || def.name === "report_session" || installed.has(def.name)) continue;
|
|
70
|
+
pi.registerTool({
|
|
71
|
+
name: def.name,
|
|
72
|
+
label: "Harness: " + def.name.replaceAll("_", " "),
|
|
73
|
+
description: def.description || "Use this connected Harness capability.",
|
|
74
|
+
parameters: toType(def.inputSchema, Type),
|
|
75
|
+
async execute(_id, params) {
|
|
76
|
+
try {
|
|
77
|
+
const result = await rpc("tools/call", { name: def.name, arguments: params || {} });
|
|
78
|
+
return { content: result?.content || [{ type: "text", text: "Harness returned no content." }], details: {}, isError: result?.isError === true };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return { content: [{ type: "text", text: error?.message || "Harness call failed." }], details: {}, isError: true };
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
installed.add(def.name);
|
|
85
|
+
}
|
|
86
|
+
ctx?.ui?.notify?.("Harness connected", "info");
|
|
87
|
+
})().finally(() => { connecting = null; });
|
|
88
|
+
return connecting;
|
|
89
|
+
};
|
|
90
|
+
pi.on("session_start", (_event, ctx) => void connect(ctx).catch((error) => ctx?.ui?.notify?.("Harness unavailable: " + error?.message, "warning")));
|
|
91
|
+
pi.registerCommand("harness-reconnect", { description: "Reconnect Harness", handler: async (_args, ctx) => connect(ctx) });
|
|
92
|
+
}
|
package/cli.mjs
CHANGED
|
@@ -1,69 +1,251 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// tryharness: thin bootstrap. The real logic lives on the Harness server and is fetched
|
|
3
|
-
// fresh on every run, so this package never needs republishing to pick up improvements
|
|
4
|
-
// (a cached copy in ~/.harness runs when offline). Works on Node 18+ — when the runtime
|
|
5
|
-
// lacks a built-in WebSocket (added in Node 22), the bundled `ws` package fills it in,
|
|
6
|
-
// which is why scripts run IN-PROCESS here instead of being spawned. Commands:
|
|
7
|
-
// npx tryharness pair this machine + set up every detected agent
|
|
8
|
-
// npx tryharness --agent codex set up only one detected agent
|
|
9
|
-
// npx tryharness bridge start the task bridge (after pairing)
|
|
10
|
-
// npx tryharness sync explicitly import existing agent sessions
|
|
11
|
-
// npx tryharness drain retry any session reports that missed
|
|
12
2
|
import fs from "node:fs";
|
|
13
|
-
import path from "node:path";
|
|
14
3
|
import os from "node:os";
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { configureAgents, AGENTS, removeAgentConfigurations } from "./lib/adapters.mjs";
|
|
7
|
+
import { detectAgents } from "./lib/commands.mjs";
|
|
8
|
+
import { daemonRequest } from "./lib/control-client.mjs";
|
|
9
|
+
import { ensureDir, readJson, writeJsonAtomic } from "./lib/files.mjs";
|
|
10
|
+
import { paths } from "./lib/paths.mjs";
|
|
11
|
+
import { installRuntime } from "./lib/runtime-install.mjs";
|
|
12
|
+
import { installService, serviceInfo, uninstallService } from "./lib/service.mjs";
|
|
25
13
|
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
14
|
+
const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const PACKAGE = readJson(path.join(SOURCE_ROOT, "package.json"), { version: "0.4.1" });
|
|
16
|
+
const VERSION = PACKAGE.version;
|
|
17
|
+
const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
|
|
18
|
+
if (!Number.isFinite(NODE_MAJOR) || NODE_MAJOR < 18) {
|
|
19
|
+
console.error(`tryharness requires Node.js 18 or newer (current: ${process.version}). Upgrade Node, then rerun: npx tryharness`);
|
|
31
20
|
process.exit(1);
|
|
32
21
|
}
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const command = ["--help", "-h"].includes(argv[0])
|
|
24
|
+
? (argv.shift(), "help")
|
|
25
|
+
: argv[0] === "--version"
|
|
26
|
+
? (argv.shift(), "version")
|
|
27
|
+
: argv[0] && !argv[0].startsWith("-") ? argv.shift() : "connect";
|
|
33
28
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
29
|
+
function arg(flag, fallback = null) {
|
|
30
|
+
const index = argv.indexOf(flag);
|
|
31
|
+
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
32
|
+
}
|
|
33
|
+
function has(flag) { return argv.includes(flag); }
|
|
34
|
+
function origin() {
|
|
35
|
+
const saved = readJson(paths.config, {});
|
|
36
|
+
return String(arg("--origin", saved.origin || "https://tryharness.ai")).replace(/\/$/, "");
|
|
39
37
|
}
|
|
40
|
-
const originIndex = argv.indexOf("--origin");
|
|
41
|
-
const origin = (originIndex !== -1 && argv[originIndex + 1] ? argv[originIndex + 1] : savedOrigin || "https://tryharness.ai").replace(/\/$/, "");
|
|
42
38
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
39
|
+
async function validateConnection(config) {
|
|
40
|
+
if (!config.origin || !config.token) return false;
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(String(config.origin).replace(/\/$/, "") + "/api/mcp", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "content-type": "application/json", authorization: "Bearer " + config.token },
|
|
45
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "tryharness", version: VERSION } } }),
|
|
46
|
+
signal: AbortSignal.timeout(10_000),
|
|
47
|
+
});
|
|
48
|
+
return response.ok && !((await response.json().catch(() => null))?.error);
|
|
49
|
+
} catch { return false; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function pair(pairOrigin, agents) {
|
|
53
|
+
const response = await fetch(pairOrigin + "/api/pair/start", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
body: JSON.stringify({ hostname: os.hostname().slice(0, 64), agents }),
|
|
57
|
+
signal: AbortSignal.timeout(15_000),
|
|
58
|
+
});
|
|
59
|
+
if (!response.ok) throw new Error(`pairing unavailable (HTTP ${response.status})`);
|
|
60
|
+
const { pairId, code } = await response.json();
|
|
61
|
+
console.log(`\n Your code: ${code}\n`);
|
|
62
|
+
console.log("In Harness: Settings -> Connections -> Connect a machine -> enter the code.");
|
|
63
|
+
console.log("Waiting for approval (expires in 10 minutes)...");
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
fetch(pairOrigin + "/api/pair/wait/" + pairId, { headers: { accept: "text/event-stream" } }).then(async (waitResponse) => {
|
|
66
|
+
if (!waitResponse.ok || !waitResponse.body) throw new Error("pairing stream failed");
|
|
67
|
+
const reader = waitResponse.body.getReader();
|
|
68
|
+
const decoder = new TextDecoder();
|
|
69
|
+
let buffer = "";
|
|
70
|
+
for (;;) {
|
|
71
|
+
const { done, value } = await reader.read();
|
|
72
|
+
if (done) break;
|
|
73
|
+
buffer += decoder.decode(value, { stream: true });
|
|
74
|
+
const frames = buffer.split("\n\n");
|
|
75
|
+
buffer = frames.pop() || "";
|
|
76
|
+
for (const frame of frames) {
|
|
77
|
+
const line = frame.split("\n").find((entry) => entry.startsWith("data: "));
|
|
78
|
+
if (!line) continue;
|
|
79
|
+
const payload = JSON.parse(line.slice(6));
|
|
80
|
+
if (payload.token) { resolve(payload.token); return; }
|
|
81
|
+
if (payload.error) { reject(new Error(payload.error)); return; }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
reject(new Error("pairing stream ended without approval"));
|
|
85
|
+
}).catch(reject);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function waitForDaemon(timeoutMs = 12_000) {
|
|
90
|
+
const deadline = Date.now() + timeoutMs;
|
|
91
|
+
let lastError;
|
|
92
|
+
let lastStatus;
|
|
93
|
+
while (Date.now() < deadline) {
|
|
94
|
+
try {
|
|
95
|
+
lastStatus = await daemonRequest({ command: "status" }, 1_000);
|
|
96
|
+
if (lastStatus.connected) return lastStatus;
|
|
97
|
+
if (lastStatus.lastError) lastError = new Error(lastStatus.lastError);
|
|
98
|
+
}
|
|
99
|
+
catch (error) { lastError = error; }
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`daemon started but its authenticated Harness bridge did not become ready: ${lastError?.message || lastStatus?.lastError || "timeout"}. Run: npx tryharness doctor`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function printAgentStates(states) {
|
|
106
|
+
for (const state of states) {
|
|
107
|
+
const icon = !state.installed ? "–" : state.configured ? "✓" : "!";
|
|
108
|
+
console.log(` ${icon} ${state.agent}: ${state.detail}`);
|
|
55
109
|
}
|
|
56
|
-
console.error("could not refresh from " + origin + " - running the cached copy");
|
|
57
110
|
}
|
|
58
111
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
112
|
+
async function connect() {
|
|
113
|
+
ensureDir(paths.home);
|
|
114
|
+
const detected = detectAgents();
|
|
115
|
+
const installed = detected.filter((entry) => entry.path).map((entry) => entry.agent);
|
|
116
|
+
const requested = argv.flatMap((value, index) => value === "--agent" && argv[index + 1] ? [argv[index + 1]] : []);
|
|
117
|
+
for (const agent of requested) if (!AGENTS.includes(agent)) throw new Error(`unknown agent '${agent}'`);
|
|
118
|
+
const selected = requested.length ? installed.filter((agent) => requested.includes(agent)) : installed;
|
|
119
|
+
console.log(`Harness setup ${VERSION}`);
|
|
120
|
+
console.log("detected: " + (installed.join(", ") || "no supported coding agents"));
|
|
121
|
+
|
|
122
|
+
const pairOrigin = origin();
|
|
123
|
+
let config = readJson(paths.config, {});
|
|
124
|
+
const reusable = !has("--reconnect") && config.origin === pairOrigin && await validateConnection(config);
|
|
125
|
+
if (reusable) console.log("pairing: already connected");
|
|
126
|
+
else {
|
|
127
|
+
const token = await pair(pairOrigin, selected);
|
|
128
|
+
config = { ...config, origin: pairOrigin, token };
|
|
129
|
+
writeJsonAtomic(paths.config, config);
|
|
130
|
+
console.log("pairing: approved");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The persistent runtime is version-pinned; neither MCP clients nor the login service
|
|
134
|
+
// point into npm's temporary npx cache.
|
|
135
|
+
const runtimeRoot = installRuntime({ sourceRoot: SOURCE_ROOT, version: VERSION });
|
|
136
|
+
const states = configureAgents({ runtimeRoot, selected });
|
|
137
|
+
const stateMap = Object.fromEntries(states.map((state) => [state.agent, state]));
|
|
138
|
+
config = { ...readJson(paths.config, config), version: VERSION, runtimeRoot, agentStates: stateMap };
|
|
139
|
+
writeJsonAtomic(paths.config, config);
|
|
140
|
+
printAgentStates(states);
|
|
141
|
+
|
|
142
|
+
const service = installService({ runtimeRoot });
|
|
143
|
+
const ready = await waitForDaemon();
|
|
144
|
+
console.log(`daemon: ready via ${service.manager} and connected to Harness`);
|
|
145
|
+
const failed = states.filter((state) => selected.includes(state.agent) && state.installed && !state.configured);
|
|
146
|
+
if (failed.length) {
|
|
147
|
+
throw new Error(`setup is incomplete for ${failed.map((state) => state.agent).join(", ")}. Nothing was hidden; run npx tryharness doctor after fixing the reported CLI error, then rerun setup.`);
|
|
148
|
+
}
|
|
149
|
+
console.log("history: indexing locally in the background");
|
|
150
|
+
console.log("Done. You can close this terminal.");
|
|
64
151
|
}
|
|
65
152
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
await
|
|
153
|
+
async function status() {
|
|
154
|
+
const fallback = readJson(paths.status, null);
|
|
155
|
+
let current;
|
|
156
|
+
try { current = await daemonRequest({ command: "status" }); }
|
|
157
|
+
catch { current = fallback ? { ok: false, ...fallback } : null; }
|
|
158
|
+
const service = serviceInfo();
|
|
159
|
+
if (!current) {
|
|
160
|
+
console.log("Harness daemon: not running");
|
|
161
|
+
console.log(`service: ${service.installed ? "installed" : "not installed"}`);
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
console.log(`Harness daemon ${current.version || "unknown"}: ${current.ok === false ? "not responding" : "running"} (pid ${current.pid || "?"})`);
|
|
166
|
+
console.log(`Harness bridge: ${current.connected ? "connected" : "disconnected"}`);
|
|
167
|
+
console.log(`history: ${current.indexing ? "indexing" : "ready"}${current.lastIndexedAt ? ` · last scan ${current.lastIndexedAt}` : ""}`);
|
|
168
|
+
for (const state of current.agents || []) {
|
|
169
|
+
const count = Number(current.counts?.[state.agent] || 0);
|
|
170
|
+
console.log(` ${state.agent}: ${state.installed ? state.configured ? "ready" : "installed, configuration needs repair" : "not installed"} · ${count} sessions`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function doctor() {
|
|
175
|
+
console.log(`tryharness ${VERSION}`);
|
|
176
|
+
const config = readJson(paths.config, {});
|
|
177
|
+
console.log(`pairing: ${await validateConnection(config) ? "valid" : "missing or invalid"}`);
|
|
178
|
+
const service = serviceInfo();
|
|
179
|
+
console.log(`service: ${service.installed ? `installed (${service.manager})` : `not installed (${service.manager})`}`);
|
|
180
|
+
try {
|
|
181
|
+
const report = await daemonRequest({ command: "doctor" });
|
|
182
|
+
console.log(`daemon: running (pid ${report.status.pid})`);
|
|
183
|
+
console.log(`bridge: ${report.status.connected ? "connected" : `disconnected${report.status.lastError ? ` — ${report.status.lastError}` : ""}`}`);
|
|
184
|
+
console.log(`database: ${report.database}`);
|
|
185
|
+
console.log(`control socket: ${report.socket}`);
|
|
186
|
+
console.log(`log: ${report.log}`);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.log(`daemon: unavailable — ${error.message}`);
|
|
189
|
+
process.exitCode = 1;
|
|
190
|
+
}
|
|
191
|
+
for (const found of detectAgents()) console.log(`${found.agent}: ${found.path || "not installed"}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function sync() {
|
|
195
|
+
const agent = arg("--agent", "all");
|
|
196
|
+
if (agent !== "all" && !AGENTS.includes(agent)) throw new Error(`unknown agent '${agent}'`);
|
|
197
|
+
const response = await daemonRequest({ command: "sync", agent });
|
|
198
|
+
if (!response.ok) throw new Error(response.error || "sync rejected");
|
|
199
|
+
console.log(`Local history scan started for ${agent}. It runs in the daemon; this terminal does not need to stay open.`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function handoff() {
|
|
203
|
+
const agent = argv[0];
|
|
204
|
+
if (!AGENTS.includes(agent)) throw new Error("usage: tryharness handoff <claude-code|codex|pi|opencode> --file <brief.md>");
|
|
205
|
+
const file = arg("--file");
|
|
206
|
+
let body = "";
|
|
207
|
+
if (file) body = fs.readFileSync(path.resolve(file), "utf8");
|
|
208
|
+
else if (!process.stdin.isTTY) for await (const chunk of process.stdin) body += chunk;
|
|
209
|
+
else body = arg("--body", "");
|
|
210
|
+
if (!body.trim()) throw new Error("handoff needs --file, --body, or piped input");
|
|
211
|
+
const response = await daemonRequest({ command: "handoffs.create", agent, sessionId: arg("--session"), title: arg("--title", path.basename(file || "Harness handoff")), body, source: file ? path.resolve(file) : "tryharness CLI" });
|
|
212
|
+
if (!response.ok) throw new Error(response.error || "handoff failed");
|
|
213
|
+
console.log(`Handoff ${response.id} is ready for ${agent}. Harness's get_handoff tool can read it.`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function logs() {
|
|
217
|
+
if (!fs.existsSync(paths.log)) { console.log("No daemon log yet."); return; }
|
|
218
|
+
const lines = fs.readFileSync(paths.log, "utf8").split("\n");
|
|
219
|
+
process.stdout.write(lines.slice(-Math.max(1, Number(arg("--lines", "100")))).join("\n"));
|
|
220
|
+
if (has("--follow")) fs.watchFile(paths.log, { interval: 500 }, () => process.stdout.write(fs.readFileSync(paths.log, "utf8").split("\n").slice(-20).join("\n")));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function disconnect() {
|
|
224
|
+
uninstallService();
|
|
225
|
+
try { await daemonRequest({ command: "shutdown" }, 2_000); } catch {}
|
|
226
|
+
removeAgentConfigurations();
|
|
227
|
+
try { fs.renameSync(paths.config, paths.config + `.disconnected-${Date.now()}`); } catch {}
|
|
228
|
+
for (const target of [paths.status, paths.socket]) try { fs.unlinkSync(target); } catch {}
|
|
229
|
+
console.log("Harness disconnected. The local history database was preserved.");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function help() {
|
|
233
|
+
console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness sync [--agent] rescan local coding history in the background\n tryharness handoff <agent> --file <path>\n tryharness logs [--follow]\n tryharness disconnect\n`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
if (command === "connect" || command === "setup") await connect();
|
|
238
|
+
else if (command === "status" || command === "bridge") await status();
|
|
239
|
+
else if (command === "doctor") await doctor();
|
|
240
|
+
else if (command === "sync" || command === "backfill" || command === "drain") await sync();
|
|
241
|
+
else if (command === "handoff") await handoff();
|
|
242
|
+
else if (command === "logs") logs();
|
|
243
|
+
else if (command === "disconnect") await disconnect();
|
|
244
|
+
else if (command === "daemon") { const { startDaemon } = await import("./lib/daemon.mjs"); await startDaemon(); }
|
|
245
|
+
else if (command === "version") console.log(VERSION);
|
|
246
|
+
else if (["help", "--help", "-h"].includes(command)) help();
|
|
247
|
+
else throw new Error(`unknown command '${command}' (run: tryharness help)`);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.error(`tryharness: ${error.message}`);
|
|
250
|
+
process.exitCode = 1;
|
|
251
|
+
}
|
package/lib/adapters.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { detectAgents, run } from "./commands.mjs";
|
|
5
|
+
import { ensureDir, readJson } from "./files.mjs";
|
|
6
|
+
import { paths } from "./paths.mjs";
|
|
7
|
+
|
|
8
|
+
export const AGENTS = ["claude-code", "codex", "pi", "opencode"];
|
|
9
|
+
|
|
10
|
+
function installAsset(source, target) {
|
|
11
|
+
ensureDir(path.dirname(target));
|
|
12
|
+
fs.copyFileSync(source, target);
|
|
13
|
+
try { fs.chmodSync(target, 0o600); } catch {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function diagnostic(result) {
|
|
17
|
+
return (result.stderr || result.stdout || result.error || "command failed").replace(/\s+/g, " ").trim().slice(0, 240);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function integrationAssets(homeDir = os.homedir()) {
|
|
21
|
+
return {
|
|
22
|
+
pi: path.join(homeDir, ".pi", "agent", "extensions", "tryharness.ts"),
|
|
23
|
+
opencode: path.join(homeDir, ".config", "opencode", "plugins", "tryharness.js"),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.homedir(), detected = detectAgents(), execute = run, connection = readJson(paths.config, {}) }) {
|
|
28
|
+
const byAgent = new Map(detected.map((entry) => [entry.agent, entry]));
|
|
29
|
+
const mcpPath = path.join(runtimeRoot, "lib", "mcp-stdio.mjs");
|
|
30
|
+
const assets = integrationAssets(homeDir);
|
|
31
|
+
const states = [];
|
|
32
|
+
for (const agent of AGENTS) {
|
|
33
|
+
const found = byAgent.get(agent);
|
|
34
|
+
if (!found?.path) {
|
|
35
|
+
states.push({ agent, installed: false, configured: false, detail: "not installed" });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!selected.includes(agent)) {
|
|
39
|
+
states.push({ agent, installed: true, configured: false, detail: "not selected" });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (agent === "claude-code") {
|
|
43
|
+
execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]);
|
|
44
|
+
const added = execute(found.path, ["mcp", "add", "--scope", "user", "harness", "--", process.execPath, mcpPath, "--agent", agent]);
|
|
45
|
+
states.push({ agent, installed: true, configured: added.ok, detail: added.ok ? "MCP configured" : diagnostic(added) });
|
|
46
|
+
} else if (agent === "codex") {
|
|
47
|
+
execute(found.path, ["mcp", "remove", "harness"]);
|
|
48
|
+
const added = execute(found.path, ["mcp", "add", "harness", "--", process.execPath, mcpPath, "--agent", agent]);
|
|
49
|
+
states.push({ agent, installed: true, configured: added.ok, detail: added.ok ? "MCP configured" : diagnostic(added) });
|
|
50
|
+
} else if (agent === "pi") {
|
|
51
|
+
installAsset(path.join(runtimeRoot, "assets", "pi-extension.ts"), assets.pi);
|
|
52
|
+
states.push({ agent, installed: true, configured: fs.existsSync(assets.pi), detail: "extension installed" });
|
|
53
|
+
} else {
|
|
54
|
+
installAsset(path.join(runtimeRoot, "assets", "opencode-plugin.js"), assets.opencode);
|
|
55
|
+
// Keep opencode's already-working remote MCP configuration. The daemon/plugin path
|
|
56
|
+
// handles history; this command supplies Harness memory tools.
|
|
57
|
+
execute(found.path, ["mcp", "remove", "harness"]);
|
|
58
|
+
const added = execute(found.path, ["mcp", "add", "harness", "--url", String(connection.origin).replace(/\/$/, "") + "/api/mcp", "--header", "Authorization=Bearer " + connection.token]);
|
|
59
|
+
states.push({ agent, installed: true, configured: added.ok, detail: added.ok ? "MCP and plugin configured" : `plugin installed; MCP: ${diagnostic(added)}` });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return states;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function agentHealth(states) {
|
|
66
|
+
return Object.fromEntries(states.map((state) => [state.agent, state]));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function removeAgentConfigurations({ homeDir = os.homedir(), detected = detectAgents(), execute = run } = {}) {
|
|
70
|
+
for (const found of detected) {
|
|
71
|
+
if (!found.path) continue;
|
|
72
|
+
if (found.agent === "claude-code") execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]);
|
|
73
|
+
else if (found.agent === "codex" || found.agent === "opencode") execute(found.path, ["mcp", "remove", "harness"]);
|
|
74
|
+
}
|
|
75
|
+
const assets = integrationAssets(homeDir);
|
|
76
|
+
for (const target of Object.values(assets)) {
|
|
77
|
+
try { fs.unlinkSync(target); } catch (error) {
|
|
78
|
+
if (error?.code !== "ENOENT") throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/lib/commands.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export function resolveCommand(command, env = process.env) {
|
|
6
|
+
const pathValue = env.PATH || "";
|
|
7
|
+
const extensions = process.platform === "win32"
|
|
8
|
+
? (env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
|
|
9
|
+
: [""];
|
|
10
|
+
for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
|
|
11
|
+
for (const extension of extensions) {
|
|
12
|
+
const candidate = path.join(dir, process.platform === "win32" ? command + extension.toLowerCase() : command);
|
|
13
|
+
const alternate = path.join(dir, command + extension);
|
|
14
|
+
for (const file of candidate === alternate ? [candidate] : [candidate, alternate]) {
|
|
15
|
+
try {
|
|
16
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
17
|
+
return file;
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function run(command, args, options = {}) {
|
|
26
|
+
const result = spawnSync(command, args, {
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
timeout: options.timeout ?? 20_000,
|
|
29
|
+
maxBuffer: options.maxBuffer ?? 10_000_000,
|
|
30
|
+
cwd: options.cwd,
|
|
31
|
+
env: options.env,
|
|
32
|
+
input: options.input,
|
|
33
|
+
stdio: options.stdio,
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
ok: result.status === 0 && !result.error,
|
|
37
|
+
status: result.status,
|
|
38
|
+
stdout: String(result.stdout || ""),
|
|
39
|
+
stderr: String(result.stderr || ""),
|
|
40
|
+
error: result.error?.message,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const AGENT_COMMANDS = {
|
|
45
|
+
"claude-code": "claude",
|
|
46
|
+
codex: "codex",
|
|
47
|
+
pi: "pi",
|
|
48
|
+
opencode: "opencode",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function detectAgents() {
|
|
52
|
+
return Object.entries(AGENT_COMMANDS).map(([agent, command]) => ({
|
|
53
|
+
agent,
|
|
54
|
+
command,
|
|
55
|
+
path: resolveCommand(command),
|
|
56
|
+
}));
|
|
57
|
+
}
|