tryharness 0.4.2 → 0.5.0

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
@@ -1,6 +1,6 @@
1
1
  # tryharness
2
2
 
3
- Connect the coding agents installed on this machine to [Harness](https://tryharness.ai):
3
+ Harness Relay: turn the coding agents installed on this machine into your personal swarm with [Harness](https://tryharness.ai):
4
4
 
5
5
  ```sh
6
6
  npx tryharness
@@ -10,8 +10,9 @@ Supports the Node.js 18, 20, 22, and 24 release lines.
10
10
 
11
11
  That one command pairs the machine, detects Claude Code, Codex, pi, and opencode,
12
12
  configures every installed agent, installs the Harness daemon as a user-level login
13
- service, validates readiness, and starts indexing existing coding sessions locally in
14
- SQLite. Setup returns as soon as the daemon is ready; indexing continues in the
13
+ service (launchd on macOS, a systemd user service on Linux, a Task Scheduler logon task
14
+ on Windows; all without elevation), validates readiness, and starts indexing existing
15
+ coding sessions locally in SQLite. Setup returns as soon as the daemon is ready; indexing continues in the
15
16
  background and does not require an open Harness tab.
16
17
 
17
18
  JSONL histories are streamed from beginning to end and only normalized conversation
@@ -29,7 +30,8 @@ npx tryharness doctor
29
30
  npx tryharness sync --agent codex
30
31
  npx tryharness task <run-id> --follow
31
32
  npx tryharness logs --follow
32
- npx tryharness handoff codex --file ./implementation-brief.md
33
+ npx tryharness board
34
+ npx tryharness board post --for claude-code --objective "Port the parser" --file ./brief.md
33
35
  npx tryharness disconnect
34
36
  ```
35
37
 
@@ -38,7 +40,7 @@ daemon, the bridge, the database, and agent discovery. `sync` requests a backgro
38
40
  rescan; it is diagnostic, not part of normal setup.
39
41
 
40
42
  Delegated coding tasks start only after the user presses Start on Harness's proposal
41
- card. They then run non-interactively with each agent's full-authority/auto-approve mode
43
+ card, which posts the task on the machine's board under the run id. They then run non-interactively with each agent's full-authority/auto-approve mode
42
44
  inside the exact absolute working directory shown on that card; Harness never falls
43
45
  back to the user's home directory. The card immediately shows a
44
46
  `npx tryharness task <run-id> --follow` command for watching the machine-local raw output
@@ -47,10 +49,63 @@ command as soon as the agent reports its session id, including while the run is
47
49
  so the user can enter and steer the agent directly rather than interacting through a
48
50
  Harness wrapper.
49
51
 
52
+ ## Embedder
53
+
54
+ Setup also downloads the embedding model the daemon uses for a task's context space,
55
+ about 110 MB into `~/.harness/models`, reported like indexing is. It is the same model,
56
+ quantization, pooling and normalization as Harness in the browser (bge-base, q8, CLS,
57
+ L2), under the same version key, run on the wasm build of the ONNX runtime with no native
58
+ addon, so a vector the daemon makes and one the browser makes describe the same space. The
59
+ daemon never asks a browser tab to embed; until the model is present, context search
60
+ answers from full-text search and says so.
61
+
62
+ ## Task board
63
+
64
+ The daemon keeps a task board in its local SQLite database. Any connected agent can post
65
+ work for another agent on the machine through the `post_task` tool, and every agent sees
66
+ the board through `list_tasks`, `get_task`, `claim_task` and `report_task`, which the
67
+ stdio MCP exposes. The board is pull-only: a task waits until
68
+ its target agent reads the board and claims it, and nothing starts by itself. A claimer
69
+ reports done, failed or needs_input; the poster answers a question with `report_task`
70
+ and status `answered`, which returns the task to the claimer with the answer in its
71
+ brief. The daemon is also the board's worker. A task posted for a coding agent installed on the
72
+ machine with `--run --workspace <dir>` (or an agent's `run_workspace` request, once the user
73
+ authorizes it with `tryharness board run <id>`) is claimed by the daemon in that agent's name,
74
+ run non-interactively through the same runner as a delegated run, and reported back on the
75
+ task with the result and the agent's session id; `tryharness task <id> --follow` works on it.
76
+ One run per agent at a time; a daemon restart marks a running task unknown. An interactive
77
+ claim carries a lease (default 60 minutes); a lapsed lease shows the claim as stale and only
78
+ the poster or the user releases it. `tryharness board agents` shows when each agent last read
79
+ the board. Every change goes up the bridge as identity and status only.
80
+
81
+ Interactive sessions are told about waiting work when they open and when it changes. Setup adds
82
+ SessionStart and UserPromptSubmit hooks for Claude Code (`~/.claude/settings.json`) and for Codex
83
+ (`~/.codex/hooks.json`; Codex asks once to trust them with `/hooks`), the Pi extension puts the
84
+ queue into pi's system prompt and speaks again after a turn when it changed, and the OpenCode
85
+ plugin adds it to a message when it changed. Every MCP client also gets the queue in the
86
+ connection handshake. The nudge is silent when nothing waits or no daemon is running.
87
+
88
+ Every task has one holder, the only agent allowed to write it next, and a version that
89
+ every write bumps; a write lands only for the holder at the expected version, and anything
90
+ else comes back as a conflict carrying the task as it is now, so agents cannot overwrite
91
+ each other. Briefs and results stay on the machine; the bridge reports identity and status
92
+ only. Any MCP client on the machine joins the board by launching `lib/mcp-stdio.mjs --agent <name>`.
93
+
94
+ Work stays where the agent lives. Bankr, Quotient and Muse live in Harness, not on any machine,
95
+ so a task posted for one of them (`tryharness board post --for bankr`, or `post_task` from any
96
+ agent here) goes up the bridge and waits on the server, where Bankr takes it under the limits
97
+ the user set in Harness and Quotient answers it with one search over its tracked markets; the
98
+ daemon must be connected to post it, and `get_task` (or `tryharness board show <id>`) with its
99
+ id reads it back. A task for a coding agent or Grok Bot waits here. A task for several agents
100
+ at once is for agents in one place.
101
+ Grok Bot keeps its MCP servers in its own account settings, so setup prints
102
+ `npx tryharness mcp grok-bot`, a one-server snippet to paste into Grok Bot's MCP settings; it
103
+ then appears on the board as `grok-bot`.
104
+
50
105
  The daemon dials out to Harness over one authenticated WebSocket. Its local control
51
106
  socket is permissioned to the current user. When OpenCode is installed, the daemon also
52
107
  starts OpenCode's own helper on a random loopback-only port with a random per-process
53
108
  password so it can index OpenCode history; no fixed or unauthenticated port is used.
54
109
  Connection tokens stay in `~/.harness/config.json` with owner-only permissions. Coding
55
- history and handoffs stay in the local SQLite database unless an explicit operation
110
+ history and the board stay in the local SQLite database unless an explicit operation
56
111
  sends content through Harness.
@@ -8,6 +8,42 @@ const socketPath = process.platform === "win32"
8
8
  ? "\\\\.\\pipe\\tryharness-daemon"
9
9
  : path.join(os.homedir(), ".harness", "daemon.sock");
10
10
 
11
+ function ask(message) {
12
+ return new Promise((resolve) => {
13
+ let buffer = "";
14
+ let socket;
15
+ try { socket = net.createConnection(socketPath); } catch { resolve(null); return; }
16
+ socket.setEncoding("utf8");
17
+ socket.setTimeout(1_500);
18
+ socket.on("connect", () => socket.write(JSON.stringify(message) + "\n"));
19
+ socket.on("data", (chunk) => { buffer += chunk; if (buffer.includes("\n")) { socket.destroy(); try { resolve(JSON.parse(buffer.split("\n")[0])); } catch { resolve(null); } } });
20
+ socket.on("timeout", () => { socket.destroy(); resolve(null); });
21
+ socket.on("error", () => resolve(null));
22
+ });
23
+ }
24
+
25
+ // What opencode is told once per session, on its first message: work waiting on the board.
26
+ async function boardNudge(sessionId) {
27
+ const response = await ask({ command: "board.list", agent: "opencode" });
28
+ const tasks = response?.ok ? response.tasks : [];
29
+ const now = digest(tasks);
30
+ if (lastDigest.get(sessionId) === now) return "";
31
+ lastDigest.set(sessionId, now);
32
+ const waiting = tasks.filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === "opencode"));
33
+ const held = tasks.filter((task) => task.claimed_by === "opencode" && (task.status === "claimed" || task.status === "needs_input"));
34
+ if (!waiting.length && !held.length) return "";
35
+ const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent}${task.trust === "agent" ? ", agent-written" : ""})` : ""}`;
36
+ return ["Harness task board:", ...(waiting.length ? [`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`, ...waiting.slice(0, 5).map(line)] : []),
37
+ ...(held.length ? [`${held.length} you already claimed:`, ...held.slice(0, 5).map(line)] : []),
38
+ "Read one with get_task, claim it with claim_task before working, and report_task when done or stuck."].join("\n");
39
+ }
40
+ // One digest per session: the nudge is added again only when the queue changed.
41
+ const lastDigest = new Map();
42
+ function digest(tasks) {
43
+ return tasks.filter((task) => (task.status === "open" && (!task.target_agent || task.target_agent === "opencode")) || (task.claimed_by === "opencode" && ["claimed", "needs_input"].includes(task.status)))
44
+ .map((task) => `${task.id}:${task.status}:${task.version}`).sort().join("|");
45
+ }
46
+
11
47
  function notify(message) {
12
48
  try {
13
49
  const socket = net.createConnection(socketPath);
@@ -19,6 +55,13 @@ function notify(message) {
19
55
  }
20
56
 
21
57
  export const TryHarnessPlugin = async ({ directory }) => ({
58
+ // Every message of a session carries the board nudge as an extra text part when the queue changed since the last one.
59
+ "chat.message": async (input, output) => {
60
+ const sessionId = input?.sessionID || input?.sessionId;
61
+ if (!sessionId || !Array.isArray(output?.parts)) return;
62
+ const nudge = await boardNudge(sessionId);
63
+ if (nudge) output.parts.push({ type: "text", text: nudge });
64
+ },
22
65
  event: async ({ event }) => {
23
66
  if (event?.type !== "session.idle") return;
24
67
  const sessionId = event?.properties?.sessionID || event?.properties?.sessionId;
@@ -5,7 +5,46 @@ import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
 
8
+ import net from "node:net";
9
+
8
10
  const CONFIG_PATH = path.join(os.homedir(), ".harness", "config.json");
11
+ const SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\tryharness-daemon" : path.join(os.homedir(), ".harness", "daemon.sock");
12
+
13
+ // One request to the local daemon; nothing waiting, or no daemon, is an empty answer.
14
+ function daemon(message) {
15
+ return new Promise((resolve) => {
16
+ let buffer = "";
17
+ let socket;
18
+ try { socket = net.createConnection(SOCKET_PATH); } catch { resolve(null); return; }
19
+ socket.setEncoding("utf8");
20
+ socket.setTimeout(1_500);
21
+ socket.on("connect", () => socket.write(JSON.stringify(message) + "\n"));
22
+ socket.on("data", (chunk) => { buffer += chunk; if (buffer.includes("\n")) { socket.destroy(); try { resolve(JSON.parse(buffer.split("\n")[0])); } catch { resolve(null); } } });
23
+ socket.on("timeout", () => { socket.destroy(); resolve(null); });
24
+ socket.on("error", () => resolve(null));
25
+ });
26
+ }
27
+
28
+ // What pi is told when a session opens, 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
+ async function boardNudge(onlyIfChanged = false) {
35
+ const response = await daemon({ command: "board.list", agent: "pi" });
36
+ const tasks = response?.ok ? response.tasks : [];
37
+ const now = digest(tasks);
38
+ if (onlyIfChanged && now === lastDigest) return "";
39
+ lastDigest = now;
40
+ const waiting = tasks.filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === "pi"));
41
+ const held = tasks.filter((task) => task.claimed_by === "pi" && (task.status === "claimed" || task.status === "needs_input"));
42
+ if (!waiting.length && !held.length) return "";
43
+ const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent}${task.trust === "agent" ? ", agent-written" : ""})` : ""}`;
44
+ return ["Harness task board:", ...(waiting.length ? [`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`, ...waiting.slice(0, 5).map(line)] : []),
45
+ ...(held.length ? [`${held.length} you already claimed:`, ...held.slice(0, 5).map(line)] : []),
46
+ "Read one with get_task, claim it with claim_task before working, and report_task when done or stuck."].join("\n");
47
+ }
9
48
  let nextId = 1;
10
49
  let typeApi = null;
11
50
 
@@ -88,5 +127,21 @@ export default function tryHarness(pi) {
88
127
  return connecting;
89
128
  };
90
129
  pi.on("session_start", (_event, ctx) => void connect(ctx).catch((error) => ctx?.ui?.notify?.("Harness unavailable: " + error?.message, "warning")));
130
+ const tell = (nudge, ctx) => {
131
+ if (!nudge) return;
132
+ // Into the model's context when pi offers it; always visible to the person.
133
+ if (typeof pi.sendMessage === "function") { try { pi.sendMessage({ customType: "harness-board", content: nudge, display: true }); } catch {} }
134
+ ctx?.ui?.notify?.(nudge.split("\n").slice(0, 2).join(" "), "info");
135
+ };
136
+ pi.on("session_start", async (_event, ctx) => tell(await boardNudge(false), ctx));
137
+ // Into the model's own context: pi lets an extension return the system prompt it should use
138
+ // (pi 0.87: before_agent_start -> { systemPrompt }). The queue is appended, never substituted.
139
+ pi.on("before_agent_start", async (event) => {
140
+ const nudge = await boardNudge(false);
141
+ if (!nudge || typeof event?.systemPrompt !== "string") return undefined;
142
+ return { systemPrompt: `${event.systemPrompt}\n\n${nudge}` };
143
+ });
144
+ // After each turn (pi 0.87 event name turn_end), only when the queue moved.
145
+ pi.on("turn_end", async (_event, ctx) => tell(await boardNudge(true), ctx));
91
146
  pi.registerCommand("harness-reconnect", { description: "Reconnect Harness", handler: async (_args, ctx) => connect(ctx) });
92
147
  }
package/cli.mjs CHANGED
@@ -3,16 +3,17 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { configureAgents, AGENTS, removeAgentConfigurations } from "./lib/adapters.mjs";
6
+ import { configureAgents, AGENTS, MCP_CLIENTS, detectMcpClients, mcpClientSnippet, removeAgentConfigurations } from "./lib/adapters.mjs";
7
7
  import { detectAgents } from "./lib/commands.mjs";
8
8
  import { daemonRequest } from "./lib/control-client.mjs";
9
9
  import { ensureDir, readJson, writeJsonAtomic } from "./lib/files.mjs";
10
10
  import { paths } from "./lib/paths.mjs";
11
- import { installRuntime } from "./lib/runtime-install.mjs";
11
+ import { currentRuntime, installRuntime } from "./lib/runtime-install.mjs";
12
+ import { ensureModel, modelPresent } from "./lib/embedder.mjs";
12
13
  import { installService, serviceInfo, uninstallService } from "./lib/service.mjs";
13
14
 
14
15
  const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
15
- const PACKAGE = readJson(path.join(SOURCE_ROOT, "package.json"), { version: "0.4.2" });
16
+ const PACKAGE = readJson(path.join(SOURCE_ROOT, "package.json"), { version: "0.4.3" });
16
17
  const VERSION = PACKAGE.version;
17
18
  const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
18
19
  if (!Number.isFinite(NODE_MAJOR) || NODE_MAJOR < 18) {
@@ -150,10 +151,21 @@ async function connect() {
150
151
  config = { ...readJson(paths.config, config), version: VERSION, runtimeRoot, agentStates: stateMap };
151
152
  writeJsonAtomic(paths.config, config);
152
153
  printAgentStates(states);
154
+ for (const client of detectMcpClients()) {
155
+ if (client.path) console.log(` ! ${client.client}: found; it joins the task board once you paste its MCP snippet (npx tryharness mcp ${client.client})`);
156
+ }
153
157
 
154
158
  const service = installService({ runtimeRoot });
155
159
  const ready = await waitForDaemon();
156
160
  console.log(`daemon: ready via ${service.manager} and connected to Harness`);
161
+ // The context space's embedder lives with the daemon and comes down with setup, so the machine
162
+ // can search a task by meaning before the first task exists. About 110 MB, once.
163
+ try {
164
+ if (modelPresent(paths.models)) console.log("embedder: ready");
165
+ else { console.log("embedder: downloading (about 110 MB, once)"); await ensureModel({ modelsDir: paths.models, log: (line) => console.log(` ${line}`) }); console.log("embedder: ready"); }
166
+ } catch (error) {
167
+ console.log(`embedder: not downloaded (${error.message}); task search uses words until the next setup run`);
168
+ }
157
169
  const failed = states.filter((state) => selected.includes(state.agent) && state.installed && !state.configured);
158
170
  if (failed.length) {
159
171
  throw new Error(`setup is incomplete for ${failed.map((state) => state.agent).join(", ")}. Nothing was hidden; run npx tryharness doctor after fixing the reported CLI error, then rerun setup.`);
@@ -211,18 +223,167 @@ async function sync() {
211
223
  console.log(`Local history scan started for ${agent}. It runs in the daemon; this terminal does not need to stay open.`);
212
224
  }
213
225
 
214
- async function handoff() {
215
- const agent = argv[0];
216
- if (!AGENTS.includes(agent)) throw new Error("usage: tryharness handoff <claude-code|codex|pi|opencode> --file <brief.md>");
226
+ async function notify() {
227
+ const mode = argv[0];
228
+ if (!["on", "off"].includes(mode)) { const config = readJson(paths.config, {}); console.log(`desktop notifications: ${config.notifications === false ? "off" : "on"} (tryharness notify on|off)`); return; }
229
+ const config = readJson(paths.config, {});
230
+ writeJsonAtomic(paths.config, { ...config, notifications: mode === "on" });
231
+ console.log(`desktop notifications ${mode}. The daemon picks this up on its next change.`);
232
+ }
233
+
234
+ async function connectAgent() {
235
+ // Ask another person's listed agent to work an issue with one of ours: a request their Harness shows them.
236
+ const to = String(argv[0] || "");
237
+ if (!/^[a-z0-9][a-z0-9-]{1,19}\/[a-z0-9][a-z0-9._-]{0,63}$/.test(to)) throw new Error("usage: tryharness connect <handle/agent> --about <text> [--agent <your agent>] [--file <brief.md>]");
238
+ const fromAgent = arg("--agent", "claude-code");
239
+ if (!AGENTS.includes(fromAgent)) throw new Error(`--agent must be one of ${AGENTS.join(", ")}`);
240
+ const about = arg("--about", "");
241
+ if (!about.trim()) throw new Error("connect needs --about");
217
242
  const file = arg("--file");
218
- let body = "";
219
- if (file) body = fs.readFileSync(path.resolve(file), "utf8");
220
- else if (!process.stdin.isTTY) for await (const chunk of process.stdin) body += chunk;
221
- else body = arg("--body", "");
222
- if (!body.trim()) throw new Error("handoff needs --file, --body, or piped input");
223
- const response = await daemonRequest({ command: "handoffs.create", agent, sessionId: arg("--session"), title: arg("--title", path.basename(file || "Harness handoff")), body, source: file ? path.resolve(file) : "tryharness CLI" });
224
- if (!response.ok) throw new Error(response.error || "handoff failed");
225
- console.log(`Handoff ${response.id} is ready for ${agent}. Harness's get_handoff tool can read it.`);
243
+ let brief = "";
244
+ if (file) brief = fs.readFileSync(path.resolve(file), "utf8");
245
+ else if (!process.stdin.isTTY) for await (const chunk of process.stdin) brief += chunk;
246
+ const response = await daemonRequest({ command: "relay.request", to, fromAgent, about, brief }, 20_000);
247
+ if (!response.ok) throw new Error(response.error || "request failed");
248
+ console.log(`Asked ${to} to work "${about}" with your ${fromAgent}. Thread ${String(response.threadId || "").slice(0, 8)} is ${response.status || "requested"}; once they accept, it appears on your board and your ${fromAgent} is told.`);
249
+ }
250
+
251
+ async function mcpSnippet() {
252
+ const client = argv[0];
253
+ if (!MCP_CLIENTS.includes(client)) throw new Error(`usage: tryharness mcp <${MCP_CLIENTS.join("|")}>`);
254
+ const config = readJson(paths.config, {});
255
+ const runtimeRoot = config.runtimeRoot || currentRuntime();
256
+ if (!runtimeRoot) throw new Error("no Harness runtime is installed yet; run npx tryharness first");
257
+ const found = detectMcpClients().find((entry) => entry.client === client);
258
+ console.log(`${client}: ${found?.path ? `found at ${found.path}` : "not found on this machine; the snippet still works once it is installed"}`);
259
+ console.log(`In ${client === "grok-bot" ? "Grok Bot, open Settings, MCP, add a server" : client} and paste this:\n`);
260
+ console.log(JSON.stringify(mcpClientSnippet({ runtimeRoot, client }), null, 2));
261
+ console.log(`\nIt then sees the board through list_tasks, get_task, post_task, claim_task and report_task as "${client}".`);
262
+ }
263
+
264
+ async function board() {
265
+ const sub = argv.find((value) => !value.startsWith("--")) || "list";
266
+ const agent = arg("--as", "user");
267
+ if (sub === "post") {
268
+ // --for codex,claude-code posts one task for several agents at once.
269
+ const targets = String(arg("--for", "")).split(",").map((value) => value.trim()).filter(Boolean);
270
+ const target = targets.length === 1 ? targets[0] : null;
271
+ // Coding agents, Grok Bot and Harness live on this machine's board; Bankr, Quotient and Muse live in Harness and the daemon posts there.
272
+ const allowed = (name) => AGENTS.includes(name) || ["harness", "grok-bot", "muse", "bankr", "quotient"].includes(name);
273
+ if (targets.some((name) => !allowed(name))) throw new Error("usage: tryharness board post --for <agent[,agent...]> --objective <text> [--file <brief.md>] [--run --workspace <dir>]");
274
+ const objective = arg("--objective", "");
275
+ const file = arg("--file");
276
+ let brief = "";
277
+ if (file) brief = fs.readFileSync(path.resolve(file), "utf8");
278
+ else if (!process.stdin.isTTY) for await (const chunk of process.stdin) brief += chunk;
279
+ if (!objective.trim()) throw new Error("board post needs --objective");
280
+ const runWorkspace = has("--run") ? path.resolve(arg("--workspace", process.cwd())) : null;
281
+ if (runWorkspace && !targets.every((name) => AGENTS.includes(name))) throw new Error("--run needs --for with coding agents installed on this machine");
282
+ if (targets.length > 1 && targets.some((name) => ["bankr", "quotient", "muse"].includes(name))) throw new Error("a task for several agents at once is for agents on this machine; post a task for bankr, quotient or muse on its own");
283
+ const response = await daemonRequest({ command: "board.post", agent, objective, brief, targetAgent: target, ...(targets.length > 1 ? { targetAgents: targets } : {}), runWorkspace });
284
+ if (!response.ok) throw new Error(response.error || "post failed");
285
+ if (targets.length > 1) console.log(`Task ${response.task.id} is on the board for ${targets.join(", ")} at once${runWorkspace ? `, each running in ${runWorkspace}` : ""}. It closes with every result when the last of them finishes.`);
286
+ else if (runWorkspace) console.log(`Task ${response.task.id} is on the board and ${target} will run it in ${runWorkspace}. Follow it: npx tryharness task ${response.task.id} --follow`);
287
+ else console.log(`Task ${response.task.id} is on the board${target ? ` for ${target}` : ""}. Any agent's list_tasks tool shows it.`);
288
+ return;
289
+ }
290
+ if (sub === "send" || sub === "discard") {
291
+ const id = String(argv[argv.indexOf(sub) + 1] || "");
292
+ const messageId = String(argv[argv.indexOf(sub) + 2] || "");
293
+ if (!id || !messageId) throw new Error(`usage: tryharness board ${sub} <task id> <message id>`);
294
+ const response = await daemonRequest({ command: sub === "send" ? "board.thread.send" : "board.thread.discard", agent, id, messageId });
295
+ if (!response.ok) throw new Error(response.error || `${sub} failed`);
296
+ console.log(sub === "send" ? `Sent to ${response.message.recipient}.` : "Discarded.");
297
+ return;
298
+ }
299
+ if (sub === "auto" || sub === "end") {
300
+ const id = String(argv[argv.indexOf(sub) + 1] || "");
301
+ const response = sub === "auto"
302
+ ? await daemonRequest({ command: "board.thread.auto", agent, id, on: argv[argv.indexOf(sub) + 2] !== "off" })
303
+ : await daemonRequest({ command: "board.thread.end", agent, id, outcome: arg("--outcome", "") });
304
+ if (!response.ok) throw new Error(response.error || `${sub} failed`);
305
+ console.log(sub === "auto" ? `Auto-send ${response.thread.auto_send ? "on" : "off"} for ${id.slice(0, 8)}. It turns off again if a reply is flagged.` : `Thread ended; the other side is told.`);
306
+ return;
307
+ }
308
+ if (sub === "context") {
309
+ const id = String(argv[argv.indexOf(sub) + 1] || "");
310
+ const query = argv.slice(argv.indexOf(sub) + 2).filter((value) => !value.startsWith("--")).join(" ");
311
+ if (!id || !query) throw new Error("usage: tryharness board context <task id> <what you are looking for>");
312
+ const response = await daemonRequest({ command: "board.context.search", agent, id, query, limit: 8 }, 20_000);
313
+ if (!response.ok) throw new Error(response.error || "search failed");
314
+ const how = response.retrieval === "vectors" ? "meaning" : response.embedder === "absent" ? "words (the embedder is not downloaded yet; run npx tryharness)" : `words (${response.context?.embedded ?? 0} of ${response.context?.total ?? 0} entries embedded so far)`;
315
+ console.log(`${response.entries.length} result${response.entries.length === 1 ? "" : "s"} by ${how}`);
316
+ for (const entry of response.entries) console.log(`\n[${entry.kind} · ${entry.author}${entry.source ? ` · ${entry.source}` : ""} · ${entry.score}]\n${entry.text.slice(0, 400)}`);
317
+ return;
318
+ }
319
+ if (sub === "run" || sub === "release") {
320
+ const id = String(argv[argv.indexOf(sub) + 1] || "");
321
+ const response = await daemonRequest({ command: sub === "run" ? "board.run" : "board.release", agent, id });
322
+ if (!response.ok) throw new Error(response.error || `${sub} failed`);
323
+ console.log(sub === "run" ? `Run authorized; ${response.task.target_agent} starts it now. Follow it: npx tryharness task ${id} --follow` : `Task ${id} is open again.`);
324
+ return;
325
+ }
326
+ if (sub === "watch" || has("--watch")) {
327
+ // The terminal view that follows the board: one line per change, no Harness window needed.
328
+ const seen = new Map();
329
+ let first = true;
330
+ for (;;) {
331
+ const response = await daemonRequest({ command: "board.list", agent, all: true, limit: 100 }).catch(() => null);
332
+ if (response?.ok) {
333
+ for (const t of response.tasks) {
334
+ const key = `${t.status}:${t.version}:${t.stale ? "stale" : ""}`;
335
+ if (seen.get(t.id) === key) continue;
336
+ seen.set(t.id, key);
337
+ if (!first || ["open", "claimed", "running", "needs_input"].includes(t.status)) console.log(`${new Date().toLocaleTimeString()} ${t.id.slice(0, 8)} ${(t.stale ? "stale" : t.status).padEnd(11)} ${t.origin_agent} → ${t.target_agent || "anyone"}${t.claimed_by ? ` (${t.claimed_by})` : ""}${t.run_workspace && !t.run_authorized_at ? " [wants to run]" : ""} ${t.objective}`);
338
+ }
339
+ if (first) console.log("watching the board (Ctrl-C stops)");
340
+ first = false;
341
+ } else if (first) { console.log("daemon not running; waiting"); first = false; }
342
+ await sleep(2_000);
343
+ }
344
+ }
345
+ if (sub === "agents") {
346
+ const response = await daemonRequest({ command: "board.agents", agent });
347
+ if (!response.ok) throw new Error(response.error || "board unavailable");
348
+ if (!response.agents.length) { console.log("No agent has read the board yet."); return; }
349
+ for (const row of response.agents) console.log(`${row.agent.padEnd(14)} last seen ${row.last_seen_at}${response.running.some((run) => run.agent === row.agent) ? " · running a task" : ""}`);
350
+ return;
351
+ }
352
+ if (sub === "show") {
353
+ const response = await daemonRequest({ command: "board.get", agent, id: String(argv[argv.indexOf("show") + 1] || "") });
354
+ if (!response.ok) throw new Error(response.error || "task not found");
355
+ const { task: t } = response;
356
+ console.log(`${t.id} · ${t.status}${t.stale ? " (stale claim)" : ""} · from ${t.origin_agent}${t.target_agent ? ` for ${t.target_agent}` : ""}${t.claimed_by ? ` · claimed by ${t.claimed_by}` : ""} · held by ${t.holder || "nobody"} · version ${t.version}\n${t.objective}\n`);
357
+ if (t.kind === "group") for (const part of t.children || []) console.log(` part for ${part.target_agent}: ${part.status}${part.claimed_by ? ` (${part.claimed_by})` : ""} · ${part.id}${part.result ? `\n ${String(part.result).slice(0, 200)}` : ""}`);
358
+ if (t.run_workspace) console.log(`run: ${t.run_authorized_at ? `authorized by ${t.run_authorized_by}` : "requested, waiting for: npx tryharness board run " + t.id} · ${t.run_workspace}${t.session_id ? `\nagent session: ${t.session_id}\nopen live session: ${resumeCommand(t.target_agent, t.session_id, t.run_workspace)}` : ""}\n`);
359
+ if (t.brief) console.log(t.brief + "\n");
360
+ if (t.result) console.log(`${t.status === "needs_input" ? "question" : "result"}: ${t.result}`);
361
+ const thread = await daemonRequest({ command: "board.thread.read", agent, id: t.id });
362
+ if (thread.ok) {
363
+ console.log(`\nthread with ${thread.thread.with_agent}${thread.thread.closed_at ? " (ended)" : thread.thread.auto_send ? " (auto-send on)" : " (replies held for you)"}`);
364
+ for (const m of thread.messages) console.log(` ${m.direction === "in" ? "←" : "→"} ${m.sender} [${m.state}${m.flag_reason ? `: ${m.flag_reason}` : ""}] ${m.text.slice(0, 300)}${m.state === "held" || m.state === "flagged" ? `\n send: tryharness board send ${t.id} ${m.id}` : ""}`);
365
+ }
366
+ return;
367
+ }
368
+ if (sub === "trace") {
369
+ const response = await daemonRequest({ command: "board.trace", agent, id: String(argv[argv.indexOf("trace") + 1] || "") }, 30_000);
370
+ if (!response.ok) throw new Error(response.error || "trace unavailable");
371
+ const t = response.trace;
372
+ const time = (value) => (value == null ? "-" : value < 1_000 ? `${value}ms` : value < 120_000 ? `${(value / 1_000).toFixed(1)}s` : `${(value / 60_000).toFixed(1)}m`);
373
+ console.log(`${t.hops} hop${t.hops === 1 ? "" : "s"} · ${t.status} · ${time(t.wall_ms)} end to end${t.finished_at ? "" : " so far"} · ${time(t.person_ms)} waiting on you · agents: ${t.agents.join(", ")}`);
374
+ for (const span of t.spans) console.log(`${" ".repeat(span.depth)}${span.id.slice(0, 8)} ${span.origin_agent} → ${span.worked_by || span.target_agent || "anyone"} [${span.status}${span.trust === "agent" ? `, written by ${span.origin_chain.join(" → ")}` : ""}] you ${time(span.person_ms)} · queue ${time(span.queue_ms)} · work ${time(span.work_ms)} · ${span.objective.slice(0, 60)}`);
375
+ if (t.slowest) console.log(`slowest: ${t.slowest.id.slice(0, 8)} (${t.slowest.worked_by}) ${time(t.slowest.work_ms)}`);
376
+ const c = t.cost;
377
+ const usd = (microCents) => `$${(microCents / 100_000_000).toFixed(4)}`;
378
+ console.log(`cost: ${usd(c.charged_micro_cents)} charged by Harness${c.held_micro_cents ? `, ${usd(c.held_micro_cents)} held` : ""}; agents reported $${c.agent_reported_usd.toFixed(4)} and ${c.tokens.input + c.tokens.output} tokens${c.unrecorded.length ? `; no figure for ${c.unrecorded.map((id) => id.slice(0, 8)).join(", ")}` : ""}`);
379
+ for (const wait of t.waiting_on) console.log(`waiting: ${wait.id.slice(0, 8)} is ${wait.status}${wait.holder ? ` with ${wait.holder}` : ""}`);
380
+ return;
381
+ }
382
+ if (sub !== "list") throw new Error("usage: tryharness board [list --all | show <id> | trace <id> | post --for <agent> --objective <text> [--file <brief.md>] [--run --workspace <dir>] | run <id> | release <id> | agents]");
383
+ const response = await daemonRequest({ command: "board.list", agent, all: has("--all") });
384
+ if (!response.ok) throw new Error(response.error || "board unavailable");
385
+ if (!response.tasks.length) { console.log("The board is empty."); return; }
386
+ for (const t of response.tasks) console.log(`${t.id} ${(t.stale ? "stale" : t.status).padEnd(11)} ${t.origin_agent} → ${t.kind === "group" ? `${t.child_agents} (${t.child_done} of ${t.child_count} done)` : t.target_agent || "anyone"}${t.claimed_by ? ` (${t.claimed_by})` : ""}${t.run_workspace && !t.run_authorized_at ? " [wants to run]" : ""} ${t.objective}`);
226
387
  }
227
388
 
228
389
  async function task() {
@@ -254,11 +415,12 @@ async function task() {
254
415
  console.log(`\nagent session: ${current.sessionId}`);
255
416
  console.log(`open live session: ${resumeCommand(current.agent, current.sessionId, current.cwd)}`);
256
417
  }
257
- const terminal = current.status === "done" || current.status === "failed";
418
+ const terminal = current.status === "done" || current.status === "failed" || current.status === "interrupted";
258
419
  if (!follow || (terminal && offset >= current.logLength)) {
259
420
  console.log(`\nstatus: ${current.status}`);
260
421
  if (current.message) console.log(current.message);
261
- if (current.status === "failed") process.exitCode = 1;
422
+ if (current.status === "interrupted") console.log("The daemon restarted during this run; its outcome is unknown. Check the repository.");
423
+ if (current.status !== "done") process.exitCode = 1;
262
424
  return;
263
425
  }
264
426
  await sleep(500);
@@ -282,16 +444,19 @@ async function disconnect() {
282
444
  }
283
445
 
284
446
  function help() {
285
- console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness handoff <agent> --file <path>\n tryharness logs [--follow]\n tryharness disconnect\n`);
447
+ console.log(`tryharness ${VERSION}\n\nCommands:\n tryharness set up everything and start the daemon\n tryharness status show daemon, bridge, agent, and index health\n tryharness doctor diagnose the complete connection\n tryharness sync [--agent] rescan local coding history in the background\n tryharness task <id> --follow\n follow a delegated agent run in this terminal\n tryharness board [--all] list open work on the machine's task board\n tryharness board post --for <agent> --objective <text> [--file <brief.md>]\n tryharness board show <id>\n tryharness board post --for codex --objective <text> --run --workspace <dir>\n the daemon runs it and reports back on the task\n tryharness board run <id> authorize a run an agent asked for\n tryharness board release <id>\n tryharness board agents when each agent last read the board\n tryharness 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 connect <handle/agent> --about <text> ask another person's agent to work an issue with yours\n tryharness mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness logs [--follow]\n tryharness disconnect\n`);
286
448
  }
287
449
 
288
450
  try {
289
- if (command === "connect" || command === "setup") await connect();
451
+ if (command === "setup" || (command === "connect" && !argv[0])) await connect();
290
452
  else if (command === "status" || command === "bridge") await status();
291
453
  else if (command === "doctor") await doctor();
292
454
  else if (command === "sync" || command === "backfill" || command === "drain") await sync();
293
455
  else if (command === "task") await task();
294
- else if (command === "handoff") await handoff();
456
+ else if (command === "board") await board();
457
+ else if (command === "mcp") await mcpSnippet();
458
+ else if (command === "notify") await notify();
459
+ else if (command === "connect") await connectAgent();
295
460
  else if (command === "logs") logs();
296
461
  else if (command === "disconnect") await disconnect();
297
462
  else if (command === "daemon") { const { startDaemon } = await import("./lib/daemon.mjs"); await startDaemon(); }
package/lib/adapters.mjs CHANGED
@@ -7,6 +7,23 @@ import { paths } from "./paths.mjs";
7
7
 
8
8
  export const AGENTS = ["claude-code", "codex", "pi", "opencode"];
9
9
 
10
+ // MCP clients that are not coding agents: no history to index and nothing to run, but they
11
+ // join the machine's task board by launching the same stdio MCP under their own name.
12
+ export const MCP_CLIENTS = ["grok-bot"];
13
+ const MCP_CLIENT_APPS = {
14
+ "grok-bot": { darwin: ["/Applications/Grok Bot.app", path.join(os.homedir(), "Applications", "Grok Bot.app")] },
15
+ };
16
+
17
+ export function detectMcpClients({ platform = process.platform, exists = fs.existsSync } = {}) {
18
+ return MCP_CLIENTS.map((client) => ({ client, path: (MCP_CLIENT_APPS[client][platform] || []).find((candidate) => exists(candidate)) || null }));
19
+ }
20
+
21
+ // Grok Bot keeps its MCP server list in its account and writes it only from its own settings
22
+ // (a one-server JSON paste); there is no file on disk to write. The snippet is the setup.
23
+ export function mcpClientSnippet({ runtimeRoot, client, nodePath = process.execPath }) {
24
+ return { mcpServers: { harness: { command: nodePath, args: [path.join(runtimeRoot, "lib", "mcp-stdio.mjs"), "--agent", client] } } };
25
+ }
26
+
10
27
  function installAsset(source, target) {
11
28
  ensureDir(path.dirname(target));
12
29
  fs.copyFileSync(source, target);
@@ -24,6 +41,70 @@ export function integrationAssets(homeDir = os.homedir()) {
24
41
  };
25
42
  }
26
43
 
44
+ // Claude Code learns about waiting board work through a SessionStart hook whose stdout joins
45
+ // its context. The hook is one entry in the user's settings, added once and removed on
46
+ // disconnect; every other setting and hook is left as it was.
47
+ export function claudeSettingsPath(homeDir = os.homedir()) {
48
+ return path.join(homeDir, ".claude", "settings.json");
49
+ }
50
+ const isHarnessHook = (entry) => JSON.stringify(entry).includes("/lib/nudge.mjs");
51
+
52
+ export function installClaudeSessionHook({ runtimeRoot, homeDir = os.homedir(), nodePath = process.execPath }) {
53
+ const file = claudeSettingsPath(homeDir);
54
+ const settings = readJson(file, {});
55
+ const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
56
+ const nudge = JSON.stringify(path.join(runtimeRoot, "lib", "nudge.mjs"));
57
+ const starts = (Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []).filter((entry) => !isHarnessHook(entry));
58
+ starts.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent claude-code`, timeout: 10 }] });
59
+ // Mid-session: on every prompt, one line only when the queue changed since the session last saw it.
60
+ const prompts = (Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : []).filter((entry) => !isHarnessHook(entry));
61
+ prompts.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent claude-code --if-changed`, timeout: 10 }] });
62
+ ensureDir(path.dirname(file));
63
+ fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts, UserPromptSubmit: prompts } }, null, 2) + "\n");
64
+ }
65
+
66
+ // Codex reads the same hook shape from ~/.codex/hooks.json (SessionStart and UserPromptSubmit,
67
+ // stdout added as developer context). Codex asks the person to trust a new or changed hook once,
68
+ // with /hooks in its TUI, before it runs; setup says so.
69
+ export function codexHooksPath(homeDir = os.homedir()) {
70
+ return path.join(homeDir, ".codex", "hooks.json");
71
+ }
72
+
73
+ export function installCodexHooks({ runtimeRoot, homeDir = os.homedir(), nodePath = process.execPath }) {
74
+ const file = codexHooksPath(homeDir);
75
+ const settings = readJson(file, {});
76
+ const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
77
+ const nudge = JSON.stringify(path.join(runtimeRoot, "lib", "nudge.mjs"));
78
+ const starts = (Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []).filter((entry) => !isHarnessHook(entry));
79
+ starts.push({ matcher: "startup|resume", hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent codex`, statusMessage: "Reading the Harness board", timeout: 10 }] });
80
+ const prompts = (Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : []).filter((entry) => !isHarnessHook(entry));
81
+ prompts.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${nudge} --agent codex --if-changed`, timeout: 10 }] });
82
+ ensureDir(path.dirname(file));
83
+ fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts, UserPromptSubmit: prompts } }, null, 2) + "\n");
84
+ }
85
+
86
+ export function removeCodexHooks(homeDir = os.homedir()) {
87
+ const file = codexHooksPath(homeDir);
88
+ const settings = readJson(file, null);
89
+ if (!settings?.hooks) return;
90
+ const hooks = { ...settings.hooks };
91
+ for (const event of ["SessionStart", "UserPromptSubmit"]) {
92
+ if (Array.isArray(hooks[event])) hooks[event] = hooks[event].filter((entry) => !isHarnessHook(entry));
93
+ }
94
+ fs.writeFileSync(file, JSON.stringify({ ...settings, hooks }, null, 2) + "\n");
95
+ }
96
+
97
+ export function removeClaudeSessionHook(homeDir = os.homedir()) {
98
+ const file = claudeSettingsPath(homeDir);
99
+ const settings = readJson(file, null);
100
+ if (!settings?.hooks) return;
101
+ const hooks = { ...settings.hooks };
102
+ for (const event of ["SessionStart", "UserPromptSubmit"]) {
103
+ if (Array.isArray(hooks[event])) hooks[event] = hooks[event].filter((entry) => !isHarnessHook(entry));
104
+ }
105
+ fs.writeFileSync(file, JSON.stringify({ ...settings, hooks }, null, 2) + "\n");
106
+ }
107
+
27
108
  export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.homedir(), detected = detectAgents(), execute = run, connection = readJson(paths.config, {}) }) {
28
109
  const byAgent = new Map(detected.map((entry) => [entry.agent, entry]));
29
110
  const mcpPath = path.join(runtimeRoot, "lib", "mcp-stdio.mjs");
@@ -42,11 +123,15 @@ export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.h
42
123
  if (agent === "claude-code") {
43
124
  execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]);
44
125
  const added = execute(found.path, ["mcp", "add", "--scope", "user", "harness", "--", process.execPath, mcpPath, "--agent", agent]);
45
- states.push({ agent, installed: true, configured: added.ok, detail: added.ok ? "MCP configured" : diagnostic(added) });
126
+ let hooked = true;
127
+ try { installClaudeSessionHook({ runtimeRoot, homeDir }); } catch { hooked = false; }
128
+ states.push({ agent, installed: true, configured: added.ok, detail: added.ok ? (hooked ? "MCP and session-start nudge configured" : "MCP configured; the session-start hook could not be written") : diagnostic(added) });
46
129
  } else if (agent === "codex") {
47
130
  execute(found.path, ["mcp", "remove", "harness"]);
48
131
  const added = execute(found.path, ["mcp", "add", "harness", "--", process.execPath, mcpPath, "--agent", agent]);
49
- states.push({ agent, installed: true, configured: added.ok, detail: added.ok ? "MCP configured" : diagnostic(added) });
132
+ let hooked = true;
133
+ try { installCodexHooks({ runtimeRoot, homeDir }); } catch { hooked = false; }
134
+ 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) });
50
135
  } else if (agent === "pi") {
51
136
  installAsset(path.join(runtimeRoot, "assets", "pi-extension.ts"), assets.pi);
52
137
  states.push({ agent, installed: true, configured: fs.existsSync(assets.pi), detail: "extension installed" });
@@ -69,8 +154,9 @@ export function agentHealth(states) {
69
154
  export function removeAgentConfigurations({ homeDir = os.homedir(), detected = detectAgents(), execute = run } = {}) {
70
155
  for (const found of detected) {
71
156
  if (!found.path) continue;
72
- if (found.agent === "claude-code") execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]);
73
- else if (found.agent === "codex" || found.agent === "opencode") execute(found.path, ["mcp", "remove", "harness"]);
157
+ if (found.agent === "claude-code") { execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]); removeClaudeSessionHook(homeDir); }
158
+ else if (found.agent === "codex") { execute(found.path, ["mcp", "remove", "harness"]); removeCodexHooks(homeDir); }
159
+ else if (found.agent === "opencode") execute(found.path, ["mcp", "remove", "harness"]);
74
160
  }
75
161
  const assets = integrationAssets(homeDir);
76
162
  for (const target of Object.values(assets)) {