tryharness 0.4.3 → 0.5.1

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
- Put the coding agents installed on this machine in your [Harness](https://tryharness.ai) relay:
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
@@ -49,6 +49,16 @@ command as soon as the agent reports its session id, including while the run is
49
49
  so the user can enter and steer the agent directly rather than interacting through a
50
50
  Harness wrapper.
51
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
+
52
62
  ## Task board
53
63
 
54
64
  The daemon keeps a task board in its local SQLite database. Any connected agent can post
@@ -68,17 +78,26 @@ claim carries a lease (default 60 minutes); a lapsed lease shows the claim as st
68
78
  the poster or the user releases it. `tryharness board agents` shows when each agent last read
69
79
  the board. Every change goes up the bridge as identity and status only.
70
80
 
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.
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.
76
87
 
77
88
  Every task has one holder, the only agent allowed to write it next, and a version that
78
89
  every write bumps; a write lands only for the holder at the expected version, and anything
79
90
  else comes back as a conflict carrying the task as it is now, so agents cannot overwrite
80
91
  each other. Briefs and results stay on the machine; the bridge reports identity and status
81
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.
82
101
  Grok Bot keeps its MCP servers in its own account settings, so setup prints
83
102
  `npx tryharness mcp grok-bot`, a one-server snippet to paste into Grok Bot's MCP settings; it
84
103
  then appears on the board as `grok-bot`.
@@ -23,18 +23,26 @@ function ask(message) {
23
23
  }
24
24
 
25
25
  // What opencode is told once per session, on its first message: work waiting on the board.
26
- async function boardNudge() {
26
+ async function boardNudge(sessionId) {
27
27
  const response = await ask({ command: "board.list", agent: "opencode" });
28
28
  const tasks = response?.ok ? response.tasks : [];
29
+ const now = digest(tasks);
30
+ if (lastDigest.get(sessionId) === now) return "";
31
+ lastDigest.set(sessionId, now);
29
32
  const waiting = tasks.filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === "opencode"));
30
33
  const held = tasks.filter((task) => task.claimed_by === "opencode" && (task.status === "claimed" || task.status === "needs_input"));
31
34
  if (!waiting.length && !held.length) return "";
32
- const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent})` : ""}`;
35
+ const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent}${task.trust === "agent" ? ", agent-written" : ""})` : ""}`;
33
36
  return ["Harness task board:", ...(waiting.length ? [`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`, ...waiting.slice(0, 5).map(line)] : []),
34
37
  ...(held.length ? [`${held.length} you already claimed:`, ...held.slice(0, 5).map(line)] : []),
35
38
  "Read one with get_task, claim it with claim_task before working, and report_task when done or stuck."].join("\n");
36
39
  }
37
- const nudged = new Set();
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
+ }
38
46
 
39
47
  function notify(message) {
40
48
  try {
@@ -47,12 +55,11 @@ function notify(message) {
47
55
  }
48
56
 
49
57
  export const TryHarnessPlugin = async ({ directory }) => ({
50
- // The first message of each session carries the board nudge as an extra text part.
58
+ // Every message of a session carries the board nudge as an extra text part when the queue changed since the last one.
51
59
  "chat.message": async (input, output) => {
52
60
  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();
61
+ if (!sessionId || !Array.isArray(output?.parts)) return;
62
+ const nudge = await boardNudge(sessionId);
56
63
  if (nudge) output.parts.push({ type: "text", text: nudge });
57
64
  },
58
65
  event: async ({ event }) => {
@@ -25,14 +25,22 @@ function daemon(message) {
25
25
  });
26
26
  }
27
27
 
28
- // What pi is told when a session opens: work waiting for it on the machine's task board.
29
- async function boardNudge() {
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) {
30
35
  const response = await daemon({ command: "board.list", agent: "pi" });
31
36
  const tasks = response?.ok ? response.tasks : [];
37
+ const now = digest(tasks);
38
+ if (onlyIfChanged && now === lastDigest) return "";
39
+ lastDigest = now;
32
40
  const waiting = tasks.filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === "pi"));
33
41
  const held = tasks.filter((task) => task.claimed_by === "pi" && (task.status === "claimed" || task.status === "needs_input"));
34
42
  if (!waiting.length && !held.length) return "";
35
- const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent})` : ""}`;
43
+ const line = (task) => `- ${task.id}: ${task.objective}${task.origin_agent ? ` (from ${task.origin_agent}${task.trust === "agent" ? ", agent-written" : ""})` : ""}`;
36
44
  return ["Harness task board:", ...(waiting.length ? [`${waiting.length} task${waiting.length === 1 ? "" : "s"} waiting for you:`, ...waiting.slice(0, 5).map(line)] : []),
37
45
  ...(held.length ? [`${held.length} you already claimed:`, ...held.slice(0, 5).map(line)] : []),
38
46
  "Read one with get_task, claim it with claim_task before working, and report_task when done or stuck."].join("\n");
@@ -119,12 +127,21 @@ export default function tryHarness(pi) {
119
127
  return connecting;
120
128
  };
121
129
  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();
130
+ const tell = (nudge, ctx) => {
124
131
  if (!nudge) return;
125
132
  // Into the model's context when pi offers it; always visible to the person.
126
133
  if (typeof pi.sendMessage === "function") { try { pi.sendMessage({ customType: "harness-board", content: nudge, display: true }); } catch {} }
127
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}` };
128
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));
129
146
  pi.registerCommand("harness-reconnect", { description: "Reconnect Harness", handler: async (_args, ctx) => connect(ctx) });
130
147
  }
package/cli.mjs CHANGED
@@ -9,6 +9,7 @@ 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
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));
@@ -157,6 +158,14 @@ async function connect() {
157
158
  const service = installService({ runtimeRoot });
158
159
  const ready = await waitForDaemon();
159
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
+ }
160
169
  const failed = states.filter((state) => selected.includes(state.agent) && state.installed && !state.configured);
161
170
  if (failed.length) {
162
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.`);
@@ -214,6 +223,31 @@ async function sync() {
214
223
  console.log(`Local history scan started for ${agent}. It runs in the daemon; this terminal does not need to stay open.`);
215
224
  }
216
225
 
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");
242
+ const file = arg("--file");
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
+
217
251
  async function mcpSnippet() {
218
252
  const client = argv[0];
219
253
  if (!MCP_CLIENTS.includes(client)) throw new Error(`usage: tryharness mcp <${MCP_CLIENTS.join("|")}>`);
@@ -231,8 +265,12 @@ async function board() {
231
265
  const sub = argv.find((value) => !value.startsWith("--")) || "list";
232
266
  const agent = arg("--as", "user");
233
267
  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>]");
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>]");
236
274
  const objective = arg("--objective", "");
237
275
  const file = arg("--file");
238
276
  let brief = "";
@@ -240,13 +278,44 @@ async function board() {
240
278
  else if (!process.stdin.isTTY) for await (const chunk of process.stdin) brief += chunk;
241
279
  if (!objective.trim()) throw new Error("board post needs --objective");
242
280
  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 });
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 });
245
284
  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`);
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`);
247
287
  else console.log(`Task ${response.task.id} is on the board${target ? ` for ${target}` : ""}. Any agent's list_tasks tool shows it.`);
248
288
  return;
249
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
+ }
250
319
  if (sub === "run" || sub === "release") {
251
320
  const id = String(argv[argv.indexOf(sub) + 1] || "");
252
321
  const response = await daemonRequest({ command: sub === "run" ? "board.run" : "board.release", agent, id });
@@ -254,6 +323,25 @@ async function board() {
254
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.`);
255
324
  return;
256
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
+ }
257
345
  if (sub === "agents") {
258
346
  const response = await daemonRequest({ command: "board.agents", agent });
259
347
  if (!response.ok) throw new Error(response.error || "board unavailable");
@@ -266,16 +354,36 @@ async function board() {
266
354
  if (!response.ok) throw new Error(response.error || "task not found");
267
355
  const { task: t } = response;
268
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)}` : ""}`);
269
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`);
270
359
  if (t.brief) console.log(t.brief + "\n");
271
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}` : ""}`);
272
380
  return;
273
381
  }
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]");
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]");
275
383
  const response = await daemonRequest({ command: "board.list", agent, all: has("--all") });
276
384
  if (!response.ok) throw new Error(response.error || "board unavailable");
277
385
  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}`);
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}`);
279
387
  }
280
388
 
281
389
  async function task() {
@@ -336,17 +444,19 @@ async function disconnect() {
336
444
  }
337
445
 
338
446
  function help() {
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`);
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`);
340
448
  }
341
449
 
342
450
  try {
343
- if (command === "connect" || command === "setup") await connect();
451
+ if (command === "setup" || (command === "connect" && !argv[0])) await connect();
344
452
  else if (command === "status" || command === "bridge") await status();
345
453
  else if (command === "doctor") await doctor();
346
454
  else if (command === "sync" || command === "backfill" || command === "drain") await sync();
347
455
  else if (command === "task") await task();
348
456
  else if (command === "board") await board();
349
457
  else if (command === "mcp") await mcpSnippet();
458
+ else if (command === "notify") await notify();
459
+ else if (command === "connect") await connectAgent();
350
460
  else if (command === "logs") logs();
351
461
  else if (command === "disconnect") await disconnect();
352
462
  else if (command === "daemon") { const { startDaemon } = await import("./lib/daemon.mjs"); await startDaemon(); }
package/lib/adapters.mjs CHANGED
@@ -53,18 +53,56 @@ export function installClaudeSessionHook({ runtimeRoot, homeDir = os.homedir(),
53
53
  const file = claudeSettingsPath(homeDir);
54
54
  const settings = readJson(file, {});
55
55
  const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
56
+ const nudge = JSON.stringify(path.join(runtimeRoot, "lib", "nudge.mjs"));
56
57
  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
+ 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 }] });
58
62
  ensureDir(path.dirname(file));
59
- fs.writeFileSync(file, JSON.stringify({ ...settings, hooks: { ...hooks, SessionStart: starts } }, null, 2) + "\n");
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");
60
95
  }
61
96
 
62
97
  export function removeClaudeSessionHook(homeDir = os.homedir()) {
63
98
  const file = claudeSettingsPath(homeDir);
64
99
  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");
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");
68
106
  }
69
107
 
70
108
  export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.homedir(), detected = detectAgents(), execute = run, connection = readJson(paths.config, {}) }) {
@@ -91,7 +129,9 @@ export function configureAgents({ runtimeRoot, selected = AGENTS, homeDir = os.h
91
129
  } else if (agent === "codex") {
92
130
  execute(found.path, ["mcp", "remove", "harness"]);
93
131
  const added = execute(found.path, ["mcp", "add", "harness", "--", process.execPath, mcpPath, "--agent", agent]);
94
- 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) });
95
135
  } else if (agent === "pi") {
96
136
  installAsset(path.join(runtimeRoot, "assets", "pi-extension.ts"), assets.pi);
97
137
  states.push({ agent, installed: true, configured: fs.existsSync(assets.pi), detail: "extension installed" });
@@ -115,7 +155,8 @@ export function removeAgentConfigurations({ homeDir = os.homedir(), detected = d
115
155
  for (const found of detected) {
116
156
  if (!found.path) continue;
117
157
  if (found.agent === "claude-code") { execute(found.path, ["mcp", "remove", "--scope", "user", "harness"]); removeClaudeSessionHook(homeDir); }
118
- else if (found.agent === "codex" || found.agent === "opencode") execute(found.path, ["mcp", "remove", "harness"]);
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"]);
119
160
  }
120
161
  const assets = integrationAssets(homeDir);
121
162
  for (const target of Object.values(assets)) {