tryharness 0.4.3 → 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 +25 -6
- package/assets/opencode-plugin.js +14 -7
- package/assets/pi-extension.ts +22 -5
- package/cli.mjs +119 -9
- package/lib/adapters.mjs +48 -7
- package/lib/daemon.mjs +218 -16
- package/lib/embedder.mjs +156 -0
- package/lib/mcp-stdio.mjs +59 -5
- package/lib/notify.mjs +52 -0
- package/lib/nudge.mjs +42 -8
- package/lib/paths.mjs +1 -0
- package/lib/provenance.mjs +21 -0
- package/lib/runner.mjs +33 -2
- package/lib/runtime-install.mjs +19 -2
- package/lib/secrets.mjs +25 -0
- package/lib/store.mjs +255 -15
- package/lib/vectors.mjs +43 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# tryharness
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
72
|
-
SessionStart
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 ||
|
|
54
|
-
|
|
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 }) => {
|
package/assets/pi-extension.ts
CHANGED
|
@@ -25,14 +25,22 @@ function daemon(message) {
|
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
// What pi is told when a session opens
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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(
|
|
244
|
-
|
|
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 (
|
|
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 === "
|
|
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)} ${
|
|
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
|
|
66
|
-
const
|
|
67
|
-
|
|
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
|
-
|
|
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"
|
|
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)) {
|