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/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
- export function nudgeText(agent, tasks, unread = [], goals = []) {
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
- export async function boardNudge(agent) {
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
- return queue ? nudgeText(agent, queue.tasks, queue.unread, queue.goals) : "";
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 last digest lives in the Harness home.
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 file = path.join(homeDir, `nudge-${agent}.json`);
61
- let last = null;
62
- try { last = JSON.parse(fs.readFileSync(file, "utf8")).digest; } catch {}
63
- if (digest === last) return "";
64
- try { fs.writeFileSync(file, JSON.stringify({ digest, at: new Date().toISOString() }), { mode: 0o600 }); } catch {}
65
- return digest ? nudgeText(agent, tasks, unread, goals) : (last ? "Harness task board: nothing waits for you now." : "");
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 readline from "node:readline";
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, 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.
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 MEASURED = "measured on a real Grok: about 23s by email, 17s by webhook";
12
+ const later = (agent) => `Later: ${pc.cyan(`npx tryharness agents setup ${agent}`)}`;
12
13
 
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 };
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, out = console.log, waitMs = 10 * 60_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
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) { 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).`);
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") { 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; }
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
- out(` ! No answer after ${Math.round(waitMs / 60_000)} minutes. Check that the agent is running, then: npx tryharness agents test ${agent}`);
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, prompter, daemonRequest, out }) {
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) { 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 });
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 (!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 });
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({ 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; }
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) { 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; }
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 (!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";
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) { out(" ! Harness did not send the setup message; update tryharness and try again."); return; }
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
- 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.");
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
- 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.");
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
- 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.");
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, 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;
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, prompter = process.stdin.isTTY ? createPrompter() : null, out = console.log, only = null, clipboard = copy }) {
166
+ export async function setupSwarm({ runtimeRoot, states = [], daemonRequest, ui = createUi(), only = null, clipboard = copy }) {
116
167
  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}`);
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
- 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.`);
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
- out(" Cloud agents: woken by email or webhook");
128
- await setupGrok({ prompter, daemonRequest, out, clipboard });
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) out(" Built in: Bankr and Quotient run on Harness, nothing to set up");
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
  ]);
@@ -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 = 9;
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 { member, nameOf } from "./members.mjs";
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, and how each answers: open to every caller, so an agent picks a name instead of guessing one.
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 seen = new Map(store.agentsSeen().map((row) => [row.agent, row.last_seen_at]));
161
- return { ok: true, agents: roster().filter((entry) => entry.id !== agent).map((entry) => ({ ...entry, name: nameOf(entry.id), answers: ANSWERS[member(entry.id)?.wake] ?? "when it next looks", lastSeen: seen.get(entry.id) ?? null })) };
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") {