tryharness 0.7.4 → 0.7.7
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/cli.mjs +123 -18
- package/lib/activity.mjs +191 -0
- package/lib/adapters.mjs +2 -1
- package/lib/claudemem.mjs +90 -0
- package/lib/coordinator.mjs +162 -0
- package/lib/daemon.mjs +698 -96
- package/lib/mcp-stdio.mjs +29 -6
- package/lib/members.json +31 -0
- package/lib/members.mjs +47 -0
- package/lib/onboard.mjs +131 -0
- package/lib/paths.mjs +1 -0
- package/lib/phones.mjs +152 -0
- package/lib/runner.mjs +81 -18
- package/lib/sealed.mjs +103 -0
- package/lib/store.mjs +174 -6
- package/lib/swarm.mjs +271 -0
- package/lib/wakes.mjs +196 -0
- package/lib/worktrees.mjs +132 -0
- package/package.json +4 -3
package/lib/mcp-stdio.mjs
CHANGED
|
@@ -13,7 +13,7 @@ let chain = Promise.resolve();
|
|
|
13
13
|
const LOCAL_TOOLS = [
|
|
14
14
|
{
|
|
15
15
|
name: "list_tasks",
|
|
16
|
-
description: "List open work on this machine's Harness task board: tasks posted by the user or by other agents (Harness, Claude Code, Codex, Pi, OpenCode, Grok Bot) for this agent or for anyone. Tasks you posted for Bankr, Quotient or Muse live in Harness, not here: read one with get_task and its id. A task for a coding agent with a folder may run on its own (the user's standing permission or a goal); otherwise claim a task before working on it.",
|
|
16
|
+
description: "List open work on this machine's Harness task board: tasks posted by the user or by other agents (Harness, Claude Code, Codex, Pi, OpenCode, Grok Bot) for this agent or for anyone. Tasks you posted for Bankr, Quotient or Muse live in Harness, not here: read one with get_task and its id. A task for a coding agent with a folder may run on its own (the user's standing permission or a goal); otherwise claim a task before working on it. When work you asked for has finished, failed or come back with a question since your last call, it comes back under finished: read it with get_task and tell the user.",
|
|
17
17
|
inputSchema: {
|
|
18
18
|
type: "object",
|
|
19
19
|
properties: { status: { type: "string", enum: ["open", "claimed", "needs_input", "done", "failed"] }, all: { type: "boolean", description: "include every task on the board, finished ones too" } },
|
|
@@ -33,7 +33,7 @@ const LOCAL_TOOLS = [
|
|
|
33
33
|
properties: {
|
|
34
34
|
objective: { type: "string", description: "one line, in the user's words" },
|
|
35
35
|
brief: { type: "string" },
|
|
36
|
-
target_agent: { type: "string", description: "claude-code, codex, pi, opencode, grok-bot or harness on this machine; bankr, quotient or muse in Harness; omit for any agent" },
|
|
36
|
+
target_agent: { type: "string", description: "claude-code, codex, pi, opencode, grok-bot, openclaw, hermes or harness on this machine; bankr, quotient or muse in Harness; omit for any agent" },
|
|
37
37
|
target_agents: { type: "array", items: { type: "string" }, description: "several agents at once: one task with a part for each, closed with every result when the last part finishes" },
|
|
38
38
|
parent_id: { type: "string", description: "the board task this one continues" },
|
|
39
39
|
run_workspace: { type: "string", description: "the absolute directory a coding agent runs this task in, non-interactively, once the user authorizes it; the run's result and session come back on the task. Left out, a task for a coding agent runs in the folder you are working in" },
|
|
@@ -91,20 +91,43 @@ const LOCAL_TOOLS = [
|
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
name: "post_to_room",
|
|
94
|
-
description: "Post in a goal's room for the whole team: a finding, partial work, a question or what you did.
|
|
95
|
-
inputSchema: { type: "object", properties: { id: { type: "string", description: "the goal id" }, text: { type: "string" }, share_with_guests: { type: "boolean", description: "also send it to the goal's invited guests (other people's agents); only what is fine for them to see" } }, required: ["id", "text"], additionalProperties: false },
|
|
94
|
+
description: "Post in a goal's room for the whole team: a finding, partial work, a question or what you did. To ask another agent for help, put its name in ask (claude-code, codex, pi, opencode, grok-bot, openclaw, hermes, bankr, quotient, muse, or harness to have the thread coordinated), say exactly what you need, then stop: you are woken again when it answers you by name. When you answer someone who asked you, name them (@codex) and they are woken. Any other @name, like a thanks, wakes no one. Post results and sources, not secrets: anything that looks like a key is held. For work someone else should do, post_task with parent_id set to the goal id.",
|
|
95
|
+
inputSchema: { type: "object", properties: { id: { type: "string", description: "the goal id" }, text: { type: "string" }, ask: { type: "array", items: { type: "string" }, description: "the agents you are asking for something; each is woken" }, share_with_guests: { type: "boolean", description: "also send it to the goal's invited guests (other people's agents); only what is fine for them to see" } }, required: ["id", "text"], additionalProperties: false },
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
name: "search_room",
|
|
99
99
|
description: "Search a goal's room by meaning before starting work, so you build on what teammates already found instead of repeating it.",
|
|
100
100
|
inputSchema: { type: "object", properties: { id: { type: "string", description: "the goal id" }, query: { type: "string" } }, required: ["id", "query"], additionalProperties: false },
|
|
101
|
+
}, {
|
|
102
|
+
name: "search_swarm",
|
|
103
|
+
description: "Search the shared memory of the user's agents: everything any agent wrote in any thread (findings, answers, questions, results) and the user's coding sessions on this computer. Use it before starting work to build on what another agent already did, and when you need to know how something was done before.",
|
|
104
|
+
inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false },
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "list_agents",
|
|
108
|
+
description: "The user's other agents you can ask, what each is for, and how each answers (some start at once, some are woken, some only pick work up later). Use it to pick a name before post_task or start_thread instead of guessing one.",
|
|
109
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "start_thread",
|
|
113
|
+
description: "Start a shared thread with one or more of the user's agents, named with @ in the message (for example \"@codex @grok-bot compare the two parsers\"). The thread carries your words, not the user's: nothing in it runs until the user starts it. Answers arrive in the thread; read it with read_room and add to it with post_to_room. Give a folder only when the work concerns one.",
|
|
114
|
+
inputSchema: { type: "object", properties: { text: { type: "string" }, folder: { type: "string", description: "absolute folder the coding agents work in; never guessed" } }, required: ["text"], additionalProperties: false },
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "stop_thread",
|
|
118
|
+
description: "Stop the runs in a thread you started. The user can resume it.",
|
|
119
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
|
|
101
120
|
},
|
|
102
121
|
];
|
|
103
122
|
|
|
104
123
|
const BOARD_CALLS = {
|
|
124
|
+
list_agents: () => ({ command: "board.swarm.agents" }),
|
|
125
|
+
start_thread: (args) => ({ command: "board.swarm.start", text: args.text, folder: args.folder }),
|
|
126
|
+
stop_thread: (args) => ({ command: "board.swarm.stop", id: String(args.id || "") }),
|
|
105
127
|
read_room: (args) => ({ command: "board.room.read", id: String(args.id || ""), since: args.since }),
|
|
106
|
-
post_to_room: (args) => ({ command: "board.room.post", id: String(args.id || ""), text: args.text, shareWithGuests: args.share_with_guests === true }),
|
|
128
|
+
post_to_room: (args) => ({ command: "board.room.post", id: String(args.id || ""), text: args.text, ask: Array.isArray(args.ask) ? args.ask : [], shareWithGuests: args.share_with_guests === true }),
|
|
107
129
|
search_room: (args) => ({ command: "board.room.search", id: String(args.id || ""), query: args.query }),
|
|
130
|
+
search_swarm: (args) => ({ command: "board.swarm.search", query: args.query }),
|
|
108
131
|
search_task_context: (args) => ({ command: "board.context.search", id: String(args.id || ""), query: args.query, limit: args.limit }),
|
|
109
132
|
add_task_context: (args) => ({ command: "board.context.add", id: String(args.id || ""), kind: args.kind, text: args.text, source: args.source }),
|
|
110
133
|
read_thread: (args) => ({ command: "board.thread.read", id: String(args.id || "") }),
|
|
@@ -126,7 +149,7 @@ async function localCall(request) {
|
|
|
126
149
|
const name = request.params?.name;
|
|
127
150
|
const args = request.params?.arguments || {};
|
|
128
151
|
try {
|
|
129
|
-
const result = await daemonRequest({ ...BOARD_CALLS[name](args), agent }, ["search_task_context", "search_room"].includes(name) ? 20_000 : 5_000);
|
|
152
|
+
const result = await daemonRequest({ ...BOARD_CALLS[name](args), agent }, ["search_task_context", "search_room", "search_swarm"].includes(name) ? 20_000 : 5_000);
|
|
130
153
|
return {
|
|
131
154
|
jsonrpc: "2.0",
|
|
132
155
|
id: request.id,
|
package/lib/members.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"members": [
|
|
4
|
+
{ "id": "harness", "name": "Harness", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
|
|
5
|
+
"about": "coordinates the thread and asks the person when only they can decide" },
|
|
6
|
+
{ "id": "claude-code", "name": "Claude Code", "aliases": ["claude code"], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
|
|
7
|
+
"about": "reads and changes code in the thread's folder, runs commands and tests" },
|
|
8
|
+
{ "id": "codex", "name": "Codex", "aliases": [], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
|
|
9
|
+
"about": "reads and changes code in the thread's folder, runs commands and tests" },
|
|
10
|
+
{ "id": "pi", "name": "Pi", "aliases": [], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
|
|
11
|
+
"about": "reads and changes code in the thread's folder and runs commands" },
|
|
12
|
+
{ "id": "opencode", "name": "OpenCode", "aliases": ["open code"], "road": "local", "home": "machine", "runs": "machine", "target": true, "wake": "run",
|
|
13
|
+
"about": "reads and changes code in the thread's folder and runs commands" },
|
|
14
|
+
{ "id": "grok-bot", "name": "Grok Bot", "aliases": ["grok", "grok bot"], "road": "hosted", "home": "machine", "runs": "self", "target": true, "wake": "email",
|
|
15
|
+
"about": "research on X and the web" },
|
|
16
|
+
{ "id": "bankr", "name": "Bankr", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
|
|
17
|
+
"about": "the person's crypto wallet and trading, always under the person's own approval and limits" },
|
|
18
|
+
{ "id": "quotient", "name": "Quotient", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
|
|
19
|
+
"about": "prediction markets research and positions, under the person's own approval and limits" },
|
|
20
|
+
{ "id": "muse", "name": "Muse", "aliases": [], "road": "hosted", "home": "cloud", "runs": "self", "target": true, "wake": "schedule",
|
|
21
|
+
"about": "writing and creative drafts" },
|
|
22
|
+
{ "id": "openclaw", "name": "OpenClaw", "aliases": ["open claw"], "road": "local", "home": "machine", "runs": "self", "target": true, "wake": "local-webhook",
|
|
23
|
+
"about": "the person's own assistant on this computer, with their chat apps and tools" },
|
|
24
|
+
{ "id": "hermes", "name": "Hermes", "aliases": [], "road": "local", "home": "machine", "runs": "self", "target": true, "wake": "local-webhook",
|
|
25
|
+
"about": "the person's own assistant on this computer, with their chat apps and tools" },
|
|
26
|
+
{ "id": "claude-app", "name": "Claude app", "aliases": ["claude ai"], "road": "hosted", "home": "cloud", "runs": "self", "target": false, "wake": "none",
|
|
27
|
+
"about": "the Claude chat app; can ask, cannot be woken" },
|
|
28
|
+
{ "id": "chatgpt", "name": "ChatGPT", "aliases": ["chat gpt"], "road": "hosted", "home": "cloud", "runs": "self", "target": false, "wake": "none",
|
|
29
|
+
"about": "the ChatGPT app; can ask, cannot be woken" }
|
|
30
|
+
]
|
|
31
|
+
}
|
package/lib/members.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// The swarm's members, one entry each (members.json): who they are, which road they call in on, where their tasks
|
|
4
|
+
// live (home), who runs their work (runs), whether others may hand them work (target) and how they are told work is
|
|
5
|
+
// waiting (wake). The server reads the same file (src/shared/members.ts). A caller on this machine names itself
|
|
6
|
+
// (--agent) and is taken at its word: anything running as the person could read a token kept for it, so none is
|
|
7
|
+
// kept (owner, October 10). A call through Harness is never taken for a member on this machine (hostedCaller).
|
|
8
|
+
const ROSTER = JSON.parse(readFileSync(new URL("./members.json", import.meta.url), "utf8"));
|
|
9
|
+
|
|
10
|
+
export const ROSTER_VERSION = ROSTER.version;
|
|
11
|
+
export const MEMBERS = ROSTER.members;
|
|
12
|
+
const byId = new Map(MEMBERS.map((entry) => [entry.id, entry]));
|
|
13
|
+
|
|
14
|
+
export const member = (id) => byId.get(String(id)) ?? null;
|
|
15
|
+
export const ids = (keep = () => true) => MEMBERS.filter(keep).map((entry) => entry.id);
|
|
16
|
+
/** Members others may hand work to, Harness included. */
|
|
17
|
+
export const TARGETS = ids((entry) => entry.target);
|
|
18
|
+
/** Coding agents the daemon runs on this machine. */
|
|
19
|
+
export const MACHINE_RUNNERS = ids((entry) => entry.runs === "machine");
|
|
20
|
+
/** Targets whose tasks live on Harness's server, not the machine (Harness itself excluded). */
|
|
21
|
+
export const CLOUD_HOMED = ids((entry) => entry.target && entry.home === "cloud" && entry.id !== "harness");
|
|
22
|
+
/** Targets asked by a task rather than started here: they run themselves or on the server. */
|
|
23
|
+
export const ASKED_BY_TASK = ids((entry) => entry.target && entry.runs !== "machine" && entry.id !== "harness");
|
|
24
|
+
export const nameOf = (id) => member(id)?.name ?? String(id);
|
|
25
|
+
export const aboutOf = (id) => {
|
|
26
|
+
const entry = member(id);
|
|
27
|
+
return entry ? `${entry.name}: ${entry.about}` : String(id);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const plain = (value) => String(value || "").trim().toLowerCase().replace(/^@/, "").replace(/[\s_]+/g, " ");
|
|
31
|
+
/**
|
|
32
|
+
* A name as people and agents write it, resolved to one member: an id, a display name or an alias, else the one
|
|
33
|
+
* member whose name it starts. A start shared by several ("claude": Claude Code and the Claude app) comes back
|
|
34
|
+
* ambiguous with the candidates, so the caller asks its person or picks. `extra` admits board names the roster does
|
|
35
|
+
* not list (a connector named by its person), matched exactly.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveMember(name, { extra = [] } = {}) {
|
|
38
|
+
const wanted = plain(name);
|
|
39
|
+
if (!wanted) return { ok: false, candidates: [] };
|
|
40
|
+
const exact = MEMBERS.find((entry) => entry.id === wanted || plain(entry.name) === wanted || entry.aliases.some((alias) => plain(alias) === wanted));
|
|
41
|
+
if (exact) return { ok: true, id: exact.id };
|
|
42
|
+
if (extra.map(String).includes(wanted)) return { ok: true, id: wanted };
|
|
43
|
+
const words = wanted.split(" ");
|
|
44
|
+
const candidates = MEMBERS.filter((entry) => [entry.id.replace(/-/g, " "), plain(entry.name), ...entry.aliases.map(plain)]
|
|
45
|
+
.some((label) => words.every((word, index) => label.split(" ")[index]?.startsWith(word)))).map((entry) => entry.id);
|
|
46
|
+
return candidates.length === 1 ? { ok: true, id: candidates[0] } : { ok: false, candidates };
|
|
47
|
+
}
|
package/lib/onboard.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import { detectLocalAgents, setupHermes, setupOpenClaw, localWakes } from "./wakes.mjs";
|
|
4
|
+
|
|
5
|
+
// "Your swarm" in setup: every agent class with how Harness reaches it, the recommended way already chosen, the steps
|
|
6
|
+
// numbered, and a real test at the end that passes only when the agent claims and reports a task. Also reachable on its
|
|
7
|
+
// own as `npx tryharness agents`. Without a terminal to answer in, it only says what is set up and how to finish.
|
|
8
|
+
|
|
9
|
+
const SETUP = { openclaw: setupOpenClaw, hermes: setupHermes };
|
|
10
|
+
const LABEL = { openclaw: "OpenClaw", hermes: "Hermes", "grok-bot": "Grok Bot" };
|
|
11
|
+
const MEASURED = "measured on a real Grok: about 23s by email, 17s by webhook";
|
|
12
|
+
|
|
13
|
+
export function createPrompter({ input = process.stdin, output = process.stdout } = {}) {
|
|
14
|
+
const ask = (question, { hidden = false } = {}) => new Promise((resolve) => {
|
|
15
|
+
const rl = readline.createInterface({ input, output, terminal: true });
|
|
16
|
+
if (hidden) rl._writeToOutput = (text) => { if (text.includes(question)) output.write(text); };
|
|
17
|
+
rl.question(question, (answer) => { rl.close(); if (hidden) output.write("\n"); resolve(answer.trim()); });
|
|
18
|
+
});
|
|
19
|
+
return { ask };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const copy = (text) => {
|
|
23
|
+
const tool = process.platform === "darwin" ? "pbcopy" : process.platform === "win32" ? "clip" : "xclip";
|
|
24
|
+
const result = spawnSync(tool, tool === "xclip" ? ["-selection", "clipboard"] : [], { input: text });
|
|
25
|
+
return result.status === 0;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Post a setup check for the agent and wait for its report: the same path real work takes. */
|
|
29
|
+
export async function testAgent({ agent, daemonRequest, out = console.log, waitMs = 10 * 60_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
30
|
+
const posted = await daemonRequest({ command: "board.post", agent: "user", objective: "Setup check from Harness: reply ready", brief: "Harness is checking that it can wake you. Claim this task and report it done with the single word: ready. Do nothing else.", targetAgent: agent });
|
|
31
|
+
if (!posted?.ok) { out(` ! the test task could not be posted: ${posted?.error || "no answer"}`); return false; }
|
|
32
|
+
const started = Date.now();
|
|
33
|
+
out(` Test sent. Waiting for ${LABEL[agent] || agent} to answer (Ctrl-C stops waiting; the test task stays on your board).`);
|
|
34
|
+
while (Date.now() - started < waitMs) {
|
|
35
|
+
await sleep(2_000);
|
|
36
|
+
const task = (await daemonRequest({ command: "board.get", agent: "user", id: posted.task.id }).catch(() => null))?.task;
|
|
37
|
+
if (task?.status === "done") { out(` ✓ ${LABEL[agent] || agent} answered in ${Math.round((Date.now() - started) / 1000)}s. Ready.`); return true; }
|
|
38
|
+
if (task && ["failed", "needs_input"].includes(task.status)) { out(` ! ${LABEL[agent] || agent} answered but the task is ${task.status}: ${task.result || ""}`); return false; }
|
|
39
|
+
}
|
|
40
|
+
out(` ! No answer after ${Math.round(waitMs / 60_000)} minutes. Check that the agent is running, then: npx tryharness agents test ${agent}`);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function setupLocal({ entry, runtimeRoot, prompter, daemonRequest, out }) {
|
|
45
|
+
const name = LABEL[entry.agent];
|
|
46
|
+
if (localWakes()[entry.agent]) {
|
|
47
|
+
const health = (await daemonRequest({ command: "wake.local" }).catch(() => null))?.agents?.[entry.agent];
|
|
48
|
+
if (!health || health.answering) { out(` ✓ ${name}: woken on this computer by its own webhook`); return; }
|
|
49
|
+
out(` ! ${name}: not answering wakes (${health.lastFailure}). Check that it is running.`);
|
|
50
|
+
if (!prompter || /^n/i.test(await prompter.ask(` Set ${name} up again? [Y/n] `))) { out(` Later: npx tryharness agents setup ${entry.agent}`); return; }
|
|
51
|
+
const result = SETUP[entry.agent]({ runtimeRoot });
|
|
52
|
+
out(` ${result.ok ? "✓" : "!"} ${result.detail}`);
|
|
53
|
+
if (result.ok) await testAgent({ agent: entry.agent, daemonRequest, out });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!prompter) { out(` ! ${name}: found. Set it up with: npx tryharness agents setup ${entry.agent}`); return; }
|
|
57
|
+
out(` ${name} found. Harness wakes it with a webhook on this computer (recommended, nothing to copy).`);
|
|
58
|
+
out(` This adds Harness to ${name}'s MCP servers and turns on its webhook with a key made here${entry.agent === "openclaw" ? ", then restarts its gateway" : ""}.`);
|
|
59
|
+
if (/^n/i.test(await prompter.ask(` Set up ${name} now? [Y/n] `))) { out(` Skipped. Later: npx tryharness agents setup ${entry.agent}`); return; }
|
|
60
|
+
const result = SETUP[entry.agent]({ runtimeRoot });
|
|
61
|
+
out(` ${result.ok ? "✓" : "!"} ${result.detail}`);
|
|
62
|
+
if (result.ok) await testAgent({ agent: entry.agent, daemonRequest, out });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function setupGrok({ prompter, daemonRequest, out, clipboard }) {
|
|
66
|
+
const listed = await daemonRequest({ command: "wake.server", input: { action: "list" } }).catch((error) => ({ ok: false, error: error.message }));
|
|
67
|
+
if (!listed.ok) { out(` ! Grok Bot: Harness could not be reached (${listed.error}). Later: npx tryharness agents setup grok-bot`); return; }
|
|
68
|
+
const current = (listed.wakes || []).find((wake) => wake.agent === "grok-bot");
|
|
69
|
+
if (current) {
|
|
70
|
+
const how = current.channel === "email" ? `email at ${current.address}` : "its webhook";
|
|
71
|
+
if (current.answering) { out(` ✓ Grok Bot: woken by ${how}`); return; }
|
|
72
|
+
out(` ! Grok Bot: not answering wakes by ${how} (${current.last_failure}).`);
|
|
73
|
+
if (!prompter || /^n/i.test(await prompter.ask(" Set it up again? [Y/n] "))) { out(" Later: npx tryharness agents setup grok-bot"); return; }
|
|
74
|
+
}
|
|
75
|
+
if (!prompter) { out(" Grok Bot: not set up. Set it up with: npx tryharness agents setup grok-bot"); return; }
|
|
76
|
+
out(` Grok Bot. How should Harness wake it? (${MEASURED})`);
|
|
77
|
+
out(" 1) Email nothing to copy back (recommended)");
|
|
78
|
+
out(" 2) Webhook a little faster; you paste its URL and key here");
|
|
79
|
+
out(" 3) Not now");
|
|
80
|
+
const choice = (await prompter.ask(" Choose [1]: ")) || "1";
|
|
81
|
+
if (choice === "3") { out(" Skipped. Later: npx tryharness agents setup grok-bot"); return; }
|
|
82
|
+
const channel = choice === "2" ? "webhook" : "email";
|
|
83
|
+
const message = listed.messages?.["grok-bot"]?.[channel];
|
|
84
|
+
if (!message) { out(" ! Harness did not send the setup message; update tryharness and try again."); return; }
|
|
85
|
+
const copied = clipboard(message);
|
|
86
|
+
out(` 1. ${copied ? "This message is on your clipboard. Paste it into Grok:" : "Paste this message into Grok:"}`);
|
|
87
|
+
out(message.split("\n").map((line) => ` ${line}`).join("\n"));
|
|
88
|
+
out(" 2. Grok opens Harness's approval page. Name the connection Grok Bot and approve.");
|
|
89
|
+
if (channel === "webhook") {
|
|
90
|
+
out(" 3. In Grok, open Routines, then Harness wake, and copy its webhook URL and key.");
|
|
91
|
+
const address = await prompter.ask(" Webhook URL: ");
|
|
92
|
+
const key = await prompter.ask(" Key (hidden): ", { hidden: true });
|
|
93
|
+
const saved = await daemonRequest({ command: "wake.server", input: { action: "save", agent: "grok-bot", channel: "webhook", address, key } });
|
|
94
|
+
if (!saved.ok) { out(` ! ${saved.error}. Try again: npx tryharness agents setup grok-bot`); return; }
|
|
95
|
+
out(" ✓ Saved. The key is stored sealed on Harness and is never shown again.");
|
|
96
|
+
} else {
|
|
97
|
+
out(" 3. Grok registers its own address with Harness. Waiting for it (Ctrl-C stops waiting)...");
|
|
98
|
+
const registered = await waitFor(async () => ((await daemonRequest({ command: "wake.server", input: { action: "list" } }).catch(() => null))?.wakes || []).some((wake) => wake.agent === "grok-bot"));
|
|
99
|
+
if (!registered) { out(" Not registered yet. When Grok has done it: npx tryharness agents test grok-bot"); return; }
|
|
100
|
+
out(" ✓ Grok registered its address.");
|
|
101
|
+
}
|
|
102
|
+
await testAgent({ agent: "grok-bot", daemonRequest, out });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function waitFor(check, waitMs = 10 * 60_000, everyMs = 3_000) {
|
|
106
|
+
const end = Date.now() + waitMs;
|
|
107
|
+
while (Date.now() < end) {
|
|
108
|
+
if (await check()) return true;
|
|
109
|
+
await new Promise((r) => setTimeout(r, everyMs));
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The whole "Your swarm" step. `only` limits it to one agent (npx tryharness agents setup <agent>). */
|
|
115
|
+
export async function setupSwarm({ runtimeRoot, states = [], daemonRequest, prompter = process.stdin.isTTY ? createPrompter() : null, out = console.log, only = null, clipboard = copy }) {
|
|
116
|
+
const want = (agent) => !only || only === agent;
|
|
117
|
+
out("\nYour swarm");
|
|
118
|
+
if (!only) {
|
|
119
|
+
out(" On this computer: Harness starts them when work is posted");
|
|
120
|
+
for (const state of states) out(` ${!state.installed ? "–" : state.configured ? "✓" : "!"} ${state.agent}: ${state.detail}`);
|
|
121
|
+
}
|
|
122
|
+
const local = detectLocalAgents().filter((entry) => entry.found && want(entry.agent));
|
|
123
|
+
if (local.length) out(" Agent apps on this computer: woken by a webhook Harness sets up");
|
|
124
|
+
for (const entry of local) await setupLocal({ entry, runtimeRoot, prompter, daemonRequest, out });
|
|
125
|
+
if (only && LABEL[only] && only !== "grok-bot" && !local.length) out(` ${LABEL[only]} was not found on this computer.`);
|
|
126
|
+
if (want("grok-bot")) {
|
|
127
|
+
out(" Cloud agents: woken by email or webhook");
|
|
128
|
+
await setupGrok({ prompter, daemonRequest, out, clipboard });
|
|
129
|
+
}
|
|
130
|
+
if (!only) out(" Built in: Bankr and Quotient run on Harness, nothing to set up");
|
|
131
|
+
}
|
package/lib/paths.mjs
CHANGED
|
@@ -14,6 +14,7 @@ export const paths = {
|
|
|
14
14
|
status: path.join(harnessHome, "daemon-status.json"),
|
|
15
15
|
socket: process.platform === "win32" ? "\\\\.\\pipe\\tryharness-daemon" : path.join(harnessHome, "daemon.sock"),
|
|
16
16
|
log: path.join(harnessHome, "daemon.log"),
|
|
17
|
+
phoneIdentity: path.join(harnessHome, "phone-identity.json"),
|
|
17
18
|
runs: path.join(harnessHome, "runs"),
|
|
18
19
|
runtime: path.join(harnessHome, "runtime"),
|
|
19
20
|
models: path.join(harnessHome, "models"),
|
package/lib/phones.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { b64u, channelKey, constantTimeEqual, loadOrCreateIdentity, MAX_SKEW_MS, open, pairProof, seal } from "./sealed.mjs";
|
|
4
|
+
|
|
5
|
+
// The daemon's side of the person's own devices: pairing offers made on this machine (a QR code
|
|
6
|
+
// for a phone, a link opened in a browser), and sealed calls from paired devices, opened here and
|
|
7
|
+
// answered here. Harness only carries the envelopes.
|
|
8
|
+
// A phone acts as the person ("user") within PHONE_COMMANDS. A browser is the Harness window on a
|
|
9
|
+
// desk: it acts as the person or as Harness (the chat owner), within BROWSER_COMMANDS, and reads
|
|
10
|
+
// the coding sessions and run records Harness used to read in the clear.
|
|
11
|
+
|
|
12
|
+
const OFFER_TTL_MS = 10 * 60_000;
|
|
13
|
+
const MAX_OFFERS = 5;
|
|
14
|
+
// A message queued at Harness while this computer slept may arrive up to 7 days late; only decisions and posts may.
|
|
15
|
+
const QUEUED_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
+
const QUEUEABLE = new Set(["board.post", "board.run", "board.release", "board.answer", "board.remove", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.end", "board.goal.stop",
|
|
17
|
+
"board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.start_run"]);
|
|
18
|
+
const ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
19
|
+
const KEY = /^[A-Za-z0-9_-]{80,100}$/;
|
|
20
|
+
const KINDS = new Set(["phone", "browser"]);
|
|
21
|
+
const PHONE_COMMANDS = new Set([
|
|
22
|
+
"board.list", "board.get", "board.children", "board.agents", "board.post", "board.run", "board.release", "board.answer", "board.remove",
|
|
23
|
+
"board.thread.read", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.auto", "board.thread.end",
|
|
24
|
+
"board.goal.list", "board.goal.get", "board.goal.stop", "board.room.read",
|
|
25
|
+
"board.swarm.list", "board.swarm.read", "board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.folder", "board.swarm.coordinator",
|
|
26
|
+
"board.swarm.diff", "board.swarm.merge", "board.swarm.discard", "board.swarm.start_run",
|
|
27
|
+
]);
|
|
28
|
+
// What the chat owner does on the board, in Harness's name (was src/server/mcp/board.ts HARNESS_COMMANDS).
|
|
29
|
+
const HARNESS_COMMANDS = new Set([
|
|
30
|
+
"board.list", "board.get", "board.post", "board.claim", "board.report", "board.answer", "board.thread.read", "board.thread.post", "board.standing.check",
|
|
31
|
+
"board.goal.create", "board.goal.get", "board.goal.list", "board.goal.finish", "board.room.read", "board.room.post", "board.room.search", "board.children", "board.agents",
|
|
32
|
+
"board.swarm.list", "board.swarm.read", "board.swarm.diff",
|
|
33
|
+
]);
|
|
34
|
+
const BROWSER_PERSON_COMMANDS = new Set([...PHONE_COMMANDS, "board.goal.resume", "board.standing", "board.standing.check", "board.standing.off"]);
|
|
35
|
+
const BROWSER_READS = new Set(["sessions.search", "sessions.read", "sessions.raw", "tasks.status", "relay.request", "relay.open"]);
|
|
36
|
+
|
|
37
|
+
export async function createPhoneDesk({ store, identityFile, boardCommand, reads = {}, version, onMessage = () => {}, machine = () => null }) {
|
|
38
|
+
const identity = await loadOrCreateIdentity(identityFile);
|
|
39
|
+
const offers = new Map();
|
|
40
|
+
const keys = new Map();
|
|
41
|
+
const hostname = os.hostname().slice(0, 64);
|
|
42
|
+
|
|
43
|
+
const sweep = () => {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
for (const [id, offer] of offers) if (offer.expiresAt < now) offers.delete(id);
|
|
46
|
+
};
|
|
47
|
+
const keyFor = async (device) => {
|
|
48
|
+
if (!keys.has(device.id)) keys.set(device.id, await channelKey(identity.privateKey, device.public_key, device.id));
|
|
49
|
+
return keys.get(device.id);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// What the QR code or link carries. The secret and the daemon's key never pass through Harness.
|
|
53
|
+
// The kind is fixed here, on the machine: a page cannot turn a phone's code into a browser's.
|
|
54
|
+
const offer = (origin, kind = "phone") => {
|
|
55
|
+
if (!KINDS.has(kind)) throw new Error(`unknown device kind ${kind}`);
|
|
56
|
+
sweep();
|
|
57
|
+
if (offers.size >= MAX_OFFERS) offers.delete(offers.keys().next().value);
|
|
58
|
+
const offerId = b64u(randomBytes(16));
|
|
59
|
+
const secret = b64u(randomBytes(32));
|
|
60
|
+
offers.set(offerId, { secret, kind, expiresAt: Date.now() + OFFER_TTL_MS });
|
|
61
|
+
const fragment = new URLSearchParams({ v: "1", o: offerId, s: secret, k: identity.publicKey, h: hostname });
|
|
62
|
+
const page = kind === "browser" ? "/link" : "/m/pair";
|
|
63
|
+
return { offerId, kind, expiresInSeconds: OFFER_TTL_MS / 1000, url: `${String(origin).replace(/\/$/, "")}${page}#${fragment}` };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const pair = async (input) => {
|
|
67
|
+
sweep();
|
|
68
|
+
const { offerId, deviceId, devicePublicKey, proof, requestId } = input;
|
|
69
|
+
if (![offerId, deviceId, requestId].every((value) => ID.test(String(value))) || !KEY.test(String(devicePublicKey))) return { ok: false, error: "invalid pairing request" };
|
|
70
|
+
const pending = offers.get(String(offerId));
|
|
71
|
+
// One attempt per code, right or wrong: a guessed proof burns the code.
|
|
72
|
+
offers.delete(String(offerId));
|
|
73
|
+
if (!pending) return { ok: false, error: "this pairing code expired or was already used; make a new one on the computer" };
|
|
74
|
+
if (!constantTimeEqual(proof, await pairProof(pending.secret, offerId, devicePublicKey, identity.publicKey))) return { ok: false, error: "the pairing proof did not match" };
|
|
75
|
+
if (store.getPhone(String(deviceId))) return { ok: false, error: "this device is already paired" };
|
|
76
|
+
const fallback = pending.kind === "browser" ? "Browser" : "Phone";
|
|
77
|
+
const label = String(input.label || fallback).replace(/[^\p{L}\p{N} ._'-]/gu, "").slice(0, 40) || fallback;
|
|
78
|
+
const device = store.addPhone({ id: String(deviceId), label, publicKey: String(devicePublicKey), kind: pending.kind });
|
|
79
|
+
return { ok: true, envelope: await seal(await keyFor(device), "d2p", device.id, String(requestId), { paired: true, hostname, label, kind: device.kind, version }) };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const call = async (envelope) => {
|
|
83
|
+
sweep();
|
|
84
|
+
if (!envelope || envelope.v !== 1 || !ID.test(String(envelope.deviceId)) || !ID.test(String(envelope.requestId)) || typeof envelope.iv !== "string" || typeof envelope.ct !== "string") {
|
|
85
|
+
return { ok: false, error: "invalid envelope" };
|
|
86
|
+
}
|
|
87
|
+
const device = store.getPhone(envelope.deviceId);
|
|
88
|
+
if (!device) return { ok: false, error: "unknown device", code: "unpaired" };
|
|
89
|
+
if (device.revoked_at) return { ok: false, error: "this device was disconnected from the computer", code: "revoked" };
|
|
90
|
+
const key = await keyFor(device);
|
|
91
|
+
let request;
|
|
92
|
+
try { request = await open(key, "p2d", envelope); } catch { return { ok: false, error: "the message could not be opened", code: "unreadable" }; }
|
|
93
|
+
const queued = request?.queued === true;
|
|
94
|
+
if (queued && !QUEUEABLE.has(String(request?.call?.command || ""))) return { ok: false, error: "only posts and decisions wait for the computer", code: "refused" };
|
|
95
|
+
const age = typeof request?.ts === "number" ? Date.now() - request.ts : Infinity;
|
|
96
|
+
if (queued ? age > QUEUED_MAX_AGE_MS || age < -MAX_SKEW_MS : Math.abs(age) > MAX_SKEW_MS) return { ok: false, error: "the message is too old or the device's clock is off", code: "stale" };
|
|
97
|
+
if (!store.firstPhoneRequest(device.id, String(envelope.requestId))) return { ok: false, error: "this message was already received", code: "replayed" };
|
|
98
|
+
store.touchPhone(device.id);
|
|
99
|
+
// Any message from a paired device keeps a Mac in Standby awake for the next hour (Lid-Closed Mode).
|
|
100
|
+
onMessage();
|
|
101
|
+
if (request.call?.command === "phone.forget") {
|
|
102
|
+
const forgotten = revoke(device.id);
|
|
103
|
+
return { ok: true, envelope: await seal(key, "d2p", device.id, envelope.requestId, { ok: forgotten.ok }) };
|
|
104
|
+
}
|
|
105
|
+
const result = await answer(request.call, device);
|
|
106
|
+
return { ok: true, envelope: await seal(key, "d2p", device.id, envelope.requestId, result) };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const settled = async (run) => {
|
|
110
|
+
try {
|
|
111
|
+
const result = run();
|
|
112
|
+
return result?.pending ? { ok: true, ...(await result.pending) } : result;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return { ok: false, error: `the board could not answer: ${error.message}` };
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const answer = async (callInput, device) => {
|
|
119
|
+
const command = String(callInput?.command || "");
|
|
120
|
+
if (command === "phone.hello") return { ok: true, hostname, version, kind: device.kind };
|
|
121
|
+
if (device.kind === "browser") {
|
|
122
|
+
if (BROWSER_READS.has(command)) {
|
|
123
|
+
const read = reads[command];
|
|
124
|
+
if (!read) return { ok: false, error: `this computer cannot answer ${command}` };
|
|
125
|
+
try { return { ok: true, result: await read(callInput) }; } catch (error) { return { ok: false, error: error.message }; }
|
|
126
|
+
}
|
|
127
|
+
// The browser says whose call it is: the person's own action, or the chat owner's tool call.
|
|
128
|
+
const as = callInput.as === "harness" ? "harness" : "user";
|
|
129
|
+
if (!(as === "harness" ? HARNESS_COMMANDS : BROWSER_PERSON_COMMANDS).has(command)) return { ok: false, error: `a browser cannot call ${command || "that"} as ${as}` };
|
|
130
|
+
// Words another agent wrote that drove Harness's call ride as a taint the board keeps.
|
|
131
|
+
const writtenBy = Array.isArray(callInput.writtenBy) ? callInput.writtenBy.map(String).slice(0, 16) : [];
|
|
132
|
+
if (as === "harness" && command === "board.goal.create" && writtenBy.length) return { ok: false, error: "a goal is set only in a turn the owner started" };
|
|
133
|
+
const { as: _as, writtenBy: _written, provenance: _provenance, ...rest } = callInput;
|
|
134
|
+
return settled(() => boardCommand({ ...rest, command, agent: as, ...(writtenBy.length ? { provenance: { trust: "agent", chain: writtenBy } } : {}) }));
|
|
135
|
+
}
|
|
136
|
+
// Lid-Closed Mode's wake: no arguments, never queued, and it can only keep the Mac awake and say how it is.
|
|
137
|
+
if (command === "machine.wake") return { ok: true, machine: machine() };
|
|
138
|
+
if (!PHONE_COMMANDS.has(command)) return { ok: false, error: `a phone cannot call ${command || "that"}` };
|
|
139
|
+
// The phone is the person; whatever it names itself, it acts as "user".
|
|
140
|
+
return settled(() => boardCommand({ ...callInput, command, agent: "user", ...(command === "board.list" && callInput.all === undefined ? { all: true } : {}) }));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Harness's bridge message: a pairing or a sealed call. Everything returned is safe to pass through Harness.
|
|
144
|
+
const handle = async (input) => (input?.kind === "pair" ? pair(input) : input?.kind === "call" ? call(input.envelope) : { ok: false, error: "unknown sealed request" });
|
|
145
|
+
|
|
146
|
+
const revoke = (id) => {
|
|
147
|
+
keys.delete(String(id));
|
|
148
|
+
return store.revokePhone(String(id));
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return { offer, handle, revoke, list: () => store.phones(), publicKey: identity.publicKey, identity };
|
|
152
|
+
}
|