tryharness 0.7.16 → 0.7.22
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 +9 -6
- package/assets/opencode-plugin.js +46 -66
- package/assets/pi-extension.ts +90 -90
- package/cli.mjs +31 -1
- package/lib/adapters.mjs +19 -11
- package/lib/agentnode.mjs +154 -0
- package/lib/cards.mjs +11 -3
- package/lib/coordinator.mjs +19 -9
- package/lib/daemon.mjs +234 -62
- package/lib/folders.mjs +138 -0
- package/lib/live.mjs +30 -5
- package/lib/mcp-stdio.mjs +10 -2
- package/lib/members.json +1 -1
- package/lib/models.mjs +233 -0
- package/lib/network.mjs +118 -0
- package/lib/nudge.mjs +6 -5
- package/lib/phones.mjs +5 -1
- package/lib/places.mjs +49 -1
- package/lib/runner.mjs +35 -6
- package/lib/store.mjs +32 -3
- package/lib/swarm.mjs +84 -20
- package/lib/t3.mjs +7 -4
- package/lib/worktrees.mjs +36 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -92,18 +92,21 @@ claim carries a lease (default 60 minutes); a lapsed lease shows the claim as st
|
|
|
92
92
|
the poster or the user releases it. `tryharness board agents` shows when each agent last read
|
|
93
93
|
the board. Every change goes up the bridge as identity and status only.
|
|
94
94
|
|
|
95
|
-
Interactive sessions are told about waiting work when they open and when it changes
|
|
96
|
-
|
|
97
|
-
(`~/.codex/hooks.json`; Codex asks once to
|
|
98
|
-
queue into pi's system prompt and speaks again
|
|
99
|
-
|
|
95
|
+
Interactive sessions are told about waiting work when they open and when it changes, by the same
|
|
96
|
+
code (`lib/nudge.mjs`) for every agent. Setup adds SessionStart and UserPromptSubmit hooks for
|
|
97
|
+
Claude Code (`~/.claude/settings.json`) and for Codex (`~/.codex/hooks.json`; Codex asks once to
|
|
98
|
+
trust them with `/hooks`), the Pi extension puts the queue into pi's system prompt and speaks again
|
|
99
|
+
after a turn when it changed, and the OpenCode plugin adds it to a session's first message and to
|
|
100
|
+
later ones when it changed. Every MCP client also gets the queue in the
|
|
100
101
|
connection handshake. The nudge is silent when nothing waits or no daemon is running.
|
|
101
102
|
|
|
102
103
|
Every task has one holder, the only agent allowed to write it next, and a version that
|
|
103
104
|
every write bumps; a write lands only for the holder at the expected version, and anything
|
|
104
105
|
else comes back as a conflict carrying the task as it is now, so agents cannot overwrite
|
|
105
106
|
each other. Briefs and results stay on the machine; the bridge reports identity and status
|
|
106
|
-
only. Any MCP client on the machine joins the board by launching `lib/mcp-stdio.mjs --agent <name
|
|
107
|
+
only. Any MCP client on the machine joins the board by launching `lib/mcp-stdio.mjs --agent <name>`:
|
|
108
|
+
Claude Code, Codex, OpenCode, OpenClaw and Hermes have it in their MCP settings, and pi, which has no
|
|
109
|
+
MCP client, gets it from its extension, which launches it and gives pi its tools.
|
|
107
110
|
|
|
108
111
|
Work stays where the agent lives. Bankr, Quotient and Muse live in Harness, not on any machine,
|
|
109
112
|
so a task posted for one of them (`tryharness board post --for bankr`, or `post_task` from any
|
|
@@ -1,59 +1,19 @@
|
|
|
1
|
-
// Installed globally by tryharness. It
|
|
2
|
-
//
|
|
1
|
+
// Installed globally by tryharness. It tells opencode about its board and hands it what the person sends a session from
|
|
2
|
+
// Harness with lib/nudge.mjs, the code Claude Code's and Codex's hooks run, and nudges the local daemon to index a session; indexing and retry state stay in the daemon's
|
|
3
|
+
// SQLite database and never block opencode's idle event. Its tools come from the Harness server in its MCP config.
|
|
3
4
|
import net from "node:net";
|
|
4
|
-
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// Filled in by setup: the runtime the other agents' Harness server runs from.
|
|
9
|
+
const RUNTIME = "__HARNESS_RUNTIME__";
|
|
10
|
+
const AGENT = "opencode";
|
|
11
|
+
const harness = () => import(pathToFileURL(path.join(RUNTIME, "lib", "nudge.mjs")).href);
|
|
12
|
+
const socketPath = async () => (await import(pathToFileURL(path.join(RUNTIME, "lib", "paths.mjs")).href)).paths.socket;
|
|
10
13
|
|
|
11
|
-
function
|
|
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, and the person's other
|
|
26
|
-
// agents and what each is for (the swarm block, from the daemon), again only when either changed.
|
|
27
|
-
async function boardNudge(sessionId) {
|
|
28
|
-
const response = await ask({ command: "board.list", agent: "opencode", swarm: true });
|
|
29
|
-
const tasks = response?.ok ? response.tasks : [];
|
|
30
|
-
const swarm = response?.swarm?.digest && lastSwarm.get(sessionId) !== response.swarm.digest ? response.swarm : null;
|
|
31
|
-
if (swarm) lastSwarm.set(sessionId, swarm.digest);
|
|
32
|
-
return [queueNudge(sessionId, tasks), swarm?.text].filter(Boolean).join("\n\n");
|
|
33
|
-
}
|
|
34
|
-
const lastSwarm = new Map();
|
|
35
|
-
function queueNudge(sessionId, tasks) {
|
|
36
|
-
const now = digest(tasks);
|
|
37
|
-
if (lastDigest.get(sessionId) === now) return "";
|
|
38
|
-
lastDigest.set(sessionId, now);
|
|
39
|
-
const waiting = tasks.filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === "opencode"));
|
|
40
|
-
const held = tasks.filter((task) => task.claimed_by === "opencode" && (task.status === "claimed" || task.status === "needs_input"));
|
|
41
|
-
if (!waiting.length && !held.length) return "";
|
|
42
|
-
const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent}${task.trust === "agent" ? ", agent-written" : ""})` : ""}`;
|
|
43
|
-
return ["Harness task board:", ...(waiting.length ? [`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`, ...waiting.slice(0, 5).map(line)] : []),
|
|
44
|
-
...(held.length ? [`${held.length} you already claimed:`, ...held.slice(0, 5).map(line)] : []),
|
|
45
|
-
"Read one with get_task, claim it with claim_task before working, and report_task when done or stuck."].join("\n");
|
|
46
|
-
}
|
|
47
|
-
// One digest per session: the nudge is added again only when the queue changed.
|
|
48
|
-
const lastDigest = new Map();
|
|
49
|
-
function digest(tasks) {
|
|
50
|
-
return tasks.filter((task) => (task.status === "open" && (!task.target_agent || task.target_agent === "opencode")) || (task.claimed_by === "opencode" && ["claimed", "needs_input"].includes(task.status)))
|
|
51
|
-
.map((task) => `${task.id}:${task.status}:${task.version}`).sort().join("|");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function notify(message) {
|
|
14
|
+
async function notify(message) {
|
|
55
15
|
try {
|
|
56
|
-
const socket = net.createConnection(socketPath);
|
|
16
|
+
const socket = net.createConnection(await socketPath());
|
|
57
17
|
socket.setTimeout(1_000);
|
|
58
18
|
socket.on("connect", () => socket.end(JSON.stringify(message) + "\n"));
|
|
59
19
|
socket.on("error", () => {});
|
|
@@ -61,28 +21,48 @@ function notify(message) {
|
|
|
61
21
|
} catch {}
|
|
62
22
|
}
|
|
63
23
|
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
24
|
+
const started = new Set();
|
|
25
|
+
const pids = [process.pid, process.ppid];
|
|
26
|
+
// opencode saves only a part that names its id, session and message; the id is opencode's own shape (prt_, the time,
|
|
27
|
+
// then random), so the notice sorts after the person's words.
|
|
28
|
+
let counter = 0;
|
|
29
|
+
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
30
|
+
const partId = () => {
|
|
31
|
+
const time = ((BigInt(Date.now()) * 0x1000n) + BigInt(++counter % 0x1000)) & 0xffffffffffffn;
|
|
32
|
+
return "prt_" + time.toString(16).padStart(12, "0") + Array.from(crypto.getRandomValues(new Uint8Array(14)), (byte) => BASE62[byte % 62]).join("");
|
|
33
|
+
};
|
|
73
34
|
|
|
74
|
-
export const TryHarnessPlugin = async ({ directory }) => ({
|
|
75
|
-
//
|
|
35
|
+
export const TryHarnessPlugin = async ({ directory, client }) => ({
|
|
36
|
+
// A session's first message is its start (Claude Code's SessionStart: the whole board); each later one carries the
|
|
37
|
+
// board again only when it changed (UserPromptSubmit).
|
|
76
38
|
"chat.message": async (input, output) => {
|
|
77
39
|
const sessionId = input?.sessionID || input?.sessionId;
|
|
78
40
|
if (!sessionId || !Array.isArray(output?.parts)) return;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
41
|
+
const board = await harness().catch(() => null);
|
|
42
|
+
if (!board) return;
|
|
43
|
+
const where = { session_id: sessionId, cwd: directory };
|
|
44
|
+
const first = !started.has(sessionId);
|
|
45
|
+
started.add(sessionId);
|
|
46
|
+
if (first) await board.notePlace(AGENT, where, pids);
|
|
47
|
+
await board.notePrompt(AGENT, where, pids);
|
|
48
|
+
const messageID = output.message?.id || input?.messageID;
|
|
49
|
+
if (!messageID) return;
|
|
50
|
+
const messages = await board.takeMessages(AGENT, sessionId);
|
|
51
|
+
const nudge = first ? await board.boardNudge(AGENT) : await board.boardNudgeIfChanged(AGENT);
|
|
52
|
+
// Marked synthetic: the model reads them, and they are not the words typed here.
|
|
53
|
+
for (const text of [messages.length ? board.messagesText(messages) : "", nudge]) {
|
|
54
|
+
if (text) output.parts.push({ id: partId(), sessionID: sessionId, messageID, type: "text", text, synthetic: true });
|
|
55
|
+
}
|
|
82
56
|
},
|
|
83
57
|
event: async ({ event }) => {
|
|
84
58
|
if (event?.type !== "session.idle") return;
|
|
85
59
|
const sessionId = event?.properties?.sessionID || event?.properties?.sessionId;
|
|
86
|
-
if (sessionId)
|
|
60
|
+
if (!sessionId) return;
|
|
61
|
+
await notify({ command: "sync", agent: AGENT, sessionId, cwd: directory });
|
|
62
|
+
// When a turn ends, a message waiting from Harness starts the next one (Claude Code's Stop hook does the same).
|
|
63
|
+
const board = await harness().catch(() => null);
|
|
64
|
+
const messages = board ? await board.takeMessages(AGENT, sessionId) : [];
|
|
65
|
+
const send = client?.session?.promptAsync?.bind(client.session) ?? client?.session?.prompt?.bind(client.session);
|
|
66
|
+
if (messages.length && send) await send({ path: { id: sessionId }, body: { parts: [{ type: "text", text: board.messagesText(messages) }] } }).catch(() => {});
|
|
87
67
|
},
|
|
88
68
|
});
|
package/assets/pi-extension.ts
CHANGED
|
@@ -1,76 +1,58 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
|
-
// Installed globally by tryharness. pi has no native MCP client, so the extension
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
// Installed globally by tryharness. pi has no native MCP client, so the extension starts the Harness server every
|
|
3
|
+
// other agent here uses (lib/mcp-stdio.mjs --agent pi) and maps its tools into normal pi tools, and tells pi about its
|
|
4
|
+
// board and hands it what the person sends the session from Harness with lib/nudge.mjs, the code Claude Code's and
|
|
5
|
+
// Codex's hooks run. Neither blocks pi starting.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
6
7
|
import path from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
// Filled in by setup: the runtime and the Node the other agents' Harness server runs from.
|
|
11
|
+
const RUNTIME = "__HARNESS_RUNTIME__";
|
|
12
|
+
const NODE = "__HARNESS_NODE__";
|
|
13
|
+
const AGENT = "pi";
|
|
14
|
+
const harness = () => import(pathToFileURL(path.join(RUNTIME, "lib", "nudge.mjs")).href);
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
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, and again after a turn when the queue changed.
|
|
29
|
-
let lastDigest = null;
|
|
30
|
-
function digest(tasks) {
|
|
31
|
-
return tasks.filter((task) => (task.status === "open" && (!task.target_agent || task.target_agent === "pi")) || (task.claimed_by === "pi" && ["claimed", "needs_input"].includes(task.status)))
|
|
32
|
-
.map((task) => `${task.id}:${task.status}:${task.version}`).sort().join("|");
|
|
33
|
-
}
|
|
34
|
-
// The system prompt also carries the swarm block (the person's other agents and what each is for, from the daemon).
|
|
35
|
-
async function boardNudge(onlyIfChanged = false, withSwarm = false) {
|
|
36
|
-
const response = await daemon({ command: "board.list", agent: "pi", ...(withSwarm ? { swarm: true } : {}) });
|
|
37
|
-
const tasks = response?.ok ? response.tasks : [];
|
|
38
|
-
const now = digest(tasks);
|
|
39
|
-
if (onlyIfChanged && now === lastDigest) return "";
|
|
40
|
-
lastDigest = now;
|
|
41
|
-
const queue = queueText(tasks);
|
|
42
|
-
return withSwarm && response?.swarm?.text ? [queue, response.swarm.text].filter(Boolean).join("\n\n") : queue;
|
|
43
|
-
}
|
|
44
|
-
function queueText(tasks) {
|
|
45
|
-
const waiting = tasks.filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === "pi"));
|
|
46
|
-
const held = tasks.filter((task) => task.claimed_by === "pi" && (task.status === "claimed" || task.status === "needs_input"));
|
|
47
|
-
if (!waiting.length && !held.length) return "";
|
|
48
|
-
const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent}${task.trust === "agent" ? ", agent-written" : ""})` : ""}`;
|
|
49
|
-
return ["Harness task board:", ...(waiting.length ? [`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`, ...waiting.slice(0, 5).map(line)] : []),
|
|
50
|
-
...(held.length ? [`${held.length} you already claimed:`, ...held.slice(0, 5).map(line)] : []),
|
|
51
|
-
"Read one with get_task, claim it with claim_task before working, and report_task when done or stuck."].join("\n");
|
|
52
|
-
}
|
|
16
|
+
// The server, one per session: JSON-RPC over its stdin and stdout.
|
|
17
|
+
let server = null;
|
|
53
18
|
let nextId = 1;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
19
|
+
function start() {
|
|
20
|
+
if (server) return server;
|
|
21
|
+
const child = spawn(NODE, [path.join(RUNTIME, "lib", "mcp-stdio.mjs"), "--agent", AGENT], { stdio: ["pipe", "pipe", "ignore"] });
|
|
22
|
+
const pending = new Map();
|
|
23
|
+
let buffer = "";
|
|
24
|
+
child.stdout.setEncoding("utf8");
|
|
25
|
+
child.stdout.on("data", (chunk) => {
|
|
26
|
+
buffer += chunk;
|
|
27
|
+
const lines = buffer.split("\n");
|
|
28
|
+
buffer = lines.pop();
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
let message;
|
|
31
|
+
try { message = JSON.parse(line); } catch { continue; }
|
|
32
|
+
pending.get(message.id)?.(message);
|
|
33
|
+
pending.delete(message.id);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const stopped = () => {
|
|
37
|
+
if (server?.child === child) server = null;
|
|
38
|
+
for (const settle of pending.values()) settle({ error: { message: "the Harness server stopped" } });
|
|
39
|
+
pending.clear();
|
|
40
|
+
};
|
|
41
|
+
child.on("exit", stopped);
|
|
42
|
+
child.on("error", stopped);
|
|
43
|
+
server = { child, pending };
|
|
44
|
+
return server;
|
|
58
45
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
headers: { "content-type": "application/json", authorization: "Bearer " + current.token },
|
|
66
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }),
|
|
67
|
-
signal: AbortSignal.timeout(90_000),
|
|
46
|
+
function rpc(method, params = {}) {
|
|
47
|
+
const { child, pending } = start();
|
|
48
|
+
const id = nextId++;
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
pending.set(id, (message) => (message.error ? reject(new Error(message.error.message)) : resolve(message.result)));
|
|
51
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
68
52
|
});
|
|
69
|
-
const payload = await response.json().catch(() => null);
|
|
70
|
-
if (!response.ok || payload?.error) throw new Error(payload?.error?.message || "Harness returned HTTP " + response.status);
|
|
71
|
-
return payload?.result;
|
|
72
53
|
}
|
|
73
54
|
|
|
55
|
+
let typeApi = null;
|
|
74
56
|
async function types() {
|
|
75
57
|
if (typeApi) return typeApi;
|
|
76
58
|
try { typeApi = (await import("@sinclair/typebox")).Type; }
|
|
@@ -104,9 +86,8 @@ function toType(schema, Type) {
|
|
|
104
86
|
export default function tryHarness(pi) {
|
|
105
87
|
const installed = new Set();
|
|
106
88
|
let connecting = null;
|
|
107
|
-
const connect =
|
|
108
|
-
|
|
109
|
-
connecting = (async () => {
|
|
89
|
+
const connect = (ctx) => {
|
|
90
|
+
connecting ??= (async () => {
|
|
110
91
|
const Type = await types();
|
|
111
92
|
const listing = await rpc("tools/list");
|
|
112
93
|
for (const def of listing?.tools || []) {
|
|
@@ -117,44 +98,63 @@ export default function tryHarness(pi) {
|
|
|
117
98
|
description: def.description || "Use this connected Harness capability.",
|
|
118
99
|
parameters: toType(def.inputSchema, Type),
|
|
119
100
|
async execute(_id, params) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
101
|
+
const result = await rpc("tools/call", { name: def.name, arguments: params || {} });
|
|
102
|
+
const content = result?.content || [{ type: "text", text: "Harness returned no content." }];
|
|
103
|
+
// pi marks a tool failed when it throws.
|
|
104
|
+
if (result?.isError) throw new Error(content.map((part) => part.text || "").join("\n") || "Harness call failed.");
|
|
105
|
+
return { content, details: undefined };
|
|
126
106
|
},
|
|
127
107
|
});
|
|
128
108
|
installed.add(def.name);
|
|
129
109
|
}
|
|
130
110
|
ctx?.ui?.notify?.("Harness connected", "info");
|
|
131
|
-
})().
|
|
111
|
+
})().catch((error) => { connecting = null; ctx?.ui?.notify?.("Harness unavailable: " + error?.message, "warning"); });
|
|
132
112
|
return connecting;
|
|
133
113
|
};
|
|
134
|
-
|
|
114
|
+
const where = (ctx) => ({ session_id: ctx?.sessionManager?.getSessionId?.(), cwd: ctx?.cwd || process.cwd() });
|
|
115
|
+
const pids = [process.pid, process.ppid];
|
|
135
116
|
const tell = (nudge, ctx) => {
|
|
136
117
|
if (!nudge) return;
|
|
137
118
|
// Into the model's context when pi offers it; always visible to the person.
|
|
138
119
|
if (typeof pi.sendMessage === "function") { try { pi.sendMessage({ customType: "harness-board", content: nudge, display: true }); } catch {} }
|
|
139
120
|
ctx?.ui?.notify?.(nudge.split("\n").slice(0, 2).join(" "), "info");
|
|
140
121
|
};
|
|
141
|
-
pi.on("session_start", async (_event, ctx) =>
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const env = Object.fromEntries(names.filter((name) => process.env[name]).map((name) => [name, process.env[name]]));
|
|
148
|
-
void daemon({ command: "board.live.place", agent: "pi", sessionId, folder: ctx?.cwd || process.cwd(), pids: [process.pid, process.ppid], env });
|
|
122
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
123
|
+
void connect(ctx);
|
|
124
|
+
const board = await harness().catch(() => null);
|
|
125
|
+
if (!board) return;
|
|
126
|
+
await board.notePlace(AGENT, where(ctx), pids);
|
|
127
|
+
tell(await board.boardNudge(AGENT), ctx);
|
|
149
128
|
});
|
|
150
129
|
// Into the model's own context: pi lets an extension return the system prompt it should use
|
|
151
|
-
// (pi 0.87: before_agent_start -> { systemPrompt }). The queue is appended, never substituted.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
130
|
+
// (pi 0.87: before_agent_start -> { systemPrompt }). The queue is appended, never substituted. The tools are in
|
|
131
|
+
// place before the run, as an MCP client's are.
|
|
132
|
+
// What the person sent this session from Harness (board.live.say), taken once, as Claude Code's hooks take it.
|
|
133
|
+
const said = async (board, ctx) => {
|
|
134
|
+
const sessionId = where(ctx).session_id;
|
|
135
|
+
const messages = sessionId ? await board.takeMessages(AGENT, sessionId) : [];
|
|
136
|
+
return messages.length ? board.messagesText(messages) : "";
|
|
137
|
+
};
|
|
138
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
139
|
+
await connect(ctx);
|
|
140
|
+
const board = await harness().catch(() => null);
|
|
141
|
+
if (!board) return undefined;
|
|
142
|
+
await board.notePrompt(AGENT, where(ctx), pids);
|
|
143
|
+
const text = [await said(board, ctx), await board.boardNudge(AGENT)].filter(Boolean).join("\n\n");
|
|
144
|
+
if (!text || typeof event?.systemPrompt !== "string") return undefined;
|
|
145
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${text}` };
|
|
146
|
+
});
|
|
147
|
+
// When a run has settled, a message waiting from Harness starts the next turn (Claude Code's Stop hook does the same).
|
|
148
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
149
|
+
const board = await harness().catch(() => null);
|
|
150
|
+
const text = board ? await said(board, ctx) : "";
|
|
151
|
+
if (text) pi.sendUserMessage(text);
|
|
156
152
|
});
|
|
157
153
|
// After each turn (pi 0.87 event name turn_end), only when the queue moved.
|
|
158
|
-
pi.on("turn_end", async (_event, ctx) =>
|
|
159
|
-
|
|
154
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
155
|
+
const board = await harness().catch(() => null);
|
|
156
|
+
if (board) tell(await board.boardNudgeIfChanged(AGENT), ctx);
|
|
157
|
+
});
|
|
158
|
+
pi.on("session_shutdown", () => { server?.child.kill(); server = null; });
|
|
159
|
+
pi.registerCommand("harness-reconnect", { description: "Reconnect Harness", handler: async (_args, ctx) => { connecting = null; await connect(ctx); } });
|
|
160
160
|
}
|
package/cli.mjs
CHANGED
|
@@ -622,6 +622,36 @@ async function agents() {
|
|
|
622
622
|
console.log(response.note ? `Every agent now reads this on ${response.agent}'s card: ${response.note}` : `${response.agent}'s card has no note now.`);
|
|
623
623
|
return;
|
|
624
624
|
}
|
|
625
|
+
// Each coding agent's models as it lists them, your choice for it, and your words on how Auto picks (models.mjs).
|
|
626
|
+
if (sub === "models") {
|
|
627
|
+
const response = await daemonRequest({ command: "board.swarm.models", agent: "user", ...(agent ? { target: agent } : {}), refresh: has("--refresh") });
|
|
628
|
+
if (!response.ok) throw new Error(response.error);
|
|
629
|
+
for (const entry of response.agents) {
|
|
630
|
+
const choice = entry.choice ? (entry.choice.auto ? "Auto" : [entry.choice.model, entry.choice.effort && `effort ${entry.choice.effort}`].filter(Boolean).join(", ")) : `its own settings${entry.ownModel ? ` (${entry.ownModel})` : ""}`;
|
|
631
|
+
console.log(`${entry.name}: runs on ${choice}`);
|
|
632
|
+
if (entry.error) console.log(` its list could not be read: ${entry.error}`);
|
|
633
|
+
if (agent) for (const model of entry.models) console.log(` ${model.id}${model.efforts.length ? ` effort ${model.efforts.join(", ")}` : ""}${model.about ? ` ${model.about}` : ""}`);
|
|
634
|
+
else console.log(` ${entry.models.length} models; list them with: npx tryharness agents models ${entry.id}`);
|
|
635
|
+
}
|
|
636
|
+
console.log(`\nAuto picks by: ${response.routing ?? `${response.defaultRouting} (the default; change it with: npx tryharness agents routing "<your words>")`}`);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (sub === "model") {
|
|
640
|
+
const model = argv[2];
|
|
641
|
+
if (!agent || !model) throw new Error("usage: tryharness agents model <agent> <model from its list> [--effort <level>] | auto | default");
|
|
642
|
+
const response = await daemonRequest({ command: "board.swarm.model", agent: "user", target: agent, model, effort: arg("--effort") });
|
|
643
|
+
if (!response.ok) throw new Error(response.error);
|
|
644
|
+
console.log(!response.choice ? `${response.agent} runs on its own settings.` : response.choice.auto ? `Auto picks ${response.agent}'s model for each run.` : `${response.agent} runs on ${response.choice.model}${response.choice.effort ? `, effort ${response.choice.effort}` : ""}.`);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (sub === "routing") {
|
|
648
|
+
const text = argv.slice(1).filter((word) => word !== "--clear").join(" ").trim();
|
|
649
|
+
if (!text && !has("--clear")) throw new Error('usage: tryharness agents routing "<how Auto should pick models for your swarm>" | --clear');
|
|
650
|
+
const response = await daemonRequest({ command: "board.swarm.routing", agent: "user", text: has("--clear") ? "" : text });
|
|
651
|
+
if (!response.ok) throw new Error(response.error);
|
|
652
|
+
console.log(`Auto picks by: ${response.routing ?? `${response.defaultRouting} (the default)`}`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
625
655
|
const ui = createUi(), only = sub === "setup" ? agent || null : null;
|
|
626
656
|
ui.intro(`${pc.bgCyan(pc.black(" tryharness "))} ${only ? `Setting up ${only}` : "Your swarm"}`);
|
|
627
657
|
await setupSwarm({ runtimeRoot: config.runtimeRoot, states: Object.values(config.agentStates || {}), daemonRequest, ui, only });
|
|
@@ -705,7 +735,7 @@ async function t3() {
|
|
|
705
735
|
}
|
|
706
736
|
|
|
707
737
|
function help() {
|
|
708
|
-
console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\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 board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text> [--ask agent,agent]\n tryharness swarm rounds [<thread>] what the coordinator saw, chose and cost each round\n tryharness swarm coordinator [harness|local] who coordinates your threads\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours, sealed to their computer\n tryharness peers the keys of other people's computers this one has worked with, to compare\n tryharness agents how Harness wakes each of your agents, and set up the rest\n tryharness agents setup <agent> | agents test <agent>\n tryharness agents cards what each of your agents is told about the others\n tryharness agents note <agent> <text> [--clear] your own note on an agent's card, told to every other agent\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness link connect the browser you use Harness in to this computer, end to end (--print shows the link)\n tryharness phone pair show a QR code that pairs your phone with this computer\n tryharness phone list | revoke <id>\n tryharness t3 approve Harness in T3 Code once, so your messages go into its threads (t3 status | t3 forget)\n tryharness logs [--follow]\n tryharness disconnect\n`);
|
|
738
|
+
console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness desktop open the Harness desktop app (setup opens it too; --no-desktop skips it)\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 board --watch follow the board in this terminal\n tryharness notify on|off desktop notifications for what waits on you\n tryharness board send <task> <message> send a held reply to another person's agent\n tryharness board auto <task> on|off auto-send replies on that thread\n tryharness board end <task> --outcome <text>\n tryharness board context <task> <query> search everything written about a task\n tryharness goal set --objective <text> [--team a,b] [--workspace <dir>] a goal your agents work together in one room\n tryharness goal [list | show <id> | stop <id> | resume <id>]\n tryharness room <goal> | room post <goal> <text> [--ask agent,agent]\n tryharness swarm rounds [<thread>] what the coordinator saw, chose and cost each round\n tryharness swarm coordinator [harness|local] who coordinates your threads\n tryharness connect <handle/agent> --about <text> ask another person's agent to work an issue with yours, sealed to their computer\n tryharness peers the keys of other people's computers this one has worked with, to compare\n tryharness agents how Harness wakes each of your agents, and set up the rest\n tryharness agents setup <agent> | agents test <agent>\n tryharness agents cards what each of your agents is told about the others\n tryharness agents note <agent> <text> [--clear] your own note on an agent's card, told to every other agent\n tryharness agents models [<agent>] [--refresh] the models each coding agent lists and the one it runs on\n tryharness agents model <agent> <model> [--effort <level>] | auto | default the model it runs on; auto lets Harness pick per run\n tryharness agents routing "<text>" [--clear] how Auto picks models for your swarm\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness link connect the browser you use Harness in to this computer, end to end (--print shows the link)\n tryharness phone pair show a QR code that pairs your phone with this computer\n tryharness phone list | revoke <id>\n tryharness t3 approve Harness in T3 Code once, so your messages go into its threads (t3 status | t3 forget)\n tryharness logs [--follow]\n tryharness disconnect\n`);
|
|
709
739
|
}
|
|
710
740
|
|
|
711
741
|
try {
|
package/lib/adapters.mjs
CHANGED
|
@@ -3,7 +3,6 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { detectAgents, run } from "./commands.mjs";
|
|
5
5
|
import { ensureDir, readJson } from "./files.mjs";
|
|
6
|
-
import { paths } from "./paths.mjs";
|
|
7
6
|
import { MACHINE_RUNNERS } from "./members.mjs";
|
|
8
7
|
|
|
9
8
|
export const AGENTS = MACHINE_RUNNERS;
|
|
@@ -37,9 +36,12 @@ export function mcpClientSnippet({ runtimeRoot, client, nodePath = process.execP
|
|
|
37
36
|
return { mcpServers: { harness: { command: nodePath, args: [path.join(runtimeRoot, "lib", "mcp-stdio.mjs"), "--agent", client] } } };
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
|
|
39
|
+
// An extension or plugin runs inside its agent and starts Harness from the runtime and Node setup used, as the hooks
|
|
40
|
+
// and MCP entries written here do.
|
|
41
|
+
function installAsset(source, target, { runtimeRoot, nodePath = process.execPath }) {
|
|
41
42
|
ensureDir(path.dirname(target));
|
|
42
|
-
fs.
|
|
43
|
+
const text = fs.readFileSync(source, "utf8").replaceAll('"__HARNESS_RUNTIME__"', JSON.stringify(runtimeRoot)).replaceAll('"__HARNESS_NODE__"', JSON.stringify(nodePath));
|
|
44
|
+
fs.writeFileSync(target, text, { mode: 0o600 });
|
|
43
45
|
try { fs.chmodSync(target, 0o600); } catch {}
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -123,7 +125,7 @@ export function removeClaudeSessionHook(homeDir = os.homedir()) {
|
|
|
123
125
|
fs.writeFileSync(file, JSON.stringify({ ...settings, hooks }, null, 2) + "\n");
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.homedir(), detected = detectAgents(), execute = run
|
|
128
|
+
export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.homedir(), detected = detectAgents(), execute = run }) {
|
|
127
129
|
const byAgent = new Map(detected.map((entry) => [entry.agent, entry]));
|
|
128
130
|
const mcpPath = path.join(runtimeRoot, "lib", "mcp-stdio.mjs");
|
|
129
131
|
const assets = integrationAssets(homeDir);
|
|
@@ -151,14 +153,13 @@ export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.h
|
|
|
151
153
|
try { installCodexHooks({ runtimeRoot, homeDir }); } catch { hooked = false; }
|
|
152
154
|
states.push({ agent, installed: true, configured: added.ok, detail: added.ok ? (hooked ? "MCP and session hooks configured; in Codex run /hooks once to trust them" : "MCP configured; the session hooks could not be written") : diagnostic(added) });
|
|
153
155
|
} else if (agent === "pi") {
|
|
154
|
-
|
|
156
|
+
// pi has no MCP client: the extension starts the same stdio server and gives pi its tools.
|
|
157
|
+
installAsset(path.join(runtimeRoot, "assets", "pi-extension.ts"), assets.pi, { runtimeRoot });
|
|
155
158
|
states.push({ agent, installed: true, configured: fs.existsSync(assets.pi), detail: "extension installed" });
|
|
156
159
|
} else {
|
|
157
|
-
installAsset(path.join(runtimeRoot, "assets", "opencode-plugin.js"), assets.opencode);
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
execute(found.path, ["mcp", "remove", "harness"]);
|
|
161
|
-
const added = execute(found.path, ["mcp", "add", "harness", "--url", String(connection.origin).replace(/\/$/, "") + "/api/mcp", "--header", "Authorization=Bearer " + connection.token]);
|
|
160
|
+
installAsset(path.join(runtimeRoot, "assets", "opencode-plugin.js"), assets.opencode, { runtimeRoot });
|
|
161
|
+
// The same stdio server as Claude Code and Codex. opencode has no `mcp remove`; `mcp add` replaces the entry.
|
|
162
|
+
const added = execute(found.path, ["mcp", "add", "harness", "--", process.execPath, mcpPath, "--agent", agent]);
|
|
162
163
|
states.push({ agent, installed: true, configured: added.ok, detail: added.ok ? "MCP and plugin configured" : `plugin installed; MCP: ${diagnostic(added)}` });
|
|
163
164
|
}
|
|
164
165
|
}
|
|
@@ -174,7 +175,14 @@ export function removeAgentConfigurations({ homeDir = os.homedir(), detected = d
|
|
|
174
175
|
if (!found.path) continue;
|
|
175
176
|
if (found.agent === "claude-code") { execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]); removeClaudeSessionHook(homeDir); }
|
|
176
177
|
else if (found.agent === "codex") { execute(found.path, ["mcp", "remove", "harness"]); removeCodexHooks(homeDir); }
|
|
177
|
-
|
|
178
|
+
}
|
|
179
|
+
// opencode has no `mcp remove`, so its entry comes out of the config file `mcp add` wrote it to.
|
|
180
|
+
for (const name of ["opencode.json", "opencode.jsonc"]) {
|
|
181
|
+
const file = path.join(homeDir, ".config", "opencode", name);
|
|
182
|
+
const config = readJson(file, null);
|
|
183
|
+
if (!config?.mcp?.harness) continue;
|
|
184
|
+
delete config.mcp.harness;
|
|
185
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
|
|
178
186
|
}
|
|
179
187
|
const assets = integrationAssets(homeDir);
|
|
180
188
|
for (const target of Object.values(assets)) {
|