tryharness 0.4.1 → 0.4.3
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 +56 -5
- package/assets/opencode-plugin.js +36 -0
- package/assets/pi-extension.ts +38 -0
- package/cli.mjs +125 -17
- package/lib/adapters.mjs +47 -2
- package/lib/daemon.mjs +197 -36
- package/lib/files.mjs +0 -14
- package/lib/mcp-stdio.mjs +51 -10
- package/lib/nudge.mjs +35 -0
- package/lib/parsers.mjs +130 -46
- package/lib/paths.mjs +1 -0
- package/lib/runner.mjs +52 -29
- package/lib/service.mjs +90 -15
- package/lib/store.mjs +184 -31
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# tryharness
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Put the coding agents installed on this machine in your [Harness](https://tryharness.ai) relay:
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npx tryharness
|
|
@@ -10,10 +10,15 @@ Supports the Node.js 18, 20, 22, and 24 release lines.
|
|
|
10
10
|
|
|
11
11
|
That one command pairs the machine, detects Claude Code, Codex, pi, and opencode,
|
|
12
12
|
configures every installed agent, installs the Harness daemon as a user-level login
|
|
13
|
-
service
|
|
14
|
-
|
|
13
|
+
service (launchd on macOS, a systemd user service on Linux, a Task Scheduler logon task
|
|
14
|
+
on Windows; all without elevation), validates readiness, and starts indexing existing
|
|
15
|
+
coding sessions locally in SQLite. Setup returns as soon as the daemon is ready; indexing continues in the
|
|
15
16
|
background and does not require an open Harness tab.
|
|
16
17
|
|
|
18
|
+
JSONL histories are streamed from beginning to end and only normalized conversation
|
|
19
|
+
records are retained. Source-file size does not truncate recall; long read results are
|
|
20
|
+
paged when Harness displays them.
|
|
21
|
+
|
|
17
22
|
The command is idempotent. Run it again after installing another agent or to repair its
|
|
18
23
|
configuration.
|
|
19
24
|
|
|
@@ -23,8 +28,10 @@ configuration.
|
|
|
23
28
|
npx tryharness status
|
|
24
29
|
npx tryharness doctor
|
|
25
30
|
npx tryharness sync --agent codex
|
|
31
|
+
npx tryharness task <run-id> --follow
|
|
26
32
|
npx tryharness logs --follow
|
|
27
|
-
npx tryharness
|
|
33
|
+
npx tryharness board
|
|
34
|
+
npx tryharness board post --for claude-code --objective "Port the parser" --file ./brief.md
|
|
28
35
|
npx tryharness disconnect
|
|
29
36
|
```
|
|
30
37
|
|
|
@@ -32,10 +39,54 @@ npx tryharness disconnect
|
|
|
32
39
|
daemon, the bridge, the database, and agent discovery. `sync` requests a background
|
|
33
40
|
rescan; it is diagnostic, not part of normal setup.
|
|
34
41
|
|
|
42
|
+
Delegated coding tasks start only after the user presses Start on Harness's proposal
|
|
43
|
+
card, which posts the task on the machine's board under the run id. They then run non-interactively with each agent's full-authority/auto-approve mode
|
|
44
|
+
inside the exact absolute working directory shown on that card; Harness never falls
|
|
45
|
+
back to the user's home directory. The card immediately shows a
|
|
46
|
+
`npx tryharness task <run-id> --follow` command for watching the machine-local raw output
|
|
47
|
+
in a terminal. Both the card and the follow command print the agent's native session
|
|
48
|
+
command as soon as the agent reports its session id, including while the run is active,
|
|
49
|
+
so the user can enter and steer the agent directly rather than interacting through a
|
|
50
|
+
Harness wrapper.
|
|
51
|
+
|
|
52
|
+
## Task board
|
|
53
|
+
|
|
54
|
+
The daemon keeps a task board in its local SQLite database. Any connected agent can post
|
|
55
|
+
work for another agent on the machine through the `post_task` tool, and every agent sees
|
|
56
|
+
the board through `list_tasks`, `get_task`, `claim_task` and `report_task`, which the
|
|
57
|
+
stdio MCP exposes. The board is pull-only: a task waits until
|
|
58
|
+
its target agent reads the board and claims it, and nothing starts by itself. A claimer
|
|
59
|
+
reports done, failed or needs_input; the poster answers a question with `report_task`
|
|
60
|
+
and status `answered`, which returns the task to the claimer with the answer in its
|
|
61
|
+
brief. The daemon is also the board's worker. A task posted for a coding agent installed on the
|
|
62
|
+
machine with `--run --workspace <dir>` (or an agent's `run_workspace` request, once the user
|
|
63
|
+
authorizes it with `tryharness board run <id>`) is claimed by the daemon in that agent's name,
|
|
64
|
+
run non-interactively through the same runner as a delegated run, and reported back on the
|
|
65
|
+
task with the result and the agent's session id; `tryharness task <id> --follow` works on it.
|
|
66
|
+
One run per agent at a time; a daemon restart marks a running task unknown. An interactive
|
|
67
|
+
claim carries a lease (default 60 minutes); a lapsed lease shows the claim as stale and only
|
|
68
|
+
the poster or the user releases it. `tryharness board agents` shows when each agent last read
|
|
69
|
+
the board. Every change goes up the bridge as identity and status only.
|
|
70
|
+
|
|
71
|
+
Interactive sessions are told about waiting work when they open. Setup adds a Claude Code
|
|
72
|
+
SessionStart hook that prints the board's tasks for it into the session's context, the Pi
|
|
73
|
+
extension shows the same on session start (and puts it in context where pi allows), and the
|
|
74
|
+
OpenCode plugin adds it to the first message of a session. Codex has no hook surface, so it
|
|
75
|
+
reads the board when asked. The nudge is silent when nothing waits or no daemon is running.
|
|
76
|
+
|
|
77
|
+
Every task has one holder, the only agent allowed to write it next, and a version that
|
|
78
|
+
every write bumps; a write lands only for the holder at the expected version, and anything
|
|
79
|
+
else comes back as a conflict carrying the task as it is now, so agents cannot overwrite
|
|
80
|
+
each other. Briefs and results stay on the machine; the bridge reports identity and status
|
|
81
|
+
only. Any MCP client on the machine joins the board by launching `lib/mcp-stdio.mjs --agent <name>`.
|
|
82
|
+
Grok Bot keeps its MCP servers in its own account settings, so setup prints
|
|
83
|
+
`npx tryharness mcp grok-bot`, a one-server snippet to paste into Grok Bot's MCP settings; it
|
|
84
|
+
then appears on the board as `grok-bot`.
|
|
85
|
+
|
|
35
86
|
The daemon dials out to Harness over one authenticated WebSocket. Its local control
|
|
36
87
|
socket is permissioned to the current user. When OpenCode is installed, the daemon also
|
|
37
88
|
starts OpenCode's own helper on a random loopback-only port with a random per-process
|
|
38
89
|
password so it can index OpenCode history; no fixed or unauthenticated port is used.
|
|
39
90
|
Connection tokens stay in `~/.harness/config.json` with owner-only permissions. Coding
|
|
40
|
-
history and
|
|
91
|
+
history and the board stay in the local SQLite database unless an explicit operation
|
|
41
92
|
sends content through Harness.
|
|
@@ -8,6 +8,34 @@ const socketPath = process.platform === "win32"
|
|
|
8
8
|
? "\\\\.\\pipe\\tryharness-daemon"
|
|
9
9
|
: path.join(os.homedir(), ".harness", "daemon.sock");
|
|
10
10
|
|
|
11
|
+
function ask(message) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let buffer = "";
|
|
14
|
+
let socket;
|
|
15
|
+
try { socket = net.createConnection(socketPath); } catch { resolve(null); return; }
|
|
16
|
+
socket.setEncoding("utf8");
|
|
17
|
+
socket.setTimeout(1_500);
|
|
18
|
+
socket.on("connect", () => socket.write(JSON.stringify(message) + "\n"));
|
|
19
|
+
socket.on("data", (chunk) => { buffer += chunk; if (buffer.includes("\n")) { socket.destroy(); try { resolve(JSON.parse(buffer.split("\n")[0])); } catch { resolve(null); } } });
|
|
20
|
+
socket.on("timeout", () => { socket.destroy(); resolve(null); });
|
|
21
|
+
socket.on("error", () => resolve(null));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// What opencode is told once per session, on its first message: work waiting on the board.
|
|
26
|
+
async function boardNudge() {
|
|
27
|
+
const response = await ask({ command: "board.list", agent: "opencode" });
|
|
28
|
+
const tasks = response?.ok ? response.tasks : [];
|
|
29
|
+
const waiting = tasks.filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === "opencode"));
|
|
30
|
+
const held = tasks.filter((task) => task.claimed_by === "opencode" && (task.status === "claimed" || task.status === "needs_input"));
|
|
31
|
+
if (!waiting.length && !held.length) return "";
|
|
32
|
+
const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent})` : ""}`;
|
|
33
|
+
return ["Harness task board:", ...(waiting.length ? [`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`, ...waiting.slice(0, 5).map(line)] : []),
|
|
34
|
+
...(held.length ? [`${held.length} you already claimed:`, ...held.slice(0, 5).map(line)] : []),
|
|
35
|
+
"Read one with get_task, claim it with claim_task before working, and report_task when done or stuck."].join("\n");
|
|
36
|
+
}
|
|
37
|
+
const nudged = new Set();
|
|
38
|
+
|
|
11
39
|
function notify(message) {
|
|
12
40
|
try {
|
|
13
41
|
const socket = net.createConnection(socketPath);
|
|
@@ -19,6 +47,14 @@ function notify(message) {
|
|
|
19
47
|
}
|
|
20
48
|
|
|
21
49
|
export const TryHarnessPlugin = async ({ directory }) => ({
|
|
50
|
+
// The first message of each session carries the board nudge as an extra text part.
|
|
51
|
+
"chat.message": async (input, output) => {
|
|
52
|
+
const sessionId = input?.sessionID || input?.sessionId;
|
|
53
|
+
if (!sessionId || nudged.has(sessionId) || !Array.isArray(output?.parts)) return;
|
|
54
|
+
nudged.add(sessionId);
|
|
55
|
+
const nudge = await boardNudge();
|
|
56
|
+
if (nudge) output.parts.push({ type: "text", text: nudge });
|
|
57
|
+
},
|
|
22
58
|
event: async ({ event }) => {
|
|
23
59
|
if (event?.type !== "session.idle") return;
|
|
24
60
|
const sessionId = event?.properties?.sessionID || event?.properties?.sessionId;
|
package/assets/pi-extension.ts
CHANGED
|
@@ -5,7 +5,38 @@ import fs from "node:fs";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
|
|
8
|
+
import net from "node:net";
|
|
9
|
+
|
|
8
10
|
const CONFIG_PATH = path.join(os.homedir(), ".harness", "config.json");
|
|
11
|
+
const SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\tryharness-daemon" : path.join(os.homedir(), ".harness", "daemon.sock");
|
|
12
|
+
|
|
13
|
+
// One request to the local daemon; nothing waiting, or no daemon, is an empty answer.
|
|
14
|
+
function daemon(message) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let buffer = "";
|
|
17
|
+
let socket;
|
|
18
|
+
try { socket = net.createConnection(SOCKET_PATH); } catch { resolve(null); return; }
|
|
19
|
+
socket.setEncoding("utf8");
|
|
20
|
+
socket.setTimeout(1_500);
|
|
21
|
+
socket.on("connect", () => socket.write(JSON.stringify(message) + "\n"));
|
|
22
|
+
socket.on("data", (chunk) => { buffer += chunk; if (buffer.includes("\n")) { socket.destroy(); try { resolve(JSON.parse(buffer.split("\n")[0])); } catch { resolve(null); } } });
|
|
23
|
+
socket.on("timeout", () => { socket.destroy(); resolve(null); });
|
|
24
|
+
socket.on("error", () => resolve(null));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// What pi is told when a session opens: work waiting for it on the machine's task board.
|
|
29
|
+
async function boardNudge() {
|
|
30
|
+
const response = await daemon({ command: "board.list", agent: "pi" });
|
|
31
|
+
const tasks = response?.ok ? response.tasks : [];
|
|
32
|
+
const waiting = tasks.filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === "pi"));
|
|
33
|
+
const held = tasks.filter((task) => task.claimed_by === "pi" && (task.status === "claimed" || task.status === "needs_input"));
|
|
34
|
+
if (!waiting.length && !held.length) return "";
|
|
35
|
+
const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent})` : ""}`;
|
|
36
|
+
return ["Harness task board:", ...(waiting.length ? [`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`, ...waiting.slice(0, 5).map(line)] : []),
|
|
37
|
+
...(held.length ? [`${held.length} you already claimed:`, ...held.slice(0, 5).map(line)] : []),
|
|
38
|
+
"Read one with get_task, claim it with claim_task before working, and report_task when done or stuck."].join("\n");
|
|
39
|
+
}
|
|
9
40
|
let nextId = 1;
|
|
10
41
|
let typeApi = null;
|
|
11
42
|
|
|
@@ -88,5 +119,12 @@ export default function tryHarness(pi) {
|
|
|
88
119
|
return connecting;
|
|
89
120
|
};
|
|
90
121
|
pi.on("session_start", (_event, ctx) => void connect(ctx).catch((error) => ctx?.ui?.notify?.("Harness unavailable: " + error?.message, "warning")));
|
|
122
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
123
|
+
const nudge = await boardNudge();
|
|
124
|
+
if (!nudge) return;
|
|
125
|
+
// Into the model's context when pi offers it; always visible to the person.
|
|
126
|
+
if (typeof pi.sendMessage === "function") { try { pi.sendMessage({ customType: "harness-board", content: nudge, display: true }); } catch {} }
|
|
127
|
+
ctx?.ui?.notify?.(nudge.split("\n").slice(0, 2).join(" "), "info");
|
|
128
|
+
});
|
|
91
129
|
pi.registerCommand("harness-reconnect", { description: "Reconnect Harness", handler: async (_args, ctx) => connect(ctx) });
|
|
92
130
|
}
|
package/cli.mjs
CHANGED
|
@@ -3,16 +3,16 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { configureAgents, AGENTS, removeAgentConfigurations } from "./lib/adapters.mjs";
|
|
6
|
+
import { configureAgents, AGENTS, MCP_CLIENTS, detectMcpClients, mcpClientSnippet, removeAgentConfigurations } from "./lib/adapters.mjs";
|
|
7
7
|
import { detectAgents } from "./lib/commands.mjs";
|
|
8
8
|
import { daemonRequest } from "./lib/control-client.mjs";
|
|
9
9
|
import { ensureDir, readJson, writeJsonAtomic } from "./lib/files.mjs";
|
|
10
10
|
import { paths } from "./lib/paths.mjs";
|
|
11
|
-
import { installRuntime } from "./lib/runtime-install.mjs";
|
|
11
|
+
import { currentRuntime, installRuntime } from "./lib/runtime-install.mjs";
|
|
12
12
|
import { installService, serviceInfo, uninstallService } from "./lib/service.mjs";
|
|
13
13
|
|
|
14
14
|
const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
const PACKAGE = readJson(path.join(SOURCE_ROOT, "package.json"), { version: "0.4.
|
|
15
|
+
const PACKAGE = readJson(path.join(SOURCE_ROOT, "package.json"), { version: "0.4.3" });
|
|
16
16
|
const VERSION = PACKAGE.version;
|
|
17
17
|
const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
|
|
18
18
|
if (!Number.isFinite(NODE_MAJOR) || NODE_MAJOR < 18) {
|
|
@@ -31,6 +31,18 @@ function arg(flag, fallback = null) {
|
|
|
31
31
|
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
32
32
|
}
|
|
33
33
|
function has(flag) { return argv.includes(flag); }
|
|
34
|
+
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
35
|
+
function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; }
|
|
36
|
+
function resumeCommand(agent, sessionId, cwd) {
|
|
37
|
+
const command = agent === "claude-code"
|
|
38
|
+
? `claude --dangerously-skip-permissions --resume ${shellQuote(sessionId)}`
|
|
39
|
+
: agent === "codex"
|
|
40
|
+
? `codex resume --dangerously-bypass-approvals-and-sandbox ${shellQuote(sessionId)}`
|
|
41
|
+
: agent === "pi"
|
|
42
|
+
? `pi --approve --session ${shellQuote(sessionId)}`
|
|
43
|
+
: `opencode --auto --session ${shellQuote(sessionId)}`;
|
|
44
|
+
return cwd ? `cd ${shellQuote(cwd)} && ${command}` : command;
|
|
45
|
+
}
|
|
34
46
|
function origin() {
|
|
35
47
|
const saved = readJson(paths.config, {});
|
|
36
48
|
return String(arg("--origin", saved.origin || "https://tryharness.ai")).replace(/\/$/, "");
|
|
@@ -138,6 +150,9 @@ async function connect() {
|
|
|
138
150
|
config = { ...readJson(paths.config, config), version: VERSION, runtimeRoot, agentStates: stateMap };
|
|
139
151
|
writeJsonAtomic(paths.config, config);
|
|
140
152
|
printAgentStates(states);
|
|
153
|
+
for (const client of detectMcpClients()) {
|
|
154
|
+
if (client.path) console.log(` ! ${client.client}: found; it joins the task board once you paste its MCP snippet (npx tryharness mcp ${client.client})`);
|
|
155
|
+
}
|
|
141
156
|
|
|
142
157
|
const service = installService({ runtimeRoot });
|
|
143
158
|
const ready = await waitForDaemon();
|
|
@@ -199,18 +214,109 @@ async function sync() {
|
|
|
199
214
|
console.log(`Local history scan started for ${agent}. It runs in the daemon; this terminal does not need to stay open.`);
|
|
200
215
|
}
|
|
201
216
|
|
|
202
|
-
async function
|
|
203
|
-
const
|
|
204
|
-
if (!
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
217
|
+
async function mcpSnippet() {
|
|
218
|
+
const client = argv[0];
|
|
219
|
+
if (!MCP_CLIENTS.includes(client)) throw new Error(`usage: tryharness mcp <${MCP_CLIENTS.join("|")}>`);
|
|
220
|
+
const config = readJson(paths.config, {});
|
|
221
|
+
const runtimeRoot = config.runtimeRoot || currentRuntime();
|
|
222
|
+
if (!runtimeRoot) throw new Error("no Harness runtime is installed yet; run npx tryharness first");
|
|
223
|
+
const found = detectMcpClients().find((entry) => entry.client === client);
|
|
224
|
+
console.log(`${client}: ${found?.path ? `found at ${found.path}` : "not found on this machine; the snippet still works once it is installed"}`);
|
|
225
|
+
console.log(`In ${client === "grok-bot" ? "Grok Bot, open Settings, MCP, add a server" : client} and paste this:\n`);
|
|
226
|
+
console.log(JSON.stringify(mcpClientSnippet({ runtimeRoot, client }), null, 2));
|
|
227
|
+
console.log(`\nIt then sees the board through list_tasks, get_task, post_task, claim_task and report_task as "${client}".`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function board() {
|
|
231
|
+
const sub = argv.find((value) => !value.startsWith("--")) || "list";
|
|
232
|
+
const agent = arg("--as", "user");
|
|
233
|
+
if (sub === "post") {
|
|
234
|
+
const target = arg("--for");
|
|
235
|
+
if (target && !AGENTS.includes(target) && target !== "harness" && target !== "grok-bot") throw new Error("usage: tryharness board post --for <agent> --objective <text> [--file <brief.md>] [--run --workspace <dir>]");
|
|
236
|
+
const objective = arg("--objective", "");
|
|
237
|
+
const file = arg("--file");
|
|
238
|
+
let brief = "";
|
|
239
|
+
if (file) brief = fs.readFileSync(path.resolve(file), "utf8");
|
|
240
|
+
else if (!process.stdin.isTTY) for await (const chunk of process.stdin) brief += chunk;
|
|
241
|
+
if (!objective.trim()) throw new Error("board post needs --objective");
|
|
242
|
+
const runWorkspace = has("--run") ? path.resolve(arg("--workspace", process.cwd())) : null;
|
|
243
|
+
if (runWorkspace && !AGENTS.includes(target)) throw new Error("--run needs --for with a coding agent installed on this machine");
|
|
244
|
+
const response = await daemonRequest({ command: "board.post", agent, objective, brief, targetAgent: target || null, runWorkspace });
|
|
245
|
+
if (!response.ok) throw new Error(response.error || "post failed");
|
|
246
|
+
if (runWorkspace) console.log(`Task ${response.task.id} is on the board and ${target} will run it in ${runWorkspace}. Follow it: npx tryharness task ${response.task.id} --follow`);
|
|
247
|
+
else console.log(`Task ${response.task.id} is on the board${target ? ` for ${target}` : ""}. Any agent's list_tasks tool shows it.`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (sub === "run" || sub === "release") {
|
|
251
|
+
const id = String(argv[argv.indexOf(sub) + 1] || "");
|
|
252
|
+
const response = await daemonRequest({ command: sub === "run" ? "board.run" : "board.release", agent, id });
|
|
253
|
+
if (!response.ok) throw new Error(response.error || `${sub} failed`);
|
|
254
|
+
console.log(sub === "run" ? `Run authorized; ${response.task.target_agent} starts it now. Follow it: npx tryharness task ${id} --follow` : `Task ${id} is open again.`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (sub === "agents") {
|
|
258
|
+
const response = await daemonRequest({ command: "board.agents", agent });
|
|
259
|
+
if (!response.ok) throw new Error(response.error || "board unavailable");
|
|
260
|
+
if (!response.agents.length) { console.log("No agent has read the board yet."); return; }
|
|
261
|
+
for (const row of response.agents) console.log(`${row.agent.padEnd(14)} last seen ${row.last_seen_at}${response.running.some((run) => run.agent === row.agent) ? " · running a task" : ""}`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (sub === "show") {
|
|
265
|
+
const response = await daemonRequest({ command: "board.get", agent, id: String(argv[argv.indexOf("show") + 1] || "") });
|
|
266
|
+
if (!response.ok) throw new Error(response.error || "task not found");
|
|
267
|
+
const { task: t } = response;
|
|
268
|
+
console.log(`${t.id} · ${t.status}${t.stale ? " (stale claim)" : ""} · from ${t.origin_agent}${t.target_agent ? ` for ${t.target_agent}` : ""}${t.claimed_by ? ` · claimed by ${t.claimed_by}` : ""} · held by ${t.holder || "nobody"} · version ${t.version}\n${t.objective}\n`);
|
|
269
|
+
if (t.run_workspace) console.log(`run: ${t.run_authorized_at ? `authorized by ${t.run_authorized_by}` : "requested, waiting for: npx tryharness board run " + t.id} · ${t.run_workspace}${t.session_id ? `\nagent session: ${t.session_id}\nopen live session: ${resumeCommand(t.target_agent, t.session_id, t.run_workspace)}` : ""}\n`);
|
|
270
|
+
if (t.brief) console.log(t.brief + "\n");
|
|
271
|
+
if (t.result) console.log(`${t.status === "needs_input" ? "question" : "result"}: ${t.result}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (sub !== "list") throw new Error("usage: tryharness board [list --all | show <id> | post --for <agent> --objective <text> [--file <brief.md>] [--run --workspace <dir>] | run <id> | release <id> | agents]");
|
|
275
|
+
const response = await daemonRequest({ command: "board.list", agent, all: has("--all") });
|
|
276
|
+
if (!response.ok) throw new Error(response.error || "board unavailable");
|
|
277
|
+
if (!response.tasks.length) { console.log("The board is empty."); return; }
|
|
278
|
+
for (const t of response.tasks) console.log(`${t.id} ${(t.stale ? "stale" : t.status).padEnd(11)} ${t.origin_agent} → ${t.target_agent || "anyone"}${t.claimed_by ? ` (${t.claimed_by})` : ""}${t.run_workspace && !t.run_authorized_at ? " [wants to run]" : ""} ${t.objective}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function task() {
|
|
282
|
+
const runId = String(argv[0] || "").trim();
|
|
283
|
+
if (!/^[a-zA-Z0-9._-]{1,128}$/.test(runId)) throw new Error("usage: tryharness task <run-id> [--follow]");
|
|
284
|
+
const follow = has("--follow") || has("-f");
|
|
285
|
+
let offset = Math.max(0, Math.floor(Number(arg("--offset", "0")) || 0));
|
|
286
|
+
let announced = false;
|
|
287
|
+
let sessionShown = null;
|
|
288
|
+
let misses = 0;
|
|
289
|
+
for (;;) {
|
|
290
|
+
const response = await daemonRequest({ command: "tasks.get", runId, offset });
|
|
291
|
+
if (!response.ok) {
|
|
292
|
+
if (follow && misses < 10) { misses += 1; await sleep(500); continue; }
|
|
293
|
+
throw new Error(response.error || `task ${runId} was not found on this machine`);
|
|
294
|
+
}
|
|
295
|
+
misses = 0;
|
|
296
|
+
const current = response.task;
|
|
297
|
+
if (!announced) {
|
|
298
|
+
console.log(`Harness task ${runId} · ${current.agent} · ${current.status}`);
|
|
299
|
+
if (current.cwd) console.log(`directory: ${current.cwd}`);
|
|
300
|
+
if (follow) console.log("following machine-local agent output (Ctrl-C stops following, not the task)\n");
|
|
301
|
+
announced = true;
|
|
302
|
+
}
|
|
303
|
+
if (current.log) process.stdout.write(current.log);
|
|
304
|
+
offset = current.nextOffset;
|
|
305
|
+
if (current.sessionId && current.sessionId !== sessionShown) {
|
|
306
|
+
sessionShown = current.sessionId;
|
|
307
|
+
console.log(`\nagent session: ${current.sessionId}`);
|
|
308
|
+
console.log(`open live session: ${resumeCommand(current.agent, current.sessionId, current.cwd)}`);
|
|
309
|
+
}
|
|
310
|
+
const terminal = current.status === "done" || current.status === "failed" || current.status === "interrupted";
|
|
311
|
+
if (!follow || (terminal && offset >= current.logLength)) {
|
|
312
|
+
console.log(`\nstatus: ${current.status}`);
|
|
313
|
+
if (current.message) console.log(current.message);
|
|
314
|
+
if (current.status === "interrupted") console.log("The daemon restarted during this run; its outcome is unknown. Check the repository.");
|
|
315
|
+
if (current.status !== "done") process.exitCode = 1;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
await sleep(500);
|
|
319
|
+
}
|
|
214
320
|
}
|
|
215
321
|
|
|
216
322
|
function logs() {
|
|
@@ -230,7 +336,7 @@ async function disconnect() {
|
|
|
230
336
|
}
|
|
231
337
|
|
|
232
338
|
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
|
|
339
|
+
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 task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness logs [--follow]\n tryharness disconnect\n`);
|
|
234
340
|
}
|
|
235
341
|
|
|
236
342
|
try {
|
|
@@ -238,7 +344,9 @@ try {
|
|
|
238
344
|
else if (command === "status" || command === "bridge") await status();
|
|
239
345
|
else if (command === "doctor") await doctor();
|
|
240
346
|
else if (command === "sync" || command === "backfill" || command === "drain") await sync();
|
|
241
|
-
else if (command === "
|
|
347
|
+
else if (command === "task") await task();
|
|
348
|
+
else if (command === "board") await board();
|
|
349
|
+
else if (command === "mcp") await mcpSnippet();
|
|
242
350
|
else if (command === "logs") logs();
|
|
243
351
|
else if (command === "disconnect") await disconnect();
|
|
244
352
|
else if (command === "daemon") { const { startDaemon } = await import("./lib/daemon.mjs"); await startDaemon(); }
|
package/lib/adapters.mjs
CHANGED
|
@@ -7,6 +7,23 @@ import { paths } from "./paths.mjs";
|
|
|
7
7
|
|
|
8
8
|
export const AGENTS = ["claude-code", "codex", "pi", "opencode"];
|
|
9
9
|
|
|
10
|
+
// MCP clients that are not coding agents: no history to index and nothing to run, but they
|
|
11
|
+
// join the machine's task board by launching the same stdio MCP under their own name.
|
|
12
|
+
export const MCP_CLIENTS = ["grok-bot"];
|
|
13
|
+
const MCP_CLIENT_APPS = {
|
|
14
|
+
"grok-bot": { darwin: ["/Applications/Grok Bot.app", path.join(os.homedir(), "Applications", "Grok Bot.app")] },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function detectMcpClients({ platform = process.platform, exists = fs.existsSync } = {}) {
|
|
18
|
+
return MCP_CLIENTS.map((client) => ({ client, path: (MCP_CLIENT_APPS[client][platform] || []).find((candidate) => exists(candidate)) || null }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Grok Bot keeps its MCP server list in its account and writes it only from its own settings
|
|
22
|
+
// (a one-server JSON paste); there is no file on disk to write. The snippet is the setup.
|
|
23
|
+
export function mcpClientSnippet({ runtimeRoot, client, nodePath = process.execPath }) {
|
|
24
|
+
return { mcpServers: { harness: { command: nodePath, args: [path.join(runtimeRoot, "lib", "mcp-stdio.mjs"), "--agent", client] } } };
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
function installAsset(source, target) {
|
|
11
28
|
ensureDir(path.dirname(target));
|
|
12
29
|
fs.copyFileSync(source, target);
|
|
@@ -24,6 +41,32 @@ export function integrationAssets(homeDir = os.homedir()) {
|
|
|
24
41
|
};
|
|
25
42
|
}
|
|
26
43
|
|
|
44
|
+
// Claude Code learns about waiting board work through a SessionStart hook whose stdout joins
|
|
45
|
+
// its context. The hook is one entry in the user's settings, added once and removed on
|
|
46
|
+
// disconnect; every other setting and hook is left as it was.
|
|
47
|
+
export function claudeSettingsPath(homeDir = os.homedir()) {
|
|
48
|
+
return path.join(homeDir, ".claude", "settings.json");
|
|
49
|
+
}
|
|
50
|
+
const isHarnessHook = (entry) => JSON.stringify(entry).includes("/lib/nudge.mjs");
|
|
51
|
+
|
|
52
|
+
export function installClaudeSessionHook({ runtimeRoot, homeDir = os.homedir(), nodePath = process.execPath }) {
|
|
53
|
+
const file = claudeSettingsPath(homeDir);
|
|
54
|
+
const settings = readJson(file, {});
|
|
55
|
+
const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
|
|
56
|
+
const starts = (Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []).filter((entry) => !isHarnessHook(entry));
|
|
57
|
+
starts.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${JSON.stringify(path.join(runtimeRoot, "lib", "nudge.mjs"))} --agent claude-code`, timeout: 10 }] });
|
|
58
|
+
ensureDir(path.dirname(file));
|
|
59
|
+
fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts } }, null, 2) + "\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function removeClaudeSessionHook(homeDir = os.homedir()) {
|
|
63
|
+
const file = claudeSettingsPath(homeDir);
|
|
64
|
+
const settings = readJson(file, null);
|
|
65
|
+
if (!settings?.hooks || !Array.isArray(settings.hooks.SessionStart)) return;
|
|
66
|
+
const starts = settings.hooks.SessionStart.filter((entry) => !isHarnessHook(entry));
|
|
67
|
+
fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...settings.hooks, SessionStart: starts } }, null, 2) + "\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
27
70
|
export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.homedir(), detected = detectAgents(), execute = run, connection = readJson(paths.config, {}) }) {
|
|
28
71
|
const byAgent = new Map(detected.map((entry) => [entry.agent, entry]));
|
|
29
72
|
const mcpPath = path.join(runtimeRoot, "lib", "mcp-stdio.mjs");
|
|
@@ -42,7 +85,9 @@ export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.h
|
|
|
42
85
|
if (agent === "claude-code") {
|
|
43
86
|
execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]);
|
|
44
87
|
const added = execute(found.path, ["mcp", "add", "--scope", "user", "harness", "--", process.execPath, mcpPath, "--agent", agent]);
|
|
45
|
-
|
|
88
|
+
let hooked = true;
|
|
89
|
+
try { installClaudeSessionHook({ runtimeRoot, homeDir }); } catch { hooked = false; }
|
|
90
|
+
states.push({ agent, installed: true, configured: added.ok, detail: added.ok ? (hooked ? "MCP and session-start nudge configured" : "MCP configured; the session-start hook could not be written") : diagnostic(added) });
|
|
46
91
|
} else if (agent === "codex") {
|
|
47
92
|
execute(found.path, ["mcp", "remove", "harness"]);
|
|
48
93
|
const added = execute(found.path, ["mcp", "add", "harness", "--", process.execPath, mcpPath, "--agent", agent]);
|
|
@@ -69,7 +114,7 @@ export function agentHealth(states) {
|
|
|
69
114
|
export function removeAgentConfigurations({ homeDir = os.homedir(), detected = detectAgents(), execute = run } = {}) {
|
|
70
115
|
for (const found of detected) {
|
|
71
116
|
if (!found.path) continue;
|
|
72
|
-
if (found.agent === "claude-code") execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]);
|
|
117
|
+
if (found.agent === "claude-code") { execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]); removeClaudeSessionHook(homeDir); }
|
|
73
118
|
else if (found.agent === "codex" || found.agent === "opencode") execute(found.path, ["mcp", "remove", "harness"]);
|
|
74
119
|
}
|
|
75
120
|
const assets = integrationAssets(homeDir);
|