tryharness 0.4.1 → 0.4.3

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
+ Put the coding agents installed on this machine in your [Harness](https://tryharness.ai) relay:
4
4
 
5
5
  ```sh
6
6
  npx tryharness
@@ -10,10 +10,15 @@ 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
 
18
+ JSONL histories are streamed from beginning to end and only normalized conversation
19
+ records are retained. Source-file size does not truncate recall; long read results are
20
+ paged when Harness displays them.
21
+
17
22
  The command is idempotent. Run it again after installing another agent or to repair its
18
23
  configuration.
19
24
 
@@ -23,8 +28,10 @@ configuration.
23
28
  npx tryharness status
24
29
  npx tryharness doctor
25
30
  npx tryharness sync --agent codex
31
+ npx tryharness task <run-id> --follow
26
32
  npx tryharness logs --follow
27
- 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
28
35
  npx tryharness disconnect
29
36
  ```
30
37
 
@@ -32,10 +39,54 @@ npx tryharness disconnect
32
39
  daemon, the bridge, the database, and agent discovery. `sync` requests a background
33
40
  rescan; it is diagnostic, not part of normal setup.
34
41
 
42
+ Delegated coding tasks start only after the user presses Start on Harness's proposal
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
44
+ inside the exact absolute working directory shown on that card; Harness never falls
45
+ back to the user's home directory. The card immediately shows a
46
+ `npx tryharness task <run-id> --follow` command for watching the machine-local raw output
47
+ in a terminal. Both the card and the follow command print the agent's native session
48
+ command as soon as the agent reports its session id, including while the run is active,
49
+ so the user can enter and steer the agent directly rather than interacting through a
50
+ Harness wrapper.
51
+
52
+ ## Task board
53
+
54
+ The daemon keeps a task board in its local SQLite database. Any connected agent can post
55
+ work for another agent on the machine through the `post_task` tool, and every agent sees
56
+ the board through `list_tasks`, `get_task`, `claim_task` and `report_task`, which the
57
+ stdio MCP exposes. The board is pull-only: a task waits until
58
+ its target agent reads the board and claims it, and nothing starts by itself. A claimer
59
+ reports done, failed or needs_input; the poster answers a question with `report_task`
60
+ and status `answered`, which returns the task to the claimer with the answer in its
61
+ brief. The daemon is also the board's worker. A task posted for a coding agent installed on the
62
+ machine with `--run --workspace <dir>` (or an agent's `run_workspace` request, once the user
63
+ authorizes it with `tryharness board run <id>`) is claimed by the daemon in that agent's name,
64
+ run non-interactively through the same runner as a delegated run, and reported back on the
65
+ task with the result and the agent's session id; `tryharness task <id> --follow` works on it.
66
+ One run per agent at a time; a daemon restart marks a running task unknown. An interactive
67
+ claim carries a lease (default 60 minutes); a lapsed lease shows the claim as stale and only
68
+ the poster or the user releases it. `tryharness board agents` shows when each agent last read
69
+ the board. Every change goes up the bridge as identity and status only.
70
+
71
+ Interactive sessions are told about waiting work when they open. Setup adds a Claude Code
72
+ SessionStart hook that prints the board's tasks for it into the session's context, the Pi
73
+ extension shows the same on session start (and puts it in context where pi allows), and the
74
+ OpenCode plugin adds it to the first message of a session. Codex has no hook surface, so it
75
+ reads the board when asked. The nudge is silent when nothing waits or no daemon is running.
76
+
77
+ Every task has one holder, the only agent allowed to write it next, and a version that
78
+ every write bumps; a write lands only for the holder at the expected version, and anything
79
+ else comes back as a conflict carrying the task as it is now, so agents cannot overwrite
80
+ each other. Briefs and results stay on the machine; the bridge reports identity and status
81
+ only. Any MCP client on the machine joins the board by launching `lib/mcp-stdio.mjs --agent <name>`.
82
+ Grok Bot keeps its MCP servers in its own account settings, so setup prints
83
+ `npx tryharness mcp grok-bot`, a one-server snippet to paste into Grok Bot's MCP settings; it
84
+ then appears on the board as `grok-bot`.
85
+
35
86
  The daemon dials out to Harness over one authenticated WebSocket. Its local control
36
87
  socket is permissioned to the current user. When OpenCode is installed, the daemon also
37
88
  starts OpenCode's own helper on a random loopback-only port with a random per-process
38
89
  password so it can index OpenCode history; no fixed or unauthenticated port is used.
39
90
  Connection tokens stay in `~/.harness/config.json` with owner-only permissions. Coding
40
- history and handoffs stay in the local SQLite database unless an explicit operation
91
+ history and the board stay in the local SQLite database unless an explicit operation
41
92
  sends content through Harness.
@@ -8,6 +8,34 @@ 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() {
27
+ const response = await ask({ command: "board.list", agent: "opencode" });
28
+ const tasks = response?.ok ? response.tasks : [];
29
+ const waiting = tasks.filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === "opencode"));
30
+ const held = tasks.filter((task) => task.claimed_by === "opencode" && (task.status === "claimed" || task.status === "needs_input"));
31
+ if (!waiting.length && !held.length) return "";
32
+ const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent})` : ""}`;
33
+ return ["Harness task board:", ...(waiting.length ? [`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`, ...waiting.slice(0, 5).map(line)] : []),
34
+ ...(held.length ? [`${held.length} you already claimed:`, ...held.slice(0, 5).map(line)] : []),
35
+ "Read one with get_task, claim it with claim_task before working, and report_task when done or stuck."].join("\n");
36
+ }
37
+ const nudged = new Set();
38
+
11
39
  function notify(message) {
12
40
  try {
13
41
  const socket = net.createConnection(socketPath);
@@ -19,6 +47,14 @@ function notify(message) {
19
47
  }
20
48
 
21
49
  export const TryHarnessPlugin = async ({ directory }) => ({
50
+ // The first message of each session carries the board nudge as an extra text part.
51
+ "chat.message": async (input, output) => {
52
+ const sessionId = input?.sessionID || input?.sessionId;
53
+ if (!sessionId || nudged.has(sessionId) || !Array.isArray(output?.parts)) return;
54
+ nudged.add(sessionId);
55
+ const nudge = await boardNudge();
56
+ if (nudge) output.parts.push({ type: "text", text: nudge });
57
+ },
22
58
  event: async ({ event }) => {
23
59
  if (event?.type !== "session.idle") return;
24
60
  const sessionId = event?.properties?.sessionID || event?.properties?.sessionId;
@@ -5,7 +5,38 @@ 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: work waiting for it on the machine's task board.
29
+ async function boardNudge() {
30
+ const response = await daemon({ command: "board.list", agent: "pi" });
31
+ const tasks = response?.ok ? response.tasks : [];
32
+ const waiting = tasks.filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === "pi"));
33
+ const held = tasks.filter((task) => task.claimed_by === "pi" && (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})` : ""}`;
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
+ }
9
40
  let nextId = 1;
10
41
  let typeApi = null;
11
42
 
@@ -88,5 +119,12 @@ export default function tryHarness(pi) {
88
119
  return connecting;
89
120
  };
90
121
  pi.on("session_start", (_event, ctx) => void connect(ctx).catch((error) => ctx?.ui?.notify?.("Harness unavailable: " + error?.message, "warning")));
122
+ pi.on("session_start", async (_event, ctx) => {
123
+ const nudge = await boardNudge();
124
+ if (!nudge) return;
125
+ // Into the model's context when pi offers it; always visible to the person.
126
+ if (typeof pi.sendMessage === "function") { try { pi.sendMessage({ customType: "harness-board", content: nudge, display: true }); } catch {} }
127
+ ctx?.ui?.notify?.(nudge.split("\n").slice(0, 2).join(" "), "info");
128
+ });
91
129
  pi.registerCommand("harness-reconnect", { description: "Reconnect Harness", handler: async (_args, ctx) => connect(ctx) });
92
130
  }
package/cli.mjs CHANGED
@@ -3,16 +3,16 @@ 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
12
  import { installService, serviceInfo, uninstallService } from "./lib/service.mjs";
13
13
 
14
14
  const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
15
- const PACKAGE = readJson(path.join(SOURCE_ROOT, "package.json"), { version: "0.4.1" });
15
+ const PACKAGE = readJson(path.join(SOURCE_ROOT, "package.json"), { version: "0.4.3" });
16
16
  const VERSION = PACKAGE.version;
17
17
  const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
18
18
  if (!Number.isFinite(NODE_MAJOR) || NODE_MAJOR < 18) {
@@ -31,6 +31,18 @@ function arg(flag, fallback = null) {
31
31
  return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
32
32
  }
33
33
  function has(flag) { return argv.includes(flag); }
34
+ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
35
+ function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; }
36
+ function resumeCommand(agent, sessionId, cwd) {
37
+ const command = agent === "claude-code"
38
+ ? `claude --dangerously-skip-permissions --resume ${shellQuote(sessionId)}`
39
+ : agent === "codex"
40
+ ? `codex resume --dangerously-bypass-approvals-and-sandbox ${shellQuote(sessionId)}`
41
+ : agent === "pi"
42
+ ? `pi --approve --session ${shellQuote(sessionId)}`
43
+ : `opencode --auto --session ${shellQuote(sessionId)}`;
44
+ return cwd ? `cd ${shellQuote(cwd)} && ${command}` : command;
45
+ }
34
46
  function origin() {
35
47
  const saved = readJson(paths.config, {});
36
48
  return String(arg("--origin", saved.origin || "https://tryharness.ai")).replace(/\/$/, "");
@@ -138,6 +150,9 @@ async function connect() {
138
150
  config = { ...readJson(paths.config, config), version: VERSION, runtimeRoot, agentStates: stateMap };
139
151
  writeJsonAtomic(paths.config, config);
140
152
  printAgentStates(states);
153
+ for (const client of detectMcpClients()) {
154
+ if (client.path) console.log(` ! ${client.client}: found; it joins the task board once you paste its MCP snippet (npx tryharness mcp ${client.client})`);
155
+ }
141
156
 
142
157
  const service = installService({ runtimeRoot });
143
158
  const ready = await waitForDaemon();
@@ -199,18 +214,109 @@ async function sync() {
199
214
  console.log(`Local history scan started for ${agent}. It runs in the daemon; this terminal does not need to stay open.`);
200
215
  }
201
216
 
202
- async function handoff() {
203
- const agent = argv[0];
204
- if (!AGENTS.includes(agent)) throw new Error("usage: tryharness handoff <claude-code|codex|pi|opencode> --file <brief.md>");
205
- const file = arg("--file");
206
- let body = "";
207
- if (file) body = fs.readFileSync(path.resolve(file), "utf8");
208
- else if (!process.stdin.isTTY) for await (const chunk of process.stdin) body += chunk;
209
- else body = arg("--body", "");
210
- if (!body.trim()) throw new Error("handoff needs --file, --body, or piped input");
211
- 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" });
212
- if (!response.ok) throw new Error(response.error || "handoff failed");
213
- console.log(`Handoff ${response.id} is ready for ${agent}. Harness's get_handoff tool can read it.`);
217
+ async function mcpSnippet() {
218
+ const client = argv[0];
219
+ if (!MCP_CLIENTS.includes(client)) throw new Error(`usage: tryharness mcp <${MCP_CLIENTS.join("|")}>`);
220
+ const config = readJson(paths.config, {});
221
+ const runtimeRoot = config.runtimeRoot || currentRuntime();
222
+ if (!runtimeRoot) throw new Error("no Harness runtime is installed yet; run npx tryharness first");
223
+ const found = detectMcpClients().find((entry) => entry.client === client);
224
+ console.log(`${client}: ${found?.path ? `found at ${found.path}` : "not found on this machine; the snippet still works once it is installed"}`);
225
+ console.log(`In ${client === "grok-bot" ? "Grok Bot, open Settings, MCP, add a server" : client} and paste this:\n`);
226
+ console.log(JSON.stringify(mcpClientSnippet({ runtimeRoot, client }), null, 2));
227
+ console.log(`\nIt then sees the board through list_tasks, get_task, post_task, claim_task and report_task as "${client}".`);
228
+ }
229
+
230
+ async function board() {
231
+ const sub = argv.find((value) => !value.startsWith("--")) || "list";
232
+ const agent = arg("--as", "user");
233
+ if (sub === "post") {
234
+ const target = arg("--for");
235
+ if (target && !AGENTS.includes(target) && target !== "harness" && target !== "grok-bot") throw new Error("usage: tryharness board post --for <agent> --objective <text> [--file <brief.md>] [--run --workspace <dir>]");
236
+ const objective = arg("--objective", "");
237
+ const file = arg("--file");
238
+ let brief = "";
239
+ if (file) brief = fs.readFileSync(path.resolve(file), "utf8");
240
+ else if (!process.stdin.isTTY) for await (const chunk of process.stdin) brief += chunk;
241
+ if (!objective.trim()) throw new Error("board post needs --objective");
242
+ const runWorkspace = has("--run") ? path.resolve(arg("--workspace", process.cwd())) : null;
243
+ if (runWorkspace && !AGENTS.includes(target)) throw new Error("--run needs --for with a coding agent installed on this machine");
244
+ const response = await daemonRequest({ command: "board.post", agent, objective, brief, targetAgent: target || null, runWorkspace });
245
+ if (!response.ok) throw new Error(response.error || "post failed");
246
+ 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`);
247
+ else console.log(`Task ${response.task.id} is on the board${target ? ` for ${target}` : ""}. Any agent's list_tasks tool shows it.`);
248
+ return;
249
+ }
250
+ if (sub === "run" || sub === "release") {
251
+ const id = String(argv[argv.indexOf(sub) + 1] || "");
252
+ const response = await daemonRequest({ command: sub === "run" ? "board.run" : "board.release", agent, id });
253
+ if (!response.ok) throw new Error(response.error || `${sub} failed`);
254
+ 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.`);
255
+ return;
256
+ }
257
+ if (sub === "agents") {
258
+ const response = await daemonRequest({ command: "board.agents", agent });
259
+ if (!response.ok) throw new Error(response.error || "board unavailable");
260
+ if (!response.agents.length) { console.log("No agent has read the board yet."); return; }
261
+ 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" : ""}`);
262
+ return;
263
+ }
264
+ if (sub === "show") {
265
+ const response = await daemonRequest({ command: "board.get", agent, id: String(argv[argv.indexOf("show") + 1] || "") });
266
+ if (!response.ok) throw new Error(response.error || "task not found");
267
+ const { task: t } = response;
268
+ 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`);
269
+ 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`);
270
+ if (t.brief) console.log(t.brief + "\n");
271
+ if (t.result) console.log(`${t.status === "needs_input" ? "question" : "result"}: ${t.result}`);
272
+ return;
273
+ }
274
+ if (sub !== "list") throw new Error("usage: tryharness board [list --all | show <id> | post --for <agent> --objective <text> [--file <brief.md>] [--run --workspace <dir>] | run <id> | release <id> | agents]");
275
+ const response = await daemonRequest({ command: "board.list", agent, all: has("--all") });
276
+ if (!response.ok) throw new Error(response.error || "board unavailable");
277
+ if (!response.tasks.length) { console.log("The board is empty."); return; }
278
+ for (const t of response.tasks) console.log(`${t.id} ${(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}`);
279
+ }
280
+
281
+ async function task() {
282
+ const runId = String(argv[0] || "").trim();
283
+ if (!/^[a-zA-Z0-9._-]{1,128}$/.test(runId)) throw new Error("usage: tryharness task <run-id> [--follow]");
284
+ const follow = has("--follow") || has("-f");
285
+ let offset = Math.max(0, Math.floor(Number(arg("--offset", "0")) || 0));
286
+ let announced = false;
287
+ let sessionShown = null;
288
+ let misses = 0;
289
+ for (;;) {
290
+ const response = await daemonRequest({ command: "tasks.get", runId, offset });
291
+ if (!response.ok) {
292
+ if (follow && misses < 10) { misses += 1; await sleep(500); continue; }
293
+ throw new Error(response.error || `task ${runId} was not found on this machine`);
294
+ }
295
+ misses = 0;
296
+ const current = response.task;
297
+ if (!announced) {
298
+ console.log(`Harness task ${runId} · ${current.agent} · ${current.status}`);
299
+ if (current.cwd) console.log(`directory: ${current.cwd}`);
300
+ if (follow) console.log("following machine-local agent output (Ctrl-C stops following, not the task)\n");
301
+ announced = true;
302
+ }
303
+ if (current.log) process.stdout.write(current.log);
304
+ offset = current.nextOffset;
305
+ if (current.sessionId && current.sessionId !== sessionShown) {
306
+ sessionShown = current.sessionId;
307
+ console.log(`\nagent session: ${current.sessionId}`);
308
+ console.log(`open live session: ${resumeCommand(current.agent, current.sessionId, current.cwd)}`);
309
+ }
310
+ const terminal = current.status === "done" || current.status === "failed" || current.status === "interrupted";
311
+ if (!follow || (terminal && offset >= current.logLength)) {
312
+ console.log(`\nstatus: ${current.status}`);
313
+ if (current.message) console.log(current.message);
314
+ if (current.status === "interrupted") console.log("The daemon restarted during this run; its outcome is unknown. Check the repository.");
315
+ if (current.status !== "done") process.exitCode = 1;
316
+ return;
317
+ }
318
+ await sleep(500);
319
+ }
214
320
  }
215
321
 
216
322
  function logs() {
@@ -230,7 +336,7 @@ async function disconnect() {
230
336
  }
231
337
 
232
338
  function help() {
233
- 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 handoff <agent> --file <path>\n tryharness logs [--follow]\n tryharness disconnect\n`);
339
+ 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 mcp grok-bot print the MCP snippet that puts Grok Bot on the board\n tryharness logs [--follow]\n tryharness disconnect\n`);
234
340
  }
235
341
 
236
342
  try {
@@ -238,7 +344,9 @@ try {
238
344
  else if (command === "status" || command === "bridge") await status();
239
345
  else if (command === "doctor") await doctor();
240
346
  else if (command === "sync" || command === "backfill" || command === "drain") await sync();
241
- else if (command === "handoff") await handoff();
347
+ else if (command === "task") await task();
348
+ else if (command === "board") await board();
349
+ else if (command === "mcp") await mcpSnippet();
242
350
  else if (command === "logs") logs();
243
351
  else if (command === "disconnect") await disconnect();
244
352
  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,32 @@ 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 starts = (Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []).filter((entry) => !isHarnessHook(entry));
57
+ starts.push({ hooks: [{ type: "command", command: `${JSON.stringify(nodePath)} ${JSON.stringify(path.join(runtimeRoot, "lib", "nudge.mjs"))} --agent claude-code`, timeout: 10 }] });
58
+ ensureDir(path.dirname(file));
59
+ fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts } }, null, 2) + "\n");
60
+ }
61
+
62
+ export function removeClaudeSessionHook(homeDir = os.homedir()) {
63
+ const file = claudeSettingsPath(homeDir);
64
+ const settings = readJson(file, null);
65
+ if (!settings?.hooks || !Array.isArray(settings.hooks.SessionStart)) return;
66
+ const starts = settings.hooks.SessionStart.filter((entry) => !isHarnessHook(entry));
67
+ fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...settings.hooks, SessionStart: starts } }, null, 2) + "\n");
68
+ }
69
+
27
70
  export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.homedir(), detected = detectAgents(), execute = run, connection = readJson(paths.config, {}) }) {
28
71
  const byAgent = new Map(detected.map((entry) => [entry.agent, entry]));
29
72
  const mcpPath = path.join(runtimeRoot, "lib", "mcp-stdio.mjs");
@@ -42,7 +85,9 @@ export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.h
42
85
  if (agent === "claude-code") {
43
86
  execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]);
44
87
  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) });
88
+ let hooked = true;
89
+ try { installClaudeSessionHook({ runtimeRoot, homeDir }); } catch { hooked = false; }
90
+ 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
91
  } else if (agent === "codex") {
47
92
  execute(found.path, ["mcp", "remove", "harness"]);
48
93
  const added = execute(found.path, ["mcp", "add", "harness", "--", process.execPath, mcpPath, "--agent", agent]);
@@ -69,7 +114,7 @@ export function agentHealth(states) {
69
114
  export function removeAgentConfigurations({ homeDir = os.homedir(), detected = detectAgents(), execute = run } = {}) {
70
115
  for (const found of detected) {
71
116
  if (!found.path) continue;
72
- if (found.agent === "claude-code") execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]);
117
+ if (found.agent === "claude-code") { execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]); removeClaudeSessionHook(homeDir); }
73
118
  else if (found.agent === "codex" || found.agent === "opencode") execute(found.path, ["mcp", "remove", "harness"]);
74
119
  }
75
120
  const assets = integrationAssets(homeDir);