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.
- package/README.md +57 -14
- package/assets/opencode-plugin.js +47 -66
- package/assets/pi-extension.ts +91 -90
- package/cli.mjs +115 -9
- package/lib/adapters.mjs +19 -11
- package/lib/agentnode.mjs +154 -0
- package/lib/atlas.mjs +304 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +16 -4
- package/lib/context.mjs +290 -0
- package/lib/coordinator.mjs +27 -9
- package/lib/daemon.mjs +790 -93
- package/lib/digest.mjs +40 -0
- package/lib/environment.mjs +98 -0
- package/lib/firewall.mjs +154 -0
- package/lib/folders.mjs +161 -0
- package/lib/frames.mjs +159 -0
- package/lib/inference.mjs +326 -0
- package/lib/learn.mjs +120 -0
- package/lib/live.mjs +30 -5
- package/lib/localmodels.mjs +230 -0
- package/lib/mcp-stdio.mjs +107 -7
- package/lib/media.mjs +118 -0
- package/lib/members.json +1 -1
- package/lib/models.mjs +233 -0
- package/lib/network.mjs +118 -0
- package/lib/nudge.mjs +13 -5
- package/lib/ocr.mjs +217 -0
- package/lib/personal.mjs +39 -0
- package/lib/phones.mjs +31 -2
- package/lib/places.mjs +61 -0
- package/lib/receipts.mjs +42 -0
- package/lib/redact.mjs +334 -0
- package/lib/runner.mjs +37 -6
- package/lib/runtime-install.mjs +6 -5
- package/lib/screens.mjs +96 -0
- package/lib/sealed.mjs +3 -0
- package/lib/service.mjs +4 -2
- package/lib/snippets.mjs +64 -0
- package/lib/speech.mjs +480 -0
- package/lib/store.mjs +64 -5
- package/lib/swarm.mjs +89 -26
- package/lib/t3.mjs +7 -4
- package/lib/trails.mjs +177 -0
- package/lib/watches.mjs +163 -0
- package/lib/worktrees.mjs +21 -5
- package/package.json +6 -4
- 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
|
-
##
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
(`~/.codex/hooks.json`; Codex asks once to
|
|
98
|
-
queue into pi's system prompt and speaks again
|
|
99
|
-
|
|
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
|
|
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,49 @@ 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(() => {});
|
|
67
|
+
else if (board && !messages.length) await board.noteTurn(AGENT, { session_id: sessionId, cwd: directory }, pids);
|
|
87
68
|
},
|
|
88
69
|
});
|
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,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
|
-
|
|
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);
|
|
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) =>
|
|
159
|
-
|
|
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
|
}
|