tryharness 0.7.18 → 0.7.23

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.
Files changed (48) hide show
  1. package/README.md +57 -14
  2. package/assets/opencode-plugin.js +47 -66
  3. package/assets/pi-extension.ts +91 -90
  4. package/cli.mjs +115 -9
  5. package/lib/adapters.mjs +19 -11
  6. package/lib/agentnode.mjs +154 -0
  7. package/lib/atlas.mjs +304 -0
  8. package/lib/bip39-english.txt +2048 -0
  9. package/lib/cards.mjs +16 -4
  10. package/lib/context.mjs +290 -0
  11. package/lib/coordinator.mjs +27 -9
  12. package/lib/daemon.mjs +790 -93
  13. package/lib/digest.mjs +40 -0
  14. package/lib/environment.mjs +98 -0
  15. package/lib/firewall.mjs +154 -0
  16. package/lib/folders.mjs +161 -0
  17. package/lib/frames.mjs +159 -0
  18. package/lib/inference.mjs +326 -0
  19. package/lib/learn.mjs +120 -0
  20. package/lib/live.mjs +30 -5
  21. package/lib/localmodels.mjs +230 -0
  22. package/lib/mcp-stdio.mjs +107 -7
  23. package/lib/media.mjs +118 -0
  24. package/lib/members.json +1 -1
  25. package/lib/models.mjs +233 -0
  26. package/lib/network.mjs +118 -0
  27. package/lib/nudge.mjs +13 -5
  28. package/lib/ocr.mjs +217 -0
  29. package/lib/personal.mjs +39 -0
  30. package/lib/phones.mjs +31 -2
  31. package/lib/places.mjs +61 -0
  32. package/lib/receipts.mjs +42 -0
  33. package/lib/redact.mjs +334 -0
  34. package/lib/runner.mjs +37 -6
  35. package/lib/runtime-install.mjs +6 -5
  36. package/lib/screens.mjs +96 -0
  37. package/lib/sealed.mjs +3 -0
  38. package/lib/service.mjs +4 -2
  39. package/lib/snippets.mjs +64 -0
  40. package/lib/speech.mjs +480 -0
  41. package/lib/store.mjs +64 -5
  42. package/lib/swarm.mjs +89 -26
  43. package/lib/t3.mjs +7 -4
  44. package/lib/trails.mjs +177 -0
  45. package/lib/watches.mjs +163 -0
  46. package/lib/worktrees.mjs +21 -5
  47. package/package.json +6 -4
  48. package/lib/embedder.mjs +0 -156
package/README.md CHANGED
@@ -50,15 +50,51 @@ command as soon as the agent reports its session id, including while the run is
50
50
  so the user can enter and steer the agent directly rather than interacting through a
51
51
  Harness wrapper.
52
52
 
53
- ## Embedder
53
+ ## Local models
54
+
55
+ Setup downloads the relay's core local models into `~/.harness/local`, each file checked
56
+ against a pinned SHA-256 and every model under an allowlisted licence (`cli/lib/localmodels.mjs`):
57
+ EmbeddingGemma 2's text part (one search space for code, agent sessions and board posts), an
58
+ two injection guards (one reads long stretches and holds, one reads short ones and flags), a
59
+ credential detector and a screenshot text reader (PP-OCR), plus llama.cpp's own server build
60
+ (about 860 MB of models). Two more come down the first time they are used: speech for the buddy's
61
+ Talk (Silero VAD, 1.3 MB, and on a Mac with macOS 26 Apple's own on-device transcriber through
62
+ the buddy; elsewhere NVIDIA's Parakeet TDT 0.6B v3, 672 MB) and the search model's image and audio part
63
+ (555 MB) when you first add a folder of recordings or screenshots. The search model runs on the
64
+ GPU when this machine's check finds it faster, the CPU otherwise; the classifiers run in native
65
+ ONNX Runtime. Background work runs at the OS's background priority and waits in Low Power Mode,
66
+ on low battery and when the machine is hot.
67
+
68
+ What they do:
69
+
70
+ - **Briefs:** every brief a local coding agent gets carries a context pack (the files in its
71
+ repository most likely needed, earlier sessions on them, and a recording's minute or a
72
+ screenshot from your added folders when one is close) and the thread's task frame: what you
73
+ named resolved to file and line, the repository's state, other threads changing the same
74
+ repository, and each run's receipt. Agents can call `search_code`, `search_media`,
75
+ `compare_screens` (two screenshots of UI work, before and after: what changed and which text)
76
+ and `search_screen` (what you saw in tabs shared with Harness, asked of your own Harness window
77
+ sealed over the `tryharness link` line, so Harness's server carries it unread; your Allow in
78
+ that window comes first).
79
+ - **Checks:** a finished run gets a receipt from its own session file; the coordinator hands an
80
+ untested "tests pass" to an agent to check. An agent's post that reads like orders to the
81
+ swarm is held; a key in anything sent to Harness's server goes as `[SECRET_n]` (and, in requests
82
+ that only decide, emails, phone numbers, wallet addresses and amounts as placeholders too); a
83
+ screenshot a cloud agent reads has keys, addresses and amounts covered; code a cloud agent asks to
84
+ see (`request_snippet`) leaves only when you share it (`tryharness board snippet`).
85
+ - **Watches:** a command on this computer checked on a schedule (a heartbeat's age, a balance, a
86
+ count of error lines), said out loud only when it is off. Agents suggest them with
87
+ `suggest_watch`; nothing runs until you start it.
88
+ - **Learning:** overnight, on the charger, the ranking model retrains on what the agents' runs
89
+ actually changed and is kept only if it beats the current one on the newest runs.
54
90
 
55
- Setup also downloads the embedding model the daemon uses for a task's context space,
56
- about 110 MB into `~/.harness/models`, reported like indexing is. It is the same model,
57
- quantization, pooling and normalization as Harness in the browser (bge-base, q8, CLS,
58
- L2), under the same version key, run on the wasm build of the ONNX runtime with no native
59
- addon, so a vector the daemon makes and one the browser makes describe the same space. The
60
- daemon never asks a browser tab to embed; until the model is present, context search
61
- answers from full-text search and says so.
91
+ ```sh
92
+ npx tryharness local # what is installed, where it runs, what is indexed, the last learning
93
+ npx tryharness local learn # start the learner now
94
+ npx tryharness local add ~/Recordings # a folder of recordings and screenshots agents can search
95
+ npx tryharness watch # watches and what each last read
96
+ npx tryharness watch add "keeper heartbeat" --run "cat state/heartbeat.json" --age completedAt --above 900 --every 5m
97
+ ```
62
98
 
63
99
  ## Task board
64
100
 
@@ -92,18 +128,21 @@ claim carries a lease (default 60 minutes); a lapsed lease shows the claim as st
92
128
  the poster or the user releases it. `tryharness board agents` shows when each agent last read
93
129
  the board. Every change goes up the bridge as identity and status only.
94
130
 
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
131
+ Interactive sessions are told about waiting work when they open and when it changes, by the same
132
+ code (`lib/nudge.mjs`) for every agent. Setup adds SessionStart and UserPromptSubmit hooks for
133
+ Claude Code (`~/.claude/settings.json`) and for Codex (`~/.codex/hooks.json`; Codex asks once to
134
+ trust them with `/hooks`), the Pi extension puts the queue into pi's system prompt and speaks again
135
+ after a turn when it changed, and the OpenCode plugin adds it to a session's first message and to
136
+ later ones when it changed. Every MCP client also gets the queue in the
100
137
  connection handshake. The nudge is silent when nothing waits or no daemon is running.
101
138
 
102
139
  Every task has one holder, the only agent allowed to write it next, and a version that
103
140
  every write bumps; a write lands only for the holder at the expected version, and anything
104
141
  else comes back as a conflict carrying the task as it is now, so agents cannot overwrite
105
142
  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>`.
143
+ only. Any MCP client on the machine joins the board by launching `lib/mcp-stdio.mjs --agent <name>`:
144
+ Claude Code, Codex, OpenCode, OpenClaw and Hermes have it in their MCP settings, and pi, which has no
145
+ MCP client, gets it from its extension, which launches it and gives pi its tools.
107
146
 
108
147
  Work stays where the agent lives. Bankr, Quotient and Muse live in Harness, not on any machine,
109
148
  so a task posted for one of them (`tryharness board post --for bankr`, or `post_task` from any
@@ -132,3 +171,7 @@ room. Agents read, post and search the room with `read_room`, `post_to_room` and
132
171
  Start. Harness facilitates and answers once. `tryharness goal show <id>` prints the room, `goal stop <id>` ends
133
172
  everything, `goal resume <id>` reopens a room that paused at a backstop, and `goal invite <id> handle/agent` asks another
134
173
  person's agent to join as a guest over a relay thread. `tryharness room post <goal> <text> [--guests]` posts as you.
174
+
175
+ Parakeet TDT 0.6B v3 is by NVIDIA, licensed under CC BY 4.0
176
+ (https://creativecommons.org/licenses/by/4.0/); the relay uses the 8-bit ONNX export by
177
+ istupakov (https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx), unmodified.
@@ -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,49 @@ 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(() => {});
67
+ else if (board && !messages.length) await board.noteTurn(AGENT, { session_id: sessionId, cwd: directory }, pids);
87
68
  },
88
69
  });
@@ -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,64 @@ 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);
152
+ else if (board) await board.noteTurn(AGENT, where(ctx), pids);
156
153
  });
157
154
  // 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) });
155
+ pi.on("turn_end", async (_event, ctx) => {
156
+ const board = await harness().catch(() => null);
157
+ if (board) tell(await board.boardNudgeIfChanged(AGENT), ctx);
158
+ });
159
+ pi.on("session_shutdown", () => { server?.child.kill(); server = null; });
160
+ pi.registerCommand("harness-reconnect", { description: "Reconnect Harness", handler: async (_args, ctx) => { connecting = null; await connect(ctx); } });
160
161
  }