tryharness 0.7.9 → 0.7.13
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/assets/opencode-plugin.js +9 -2
- package/assets/pi-extension.ts +8 -3
- package/cli.mjs +67 -42
- package/lib/adapters.mjs +12 -0
- package/lib/cards.mjs +113 -0
- package/lib/coordinator.mjs +5 -6
- package/lib/daemon.mjs +172 -20
- package/lib/files.mjs +15 -0
- package/lib/mcp-stdio.mjs +36 -5
- package/lib/members.json +26 -13
- package/lib/nudge.mjs +29 -13
- package/lib/onboard.mjs +132 -82
- package/lib/paths.mjs +1 -0
- package/lib/phones.mjs +1 -1
- package/lib/runtime-install.mjs +1 -1
- package/lib/sealed.mjs +9 -0
- package/lib/store.mjs +65 -3
- package/lib/swarm.mjs +12 -7
- package/lib/ui.mjs +111 -0
- package/package.json +9 -6
package/lib/nudge.mjs
CHANGED
|
@@ -11,7 +11,13 @@ import { placeEnv } from "./places.mjs";
|
|
|
11
11
|
// SessionStart hook (stdout lands in its context); the Pi extension and OpenCode plugin
|
|
12
12
|
// build the same text in-process. Nothing waiting, or no daemon, prints nothing.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// The swarm block (cards.mjs), when given, follows the queue: who else is in the person's swarm and what each is for.
|
|
15
|
+
export function nudgeText(agent, tasks, unread = [], goals = [], swarm = "") {
|
|
16
|
+
const queue = queueText(agent, tasks, unread, goals);
|
|
17
|
+
return [queue, swarm].filter(Boolean).join("\n\n");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function queueText(agent, tasks, unread, goals) {
|
|
15
21
|
const waiting = (tasks || []).filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === agent));
|
|
16
22
|
const held = (tasks || []).filter((task) => task.claimed_by === agent && (task.status === "claimed" || task.status === "needs_input"));
|
|
17
23
|
if (!waiting.length && !held.length && !unread.length && !goals.length) return "";
|
|
@@ -38,31 +44,41 @@ export function queueDigest(agent, tasks, unread = [], goals = []) {
|
|
|
38
44
|
|
|
39
45
|
export async function boardQueue(agent) {
|
|
40
46
|
try {
|
|
41
|
-
const response = await daemonRequest({ command: "board.list", agent, unread: true }, 1_500);
|
|
42
|
-
return response?.ok ? { tasks: response.tasks, unread: response.unread || [], goals: response.goals || [] } : null;
|
|
47
|
+
const response = await daemonRequest({ command: "board.list", agent, unread: true, swarm: true }, 1_500);
|
|
48
|
+
return response?.ok ? { tasks: response.tasks, unread: response.unread || [], goals: response.goals || [], swarm: response.swarm || null } : null;
|
|
43
49
|
} catch {
|
|
44
50
|
return null;
|
|
45
51
|
}
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
// What this agent was last told, in the Harness home: the queue's digest and the swarm's.
|
|
55
|
+
const toldFile = (agent, homeDir) => path.join(homeDir, `nudge-${agent}.json`);
|
|
56
|
+
function lastTold(agent, homeDir) { try { return JSON.parse(fs.readFileSync(toldFile(agent, homeDir), "utf8")); } catch { return {}; } }
|
|
57
|
+
function noteTold(agent, homeDir, told) { try { fs.writeFileSync(toldFile(agent, homeDir), JSON.stringify({ ...lastTold(agent, homeDir), ...told, at: new Date().toISOString() }), { mode: 0o600 }); } catch {} }
|
|
58
|
+
|
|
59
|
+
/** What a session is told when it starts: its queue and the whole swarm. */
|
|
60
|
+
export async function boardNudge(agent, homeDir = paths.home) {
|
|
49
61
|
const queue = await boardQueue(agent);
|
|
50
|
-
|
|
62
|
+
if (!queue) return "";
|
|
63
|
+
if (queue.swarm?.digest) noteTold(agent, homeDir, { swarm: queue.swarm.digest });
|
|
64
|
+
return nudgeText(agent, queue.tasks, queue.unread, queue.goals, queue.swarm?.text || "");
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
// Only what changed since this agent last saw its queue: a hook that runs on every prompt
|
|
54
|
-
// prints once per change, never the same block again. The
|
|
68
|
+
// prints once per change, never the same block again. The swarm is told again only when who is
|
|
69
|
+
// in it, their facts or the person's notes changed, not each time an agent finishes something.
|
|
55
70
|
export async function boardNudgeIfChanged(agent, homeDir = paths.home) {
|
|
56
71
|
const queue = await boardQueue(agent);
|
|
57
72
|
if (!queue) return "";
|
|
58
|
-
const { tasks, unread, goals } = queue;
|
|
73
|
+
const { tasks, unread, goals, swarm } = queue;
|
|
59
74
|
const digest = queueDigest(agent, tasks, unread, goals);
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
const last = lastTold(agent, homeDir);
|
|
76
|
+
const queueMoved = digest !== (last.digest ?? null);
|
|
77
|
+
const swarmMoved = Boolean(swarm?.digest) && swarm.digest !== last.swarm;
|
|
78
|
+
if (!queueMoved && !swarmMoved) return "";
|
|
79
|
+
noteTold(agent, homeDir, { digest, ...(swarm?.digest ? { swarm: swarm.digest } : {}) });
|
|
80
|
+
const told = !queueMoved ? "" : digest ? queueText(agent, tasks, unread, goals) : (last.digest ? "Harness task board: nothing waits for you now." : "");
|
|
81
|
+
return [told, swarmMoved ? swarm.text : ""].filter(Boolean).join("\n\n");
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
// The prompt hook also tells the daemon the person typed in this session: the agent process is the hook's parent, or
|
package/lib/onboard.mjs
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import
|
|
2
|
+
import pc from "picocolors";
|
|
3
3
|
import { detectLocalAgents, setupHermes, setupOpenClaw, localWakes } from "./wakes.mjs";
|
|
4
|
+
import { columns, createUi, wrap } from "./ui.mjs";
|
|
4
5
|
|
|
5
|
-
// "Your swarm" in setup: every agent class with how Harness reaches it, the recommended way already chosen,
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// "Your swarm" in setup: every agent class with how Harness reaches it, the recommended way already chosen, and a real
|
|
7
|
+
// test at the end that passes only when the agent claims and reports a task. Also reachable on its own as
|
|
8
|
+
// `npx tryharness agents`. Without a terminal to answer in, it only says what is set up and how to finish.
|
|
8
9
|
|
|
9
10
|
const SETUP = { openclaw: setupOpenClaw, hermes: setupHermes };
|
|
10
11
|
const LABEL = { openclaw: "OpenClaw", hermes: "Hermes", "grok-bot": "Grok Bot" };
|
|
11
|
-
const
|
|
12
|
+
const later = (agent) => `Later: ${pc.cyan(`npx tryharness agents setup ${agent}`)}`;
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
// The Harness mark (src/main/home/landing/walkthrough/BrandMark.tsx) in box characters: dim corners, the bright one in
|
|
15
|
+
// Harness teal, the wordmark beside it. Plain when the output is not a terminal or NO_COLOR is set.
|
|
16
|
+
export function banner(version, { color = pc.isColorSupported } = {}) {
|
|
17
|
+
const c = pc.createColors(color), teal = (text) => (color ? `\x1b[38;5;36m${text}\x1b[39m` : text);
|
|
18
|
+
const rows = [
|
|
19
|
+
[c.dim("┌───"), " ", teal("───┐")],
|
|
20
|
+
[c.dim("│"), " ", teal("│")],
|
|
21
|
+
[""],
|
|
22
|
+
[" ", c.dim("┌──"), teal("───┐")],
|
|
23
|
+
[" ", c.dim("│"), " ", teal("│")],
|
|
24
|
+
[" ", c.dim("└─────┘")],
|
|
25
|
+
[""],
|
|
26
|
+
[c.dim("│"), " ", c.dim("│")],
|
|
27
|
+
[c.dim("└───"), " ", c.dim("───┘")],
|
|
28
|
+
];
|
|
29
|
+
const side = { 3: c.bold("harness relay"), 4: "your personal swarm", 5: c.dim(`setup ${version}`) };
|
|
30
|
+
return rows.map((parts, index) => {
|
|
31
|
+
const line = parts.join("");
|
|
32
|
+
const width = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
33
|
+
return side[index] ? ` ${line}${" ".repeat(23 - width)}${side[index]}` : ` ${line}`.trimEnd();
|
|
34
|
+
}).join("\n");
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
const copy = (text) => {
|
|
@@ -26,106 +41,141 @@ const copy = (text) => {
|
|
|
26
41
|
};
|
|
27
42
|
|
|
28
43
|
/** 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,
|
|
44
|
+
export async function testAgent({ agent, daemonRequest, ui = createUi(), waitMs = 10 * 60_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
45
|
+
const name = LABEL[agent] || agent;
|
|
30
46
|
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) {
|
|
32
|
-
const started = Date.now();
|
|
33
|
-
|
|
47
|
+
if (!posted?.ok) { ui.warn(`The test task could not be posted: ${posted?.error || "no answer"}`); return false; }
|
|
48
|
+
const started = Date.now(), spin = ui.spinner();
|
|
49
|
+
spin.start(`Waiting for ${name} to answer a test task`);
|
|
34
50
|
while (Date.now() - started < waitMs) {
|
|
35
51
|
await sleep(2_000);
|
|
36
52
|
const task = (await daemonRequest({ command: "board.get", agent: "user", id: posted.task.id }).catch(() => null))?.task;
|
|
37
|
-
if (task?.status === "done") {
|
|
38
|
-
if (task && ["failed", "needs_input"].includes(task.status)) {
|
|
53
|
+
if (task?.status === "done") { spin.stop(`${name} answered in ${Math.round((Date.now() - started) / 1000)}s. Ready.`); return true; }
|
|
54
|
+
if (task && ["failed", "needs_input"].includes(task.status)) { spin.stop(`${name} answered, but the task is ${task.status}${task.result ? `: ${task.result}` : ""}`, 2); return false; }
|
|
39
55
|
}
|
|
40
|
-
|
|
56
|
+
spin.stop(`No answer after ${Math.round(waitMs / 60_000)} minutes. Check that ${name} is running, then: npx tryharness agents test ${agent}`, 2);
|
|
41
57
|
return false;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
|
-
async function setupLocal({ entry, runtimeRoot,
|
|
60
|
+
async function setupLocal({ entry, runtimeRoot, ui, daemonRequest }) {
|
|
45
61
|
const name = LABEL[entry.agent];
|
|
62
|
+
const run = async () => {
|
|
63
|
+
const result = SETUP[entry.agent]({ runtimeRoot });
|
|
64
|
+
if (!result.ok) { ui.warn(result.detail); return; }
|
|
65
|
+
ui.success(result.detail);
|
|
66
|
+
await testAgent({ agent: entry.agent, daemonRequest, ui });
|
|
67
|
+
};
|
|
46
68
|
if (localWakes()[entry.agent]) {
|
|
47
69
|
const health = (await daemonRequest({ command: "wake.local" }).catch(() => null))?.agents?.[entry.agent];
|
|
48
|
-
if (!health || health.answering) {
|
|
49
|
-
|
|
50
|
-
if (!
|
|
51
|
-
|
|
52
|
-
out(` ${result.ok ? "✓" : "!"} ${result.detail}`);
|
|
53
|
-
if (result.ok) await testAgent({ agent: entry.agent, daemonRequest, out });
|
|
70
|
+
if (!health || health.answering) { ui.success(`${name} is woken on this computer by its own webhook`); return; }
|
|
71
|
+
ui.warn(`${name} is not answering wakes (${health.lastFailure}). Check that it is running.`);
|
|
72
|
+
if (!ui.interactive || !(await ui.confirm({ message: `Set ${name} up again?`, initialValue: true }))) { ui.info(later(entry.agent)); return; }
|
|
73
|
+
await run();
|
|
54
74
|
return;
|
|
55
75
|
}
|
|
56
|
-
if (!
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
if (!ui.interactive) { ui.info(`${name} found. ${later(entry.agent)}`); return; }
|
|
77
|
+
ui.step(`${name} found on this computer\n${pc.dim(`Harness adds itself to ${name}'s MCP servers and turns on its webhook with a key made here${entry.agent === "openclaw" ? ", then restarts its gateway" : ""}. Nothing to copy.`)}`);
|
|
78
|
+
if (!(await ui.confirm({ message: `Set up ${name} now?`, initialValue: true }))) { ui.info(`Skipped. ${later(entry.agent)}`); return; }
|
|
79
|
+
await run();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function waitFor(check, waitMs = 10 * 60_000, everyMs = 3_000) {
|
|
83
|
+
const end = Date.now() + waitMs;
|
|
84
|
+
while (Date.now() < end) {
|
|
85
|
+
if (await check()) return true;
|
|
86
|
+
await new Promise((r) => setTimeout(r, everyMs));
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
async function setupGrok({
|
|
66
|
-
const
|
|
67
|
-
|
|
91
|
+
async function setupGrok({ ui, daemonRequest, clipboard }) {
|
|
92
|
+
const list = () => daemonRequest({ command: "wake.server", input: { action: "list" } });
|
|
93
|
+
const listed = await list().catch((error) => ({ ok: false, error: error.message }));
|
|
94
|
+
if (!listed.ok) { ui.warn(`Harness could not be reached (${listed.error}). ${later("grok-bot")}`); return; }
|
|
68
95
|
const current = (listed.wakes || []).find((wake) => wake.agent === "grok-bot");
|
|
69
96
|
if (current) {
|
|
70
97
|
const how = current.channel === "email" ? `email at ${current.address}` : "its webhook";
|
|
71
|
-
if (current.answering) {
|
|
72
|
-
|
|
73
|
-
if (!
|
|
98
|
+
if (current.answering) { ui.success(`Grok Bot is woken by ${how}`); return; }
|
|
99
|
+
ui.warn(`Grok Bot is not answering wakes by ${how} (${current.last_failure})`);
|
|
100
|
+
if (!ui.interactive || !(await ui.confirm({ message: "Set it up again?", initialValue: true }))) { ui.info(later("grok-bot")); return; }
|
|
74
101
|
}
|
|
75
|
-
if (!
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
102
|
+
if (!ui.interactive) { ui.info(`Grok Bot is not set up. ${later("grok-bot")}`); return; }
|
|
103
|
+
const channel = await ui.select({
|
|
104
|
+
message: "How should Harness wake Grok Bot?",
|
|
105
|
+
initialValue: "email",
|
|
106
|
+
options: [
|
|
107
|
+
{ value: "email", label: "Email", hint: "recommended · about 23s · nothing to copy back" },
|
|
108
|
+
{ value: "webhook", label: "Webhook", hint: "about 17s · you paste its URL and key here" },
|
|
109
|
+
{ value: "skip", label: "Not now" },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
if (!channel || channel === "skip") { ui.info(`Skipped. ${later("grok-bot")}`); return; }
|
|
83
113
|
const message = listed.messages?.["grok-bot"]?.[channel];
|
|
84
|
-
if (!message) {
|
|
114
|
+
if (!message) { ui.warn("Harness did not send the setup message. Update tryharness and try again."); return; }
|
|
115
|
+
const setup = (listed.setup || []).find((entry) => entry.agent === "grok-bot");
|
|
116
|
+
const connected = Boolean(setup) && !setup.missing?.includes("connection");
|
|
85
117
|
const copied = clipboard(message);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
118
|
+
ui.note(wrap(message, Math.max(40, columns() - 8)).join("\n"), copied ? "Paste this into Grok · it is on your clipboard" : "Paste this into Grok");
|
|
119
|
+
if (connected) ui.success(`Grok is already connected to Harness as Grok Bot${setup.old_approval ? `\n${pc.dim("approved before it could register its own address, so it gives you its address instead")}` : ""}`);
|
|
120
|
+
else ui.info("Grok opens Harness's approval page. Name the connection Grok Bot and approve it.");
|
|
121
|
+
|
|
122
|
+
const save = async (input, done) => {
|
|
123
|
+
const saved = await daemonRequest({ command: "wake.server", input: { action: "save", agent: "grok-bot", ...input } });
|
|
124
|
+
if (!saved.ok) { ui.warn(`${saved.error}. Try again: npx tryharness agents setup grok-bot`); return false; }
|
|
125
|
+
ui.success(done(saved));
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
89
128
|
if (channel === "webhook") {
|
|
90
|
-
|
|
91
|
-
const address = await
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
if (!
|
|
95
|
-
|
|
129
|
+
ui.info("In Grok, open Routines, then Harness wake, and copy its webhook URL and key.");
|
|
130
|
+
const address = await ui.text({ message: "Grok's webhook URL", placeholder: "https://", validate: (value) => (/^https:\/\/\S+$/.test(value.trim()) ? undefined : "Paste the https URL from Grok's routine") });
|
|
131
|
+
if (!address) { ui.info(`Skipped. ${later("grok-bot")}`); return; }
|
|
132
|
+
const key = await ui.password({ message: "Its key", validate: (value) => (value.trim() ? undefined : "Paste the key from Grok's routine") });
|
|
133
|
+
if (!key) { ui.info(`Skipped. ${later("grok-bot")}`); return; }
|
|
134
|
+
if (!(await save({ channel: "webhook", address: address.trim(), key }, () => "Saved. The key is stored sealed on Harness and never shown again."))) return;
|
|
96
135
|
} else {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
136
|
+
const ownAddress = (value) => (/^[^\s@]+@mail\.grokbot\.com$/i.test(value.trim()) ? undefined : "Grok's address ends in @mail.grokbot.com");
|
|
137
|
+
let address = "";
|
|
138
|
+
if (setup?.old_approval) {
|
|
139
|
+
address = await ui.text({ message: "Grok's email address", placeholder: "name@mail.grokbot.com", validate: ownAddress }) ?? "";
|
|
140
|
+
if (!address) { ui.info(`Skipped. ${later("grok-bot")}`); return; }
|
|
141
|
+
} else {
|
|
142
|
+
// Grok registers its own address; the person may type the one Grok gives instead, and the prompt closes when it registers.
|
|
143
|
+
let settled = false;
|
|
144
|
+
const registered = waitFor(async () => settled || ((await list().catch(() => null))?.wakes || []).some((wake) => wake.agent === "grok-bot"))
|
|
145
|
+
.then((ok) => (ok ? { ok, line: "Grok registered its address" } : { ok, line: "Grok has not registered yet. When it has: npx tryharness agents test grok-bot" }));
|
|
146
|
+
const answer = await ui.waitOrType({ message: "Waiting for Grok to register its address", placeholder: "or type the address Grok gives you", until: registered });
|
|
147
|
+
let outcome = answer.done;
|
|
148
|
+
if (answer.cancelled) { settled = true; ui.info(`Stopped waiting. ${later("grok-bot")}`); return; }
|
|
149
|
+
if (answer.value && ownAddress(answer.value)) { settled = true; ui.warn(`${ownAddress(answer.value)}. ${later("grok-bot")}`); return; }
|
|
150
|
+
if (answer.value) address = answer.value;
|
|
151
|
+
else if (!outcome) {
|
|
152
|
+
const spin = ui.spinner();
|
|
153
|
+
spin.start("Waiting for Grok to register its address");
|
|
154
|
+
outcome = await registered;
|
|
155
|
+
spin.stop(outcome.line, outcome.ok ? 0 : 2);
|
|
156
|
+
}
|
|
157
|
+
settled = true;
|
|
158
|
+
if (outcome && !outcome.ok) return;
|
|
159
|
+
}
|
|
160
|
+
if (address && !(await save({ channel: "email", address: address.trim() }, (saved) => `Saved. Harness wakes Grok at ${saved.wake?.address ?? address.trim()}`))) return;
|
|
101
161
|
}
|
|
102
|
-
await testAgent({ agent: "grok-bot", daemonRequest,
|
|
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;
|
|
162
|
+
await testAgent({ agent: "grok-bot", daemonRequest, ui });
|
|
112
163
|
}
|
|
113
164
|
|
|
114
165
|
/** The whole "Your swarm" step. `only` limits it to one agent (npx tryharness agents setup <agent>). */
|
|
115
|
-
export async function setupSwarm({ runtimeRoot, states = [], daemonRequest,
|
|
166
|
+
export async function setupSwarm({ runtimeRoot, states = [], daemonRequest, ui = createUi(), only = null, clipboard = copy }) {
|
|
116
167
|
const want = (agent) => !only || only === agent;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
168
|
+
if (!only && states.length) {
|
|
169
|
+
const width = Math.max(...states.map((state) => state.agent.length));
|
|
170
|
+
const rows = states.map((state) => `${!state.installed ? pc.dim("–") : state.configured ? pc.green("✓") : pc.yellow("!")} ${state.agent.padEnd(width)} ${pc.dim(state.detail)}`);
|
|
171
|
+
ui.step(`Coding agents on this computer ${pc.dim("· Harness starts them when work is posted")}\n${rows.join("\n")}`);
|
|
121
172
|
}
|
|
122
173
|
const local = detectLocalAgents().filter((entry) => entry.found && want(entry.agent));
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (only && LABEL[only] && only !== "grok-bot" && !local.length) out(` ${LABEL[only]} was not found on this computer.`);
|
|
174
|
+
for (const entry of local) await setupLocal({ entry, runtimeRoot, ui, daemonRequest });
|
|
175
|
+
if (only && LABEL[only] && only !== "grok-bot" && !local.length) ui.warn(`${LABEL[only]} was not found on this computer.`);
|
|
126
176
|
if (want("grok-bot")) {
|
|
127
|
-
|
|
128
|
-
await setupGrok({
|
|
177
|
+
ui.step(`Grok Bot ${pc.dim("· runs in its own cloud, woken by email or webhook")}`);
|
|
178
|
+
await setupGrok({ ui, daemonRequest, clipboard });
|
|
129
179
|
}
|
|
130
|
-
if (!only)
|
|
180
|
+
if (!only) ui.info(`Bankr and Quotient run on Harness ${pc.dim("· nothing to set up")}`);
|
|
131
181
|
}
|
package/lib/paths.mjs
CHANGED
|
@@ -16,6 +16,7 @@ export const paths = {
|
|
|
16
16
|
log: path.join(harnessHome, "daemon.log"),
|
|
17
17
|
phoneIdentity: path.join(harnessHome, "phone-identity.json"),
|
|
18
18
|
runs: path.join(harnessHome, "runs"),
|
|
19
|
+
files: path.join(harnessHome, "files"),
|
|
19
20
|
runtime: path.join(harnessHome, "runtime"),
|
|
20
21
|
models: path.join(harnessHome, "models"),
|
|
21
22
|
legacyBridgeConfig: path.join(harnessHome, "bridge.json"),
|
package/lib/phones.mjs
CHANGED
|
@@ -23,7 +23,7 @@ const PHONE_COMMANDS = new Set([
|
|
|
23
23
|
"board.thread.read", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.auto", "board.thread.end",
|
|
24
24
|
"board.goal.list", "board.goal.get", "board.goal.stop", "board.room.read",
|
|
25
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",
|
|
26
|
+
"board.swarm.diff", "board.swarm.merge", "board.swarm.discard", "board.swarm.start_run", "board.swarm.agents", "board.swarm.note",
|
|
27
27
|
// The person's own running sessions, read and messaged as from the desktop app; never queued (a session may be gone).
|
|
28
28
|
"board.live", "board.live.read", "board.live.say",
|
|
29
29
|
]);
|
package/lib/runtime-install.mjs
CHANGED
|
@@ -76,7 +76,7 @@ export function installRuntime({ sourceRoot, version }) {
|
|
|
76
76
|
if (!fs.existsSync(source)) continue;
|
|
77
77
|
fs.cpSync(source, path.join(staging, entry), { recursive: true, force: true });
|
|
78
78
|
}
|
|
79
|
-
copyDependencyClosure(sourceRoot, staging, ["node-sqlite3-wasm", "ws", "onnxruntime-web", "@huggingface/tokenizers"]);
|
|
79
|
+
copyDependencyClosure(sourceRoot, staging, ["node-sqlite3-wasm", "ws", "onnxruntime-web", "@huggingface/tokenizers", "@clack/prompts", "@clack/core", "picocolors"]);
|
|
80
80
|
fs.writeFileSync(path.join(staging, ".ready"), version + "\n", { mode: 0o600 });
|
|
81
81
|
ensureDir(path.dirname(release));
|
|
82
82
|
fs.rmSync(release, { recursive: true, force: true });
|
package/lib/sealed.mjs
CHANGED
|
@@ -96,6 +96,15 @@ export async function openBound(key, bound, sealed) {
|
|
|
96
96
|
const plain = await subtle.decrypt({ name: "AES-GCM", iv: fromB64u(sealed.iv), additionalData: encoder.encode(`HSC1|${bound}`) }, key, fromB64u(sealed.ct));
|
|
97
97
|
return JSON.parse(decoder.decode(plain));
|
|
98
98
|
}
|
|
99
|
+
// A file's bytes, sealed as they are (no JSON or base64 inside): bound to its thread and file id like a message.
|
|
100
|
+
export async function sealBytes(key, bound, bytes) {
|
|
101
|
+
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
102
|
+
const ct = await subtle.encrypt({ name: "AES-GCM", iv, additionalData: encoder.encode(`HSC1|${bound}`) }, key, bytes);
|
|
103
|
+
return { iv: b64u(iv), ct: b64u(new Uint8Array(ct)) };
|
|
104
|
+
}
|
|
105
|
+
export async function openBytes(key, bound, sealed) {
|
|
106
|
+
return Buffer.from(await subtle.decrypt({ name: "AES-GCM", iv: fromB64u(sealed.iv), additionalData: encoder.encode(`HSC1|${bound}`) }, key, fromB64u(sealed.ct)));
|
|
107
|
+
}
|
|
99
108
|
// What two people read to each other to check they hold each other's key: SHA-256 of the key, 20 hex digits in groups of four.
|
|
100
109
|
export async function fingerprint(publicKey) {
|
|
101
110
|
const digest = Buffer.from(await subtle.digest("SHA-256", fromB64u(publicKey))).toString("hex");
|
package/lib/store.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import sqliteWasm from "node-sqlite3-wasm";
|
|
|
3
3
|
import { parseSession } from "./parsers.mjs";
|
|
4
4
|
import { provenanceFor } from "./provenance.mjs";
|
|
5
5
|
|
|
6
|
-
const SCHEMA_VERSION =
|
|
6
|
+
const SCHEMA_VERSION = 10;
|
|
7
7
|
const INDEX_FORMAT_VERSION = 2;
|
|
8
8
|
const { Database } = sqliteWasm;
|
|
9
9
|
|
|
@@ -185,6 +185,20 @@ export function openStore(file) {
|
|
|
185
185
|
version text not null,
|
|
186
186
|
vector blob not null
|
|
187
187
|
);
|
|
188
|
+
create table if not exists board_files (
|
|
189
|
+
id text primary key,
|
|
190
|
+
task_id text not null,
|
|
191
|
+
name text not null,
|
|
192
|
+
mime text not null,
|
|
193
|
+
bytes integer,
|
|
194
|
+
sha256 text,
|
|
195
|
+
author text not null,
|
|
196
|
+
cloud integer not null default 0,
|
|
197
|
+
remote_id text,
|
|
198
|
+
present integer not null default 0,
|
|
199
|
+
created_at text not null
|
|
200
|
+
);
|
|
201
|
+
create index if not exists board_files_task_idx on board_files(task_id, created_at);
|
|
188
202
|
`);
|
|
189
203
|
const sessionColumns = new Set(db.all("pragma table_info(sessions)").map((column) => column.name));
|
|
190
204
|
if (!sessionColumns.has("parser_version")) {
|
|
@@ -689,6 +703,46 @@ export function openStore(file) {
|
|
|
689
703
|
relayPeers() {
|
|
690
704
|
return execute("all", "select handle, public_key, pinned_at from relay_peers order by pinned_at desc");
|
|
691
705
|
},
|
|
706
|
+
// Files on a task. A file this machine has is present; one another person's computer is sending is named by its
|
|
707
|
+
// message first or arrives first, and is matched by the id that computer gave it (remote_id), never by ours.
|
|
708
|
+
addFile({ taskId, name, mime, bytes, sha256, author, cloud = false }) {
|
|
709
|
+
const id = randomUUID();
|
|
710
|
+
execute("run", `insert into board_files(id, task_id, name, mime, bytes, sha256, author, cloud, present, created_at) values(@id, @taskId, @name, @mime, @bytes, @sha256, @author, @cloud, 1, @now)`,
|
|
711
|
+
{ id, taskId, name, mime, bytes, sha256, author, cloud: cloud ? 1 : 0, now: new Date().toISOString() });
|
|
712
|
+
execute("run", "update board_tasks set updated_at = ?, version = version + 1 where id = ?", new Date().toISOString(), taskId);
|
|
713
|
+
return this.file(id);
|
|
714
|
+
},
|
|
715
|
+
file(id) {
|
|
716
|
+
const row = execute("get", "select * from board_files where id = ?", String(id));
|
|
717
|
+
return row ? { ...row, bytes: row.bytes == null ? null : Number(row.bytes), cloud: Number(row.cloud) === 1, present: Number(row.present) === 1 } : null;
|
|
718
|
+
},
|
|
719
|
+
taskFiles(taskId) {
|
|
720
|
+
return execute("all", "select id from board_files where task_id = ? order by created_at asc", taskId).map((row) => this.file(row.id));
|
|
721
|
+
},
|
|
722
|
+
// The other computer's message names a file it sends: kept with the hash it promised, which its bytes must match.
|
|
723
|
+
expectFile({ taskId, remoteId, name, mime, sha256, author }) {
|
|
724
|
+
const known = execute("get", "select id, sha256, present from board_files where task_id = ? and remote_id = ?", taskId, remoteId);
|
|
725
|
+
if (known && Number(known.present) === 1 && known.sha256 !== sha256) return { ok: false, error: "a file arrived with other bytes than its message promised" };
|
|
726
|
+
if (known) {
|
|
727
|
+
execute("run", "update board_files set name = ?, mime = ?, sha256 = ?, author = ? where id = ?", name, mime, sha256, author, known.id);
|
|
728
|
+
return { ok: true, file: this.file(known.id) };
|
|
729
|
+
}
|
|
730
|
+
const id = randomUUID();
|
|
731
|
+
execute("run", `insert into board_files(id, task_id, name, mime, sha256, author, remote_id, present, created_at) values(@id, @taskId, @name, @mime, @sha256, @author, @remoteId, 0, @now)`,
|
|
732
|
+
{ id, taskId, name, mime, sha256, author, remoteId, now: new Date().toISOString() });
|
|
733
|
+
return { ok: true, file: this.file(id) };
|
|
734
|
+
},
|
|
735
|
+
receiveFile({ taskId, remoteId, sha256, bytes, author }) {
|
|
736
|
+
const known = execute("get", "select id, sha256, present from board_files where task_id = ? and remote_id = ?", taskId, remoteId);
|
|
737
|
+
if (known && Number(known.present) === 1) return { ok: true, duplicate: true, file: this.file(known.id) };
|
|
738
|
+
if (known && known.sha256 && known.sha256 !== sha256) return { ok: false, error: "a file arrived with other bytes than its message promised" };
|
|
739
|
+
const id = known?.id || randomUUID();
|
|
740
|
+
if (known) execute("run", "update board_files set sha256 = ?, bytes = ?, present = 1 where id = ?", sha256, bytes, id);
|
|
741
|
+
else execute("run", `insert into board_files(id, task_id, name, mime, bytes, sha256, author, remote_id, present, created_at) values(@id, @taskId, 'file', 'application/octet-stream', @bytes, @sha256, @author, @remoteId, 1, @now)`,
|
|
742
|
+
{ id, taskId, bytes, sha256, author, remoteId, now: new Date().toISOString() });
|
|
743
|
+
execute("run", "update board_tasks set updated_at = ?, version = version + 1 where id = ?", new Date().toISOString(), taskId);
|
|
744
|
+
return { ok: true, file: this.file(id) };
|
|
745
|
+
},
|
|
692
746
|
getThread(taskId) {
|
|
693
747
|
const row = execute("get", "select * from board_threads where task_id = ?", taskId);
|
|
694
748
|
return row ? { ...row, auto_send: Number(row.auto_send) === 1 } : null;
|
|
@@ -713,11 +767,11 @@ export function openStore(file) {
|
|
|
713
767
|
this.addContribution({ id: `msg:${id}`, taskId, kind: "message", author: sender, text, source: `message:${id}` });
|
|
714
768
|
return { ok: true, message: this.threadMessages(taskId).find((message) => message.id === id) };
|
|
715
769
|
},
|
|
716
|
-
postMessage({ id, taskId, sender, text, refs = [], flagReason = null }) {
|
|
770
|
+
postMessage({ id, taskId, sender, text, refs = [], flagReason = null, hold = false }) {
|
|
717
771
|
const thread = this.getThread(taskId);
|
|
718
772
|
if (!thread) return { ok: false, error: "no thread on this task" };
|
|
719
773
|
if (thread.closed_at) return { ok: false, error: "the thread is closed" };
|
|
720
|
-
const state = flagReason ? "flagged" : thread.auto_send ? "sent" : "held";
|
|
774
|
+
const state = flagReason ? "flagged" : thread.auto_send && !hold ? "sent" : "held";
|
|
721
775
|
const now = new Date().toISOString();
|
|
722
776
|
execute("run", `insert into board_messages(id, task_id, seq, direction, sender, recipient, text, refs, state, flag_reason, created_at, sent_at)
|
|
723
777
|
values(@id, @taskId, @seq, 'out', @sender, @recipient, @text, @refs, @state, @flagReason, @now, @sentAt)`,
|
|
@@ -819,6 +873,14 @@ export function openStore(file) {
|
|
|
819
873
|
agentsSeen() {
|
|
820
874
|
return execute("all", "select agent, last_seen_at from board_agents order by last_seen_at desc");
|
|
821
875
|
},
|
|
876
|
+
// Each agent's work on the board, newest first, for its card (cards.mjs): what it holds and finished, and when it
|
|
877
|
+
// could start (the person's Start, else the post) and reported.
|
|
878
|
+
agentWork() {
|
|
879
|
+
return execute("all", `select coalesce(claimed_by, target_agent) as agent, status, target_agent is not null as targeted,
|
|
880
|
+
coalesce(run_authorized_at, created_at) as started_at, reported_at from board_tasks
|
|
881
|
+
where kind in ('task', 'wake') and removed_at is null and coalesce(claimed_by, target_agent) is not null
|
|
882
|
+
order by updated_at desc limit 5000`);
|
|
883
|
+
},
|
|
822
884
|
listTasks({ agent, status, all = false, limit = 20 } = {}) {
|
|
823
885
|
const clauses = ["b.removed_at is null"];
|
|
824
886
|
const params = { limit: Math.max(1, Math.min(100, Number(limit) || 20)) };
|
package/lib/swarm.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { readRunSteps } from "./runner.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { nameOf, resolveMember } from "./members.mjs";
|
|
6
|
+
import { setNote, swarmBlock, swarmCards, swarmDigest } from "./cards.mjs";
|
|
6
7
|
|
|
7
8
|
// The person's swarm as threads. A thread is a goal under the hood: its room is the transcript, its team the agents in
|
|
8
9
|
// it, its folder where coding agents work, and it runs on its own authority. A message the person sends wakes the
|
|
@@ -11,9 +12,6 @@ import { member, nameOf } from "./members.mjs";
|
|
|
11
12
|
|
|
12
13
|
const PROCESS_NAMES = { "claude-code": "claude", codex: "codex", pi: "pi", opencode: "opencode" };
|
|
13
14
|
const SAY_LIMIT = 4_000;
|
|
14
|
-
// How a member hears that work is waiting for it, by its roster wake.
|
|
15
|
-
const ANSWERS = { run: "starts as soon as it may", email: "woken by email when work is posted", webhook: "woken when work is posted", "local-webhook": "woken when work is posted",
|
|
16
|
-
schedule: "picks work up on its own schedule", none: "only when the person next talks to it" };
|
|
17
15
|
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
18
16
|
|
|
19
17
|
// The id an agent resumes a session by: the index keys sessions by their file name.
|
|
@@ -155,10 +153,11 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
155
153
|
const mine = request.teamOnly ? store.goals({ all: true }).filter((thread) => thread.goal.team.includes(agent)).map((thread) => thread.id) : null;
|
|
156
154
|
return { ok: true, threads: store.searchThreads(query, { goalIds: mine, k: 8 }), sessions: request.teamOnly ? [] : store.search({ query, limit: 5 }) };
|
|
157
155
|
}
|
|
158
|
-
// Who can be asked
|
|
156
|
+
// Who can be asked: every other agent's card (cards.mjs), and the same cards as the block an agent is told when its
|
|
157
|
+
// session starts. Open to every caller, so an agent picks a name instead of guessing one.
|
|
159
158
|
if (command === "board.swarm.agents") {
|
|
160
|
-
const
|
|
161
|
-
return { ok: true, agents
|
|
159
|
+
const agents = swarmCards(store, roster().filter((entry) => entry.id !== agent));
|
|
160
|
+
return { ok: true, agents, text: swarmBlock(agents), digest: swarmDigest(agents) };
|
|
162
161
|
}
|
|
163
162
|
// Any of the person's agents may start a thread and stop one it started. The thread carries the agent's trust, so
|
|
164
163
|
// nothing in it runs without the person's Start, and one started through Harness gets no coordinator rounds (they
|
|
@@ -186,6 +185,12 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
186
185
|
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
187
186
|
}
|
|
188
187
|
if (agent !== "user") return { ok: false, error: "only the person writes in, stops or resumes a thread they did not start; post in it with post_to_room" };
|
|
188
|
+
// The person's own note on one agent's card ("use it for reviews"), told to every other agent; no text clears it.
|
|
189
|
+
if (command === "board.swarm.note") {
|
|
190
|
+
const named = resolveMember(request.target);
|
|
191
|
+
if (!named.ok) return { ok: false, error: named.candidates.length ? `"${request.target}" could be ${named.candidates.join(" or ")}; name one` : `no agent called "${request.target}"` };
|
|
192
|
+
return { ok: true, agent: named.id, note: setNote(store, named.id, request.text) };
|
|
193
|
+
}
|
|
189
194
|
// The coordinator's record: what each round saw, chose and cost. The evidence for its limits.
|
|
190
195
|
if (command === "board.swarm.rounds") return { ok: true, rounds: store.rounds(request.id ? String(request.id) : null, request.limit) };
|
|
191
196
|
if (command === "board.swarm.coordinator") {
|