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 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. Setup adds
96
- SessionStart and UserPromptSubmit hooks for Claude Code (`~/.claude/settings.json`) and for Codex
97
- (`~/.codex/hooks.json`; Codex asks once to trust them with `/hooks`), the Pi extension puts the
98
- queue into pi's system prompt and speaks again after a turn when it changed, and the OpenCode
99
- plugin adds it to a message when it changed. Every MCP client also gets the queue in the
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 only nudges the local daemon; indexing and retry
2
- // state stay in the daemon's SQLite database and never block opencode's idle event.
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
- const socketPath = process.platform === "win32"
8
- ? "\\\\.\\pipe\\tryharness-daemon"
9
- : path.join(os.homedir(), ".harness", "daemon.sock");
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 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, 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
- // Where a session runs, as opencode's own environment says (Harness's places.mjs reads it): once per session.
65
- const PLACE_ENV = ["__CFBundleIdentifier", "TERM_PROGRAM", "TMUX", "TMUX_PANE", "WEZTERM_PANE", "KITTY_WINDOW_ID", "KITTY_LISTEN_ON", "WT_SESSION", "VSCODE_GIT_ASKPASS_NODE"];
66
- const placed = new Set();
67
- function notePlace(sessionId, directory) {
68
- if (placed.has(sessionId)) return;
69
- placed.add(sessionId);
70
- const env = Object.fromEntries(PLACE_ENV.filter((name) => process.env[name]).map((name) => [name, process.env[name]]));
71
- notify({ command: "board.live.place", agent: "opencode", sessionId, folder: directory, pids: [process.pid, process.ppid], env });
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
- // Every message of a session carries the board nudge as an extra text part when the queue changed since the last one.
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
- notePlace(sessionId, directory);
80
- const nudge = await boardNudge(sessionId);
81
- if (nudge) output.parts.push({ type: "text", text: nudge });
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) notify({ command: "sync", agent: "opencode", sessionId, cwd: directory });
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
  });
@@ -1,76 +1,58 @@
1
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";
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
- import net from "node:net";
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
- 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, 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
- let typeApi = null;
55
-
56
- function config() {
57
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
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
- async function rpc(method, params = {}) {
61
- const current = config();
62
- if (!current.origin || !current.token) throw new Error("Harness is not connected. Run: npx tryharness");
63
- const response = await fetch(String(current.origin).replace(/\/$/, "") + "/api/mcp", {
64
- method: "POST",
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 = async (ctx) => {
108
- if (connecting) return connecting;
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
- try {
121
- const result = await rpc("tools/call", { name: def.name, arguments: params || {} });
122
- return { content: result?.content || [{ type: "text", text: "Harness returned no content." }], details: {}, isError: result?.isError === true };
123
- } catch (error) {
124
- return { content: [{ type: "text", text: error?.message || "Harness call failed." }], details: {}, isError: true };
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
- })().finally(() => { connecting = null; });
111
+ })().catch((error) => { connecting = null; ctx?.ui?.notify?.("Harness unavailable: " + error?.message, "warning"); });
132
112
  return connecting;
133
113
  };
134
- pi.on("session_start", (_event, ctx) => void connect(ctx).catch((error) => ctx?.ui?.notify?.("Harness unavailable: " + error?.message, "warning")));
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) => tell(await boardNudge(false), ctx));
142
- // Where the session runs, as pi's own environment says (Harness's places.mjs reads it).
143
- pi.on("session_start", (_event, ctx) => {
144
- const sessionId = ctx?.sessionManager?.getSessionId?.();
145
- if (!sessionId) return;
146
- const names = ["__CFBundleIdentifier", "TERM_PROGRAM", "TMUX", "TMUX_PANE", "WEZTERM_PANE", "KITTY_WINDOW_ID", "KITTY_LISTEN_ON", "WT_SESSION", "VSCODE_GIT_ASKPASS_NODE"];
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
- pi.on("before_agent_start", async (event) => {
153
- const nudge = await boardNudge(false, true);
154
- if (!nudge || typeof event?.systemPrompt !== "string") return undefined;
155
- return { systemPrompt: `${event.systemPrompt}\n\n${nudge}` };
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) => tell(await boardNudge(true), ctx));
159
- pi.registerCommand("harness-reconnect", { description: "Reconnect Harness", handler: async (_args, ctx) => connect(ctx) });
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
- function installAsset(source, target) {
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.copyFileSync(source, target);
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, connection = readJson(paths.config, {}) }) {
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
- installAsset(path.join(runtimeRoot, "assets", "pi-extension.ts"), assets.pi);
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
- // Keep opencode's already-working remote MCP configuration. The daemon/plugin path
159
- // handles history; this command supplies Harness memory tools.
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
- else if (found.agent === "opencode") execute(found.path, ["mcp", "remove", "harness"]);
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)) {