tryharness 0.7.10 → 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/cli.mjs +51 -41
- package/lib/onboard.mjs +132 -82
- package/lib/runtime-install.mjs +1 -1
- package/lib/ui.mjs +111 -0
- package/package.json +7 -4
package/cli.mjs
CHANGED
|
@@ -5,7 +5,9 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { configureAgents, AGENTS, MCP_CLIENTS, detectMcpClients, mcpClientSnippet, removeAgentConfigurations } from "./lib/adapters.mjs";
|
|
8
|
-
import { setupSwarm, testAgent } from "./lib/onboard.mjs";
|
|
8
|
+
import { banner, setupSwarm, testAgent } from "./lib/onboard.mjs";
|
|
9
|
+
import { createUi } from "./lib/ui.mjs";
|
|
10
|
+
import pc from "picocolors";
|
|
9
11
|
import { removeLocalWakes } from "./lib/wakes.mjs";
|
|
10
12
|
import { detectAgents } from "./lib/commands.mjs";
|
|
11
13
|
import { daemonRequest } from "./lib/control-client.mjs";
|
|
@@ -66,7 +68,7 @@ async function validateConnection(config) {
|
|
|
66
68
|
} catch { return false; }
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
async function pair(pairOrigin, agents) {
|
|
71
|
+
async function pair(pairOrigin, agents, ui) {
|
|
70
72
|
const response = await fetch(pairOrigin + "/api/pair/start", {
|
|
71
73
|
method: "POST",
|
|
72
74
|
headers: { "content-type": "application/json" },
|
|
@@ -75,9 +77,9 @@ async function pair(pairOrigin, agents) {
|
|
|
75
77
|
});
|
|
76
78
|
if (!response.ok) throw new Error(`pairing unavailable (HTTP ${response.status})`);
|
|
77
79
|
const { pairId, code } = await response.json();
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
ui.note(`${pc.bold(code)}\n\n${pc.dim("In Harness: Settings → Connections → Connect a machine, then enter this code.")}`, "Pair this computer");
|
|
81
|
+
const spin = ui.spinner();
|
|
82
|
+
spin.start("Waiting for your approval (the code works for 10 minutes)");
|
|
81
83
|
return new Promise((resolve, reject) => {
|
|
82
84
|
fetch(pairOrigin + "/api/pair/wait/" + pairId, { headers: { accept: "text/event-stream" } }).then(async (waitResponse) => {
|
|
83
85
|
if (!waitResponse.ok || !waitResponse.body) throw new Error("pairing stream failed");
|
|
@@ -100,7 +102,7 @@ async function pair(pairOrigin, agents) {
|
|
|
100
102
|
}
|
|
101
103
|
reject(new Error("pairing stream ended without approval"));
|
|
102
104
|
}).catch(reject);
|
|
103
|
-
});
|
|
105
|
+
}).then((token) => { spin.stop("Paired with Harness"); return token; }, (error) => { spin.stop("Pairing did not finish", 2); throw error; });
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
async function waitForDaemon(timeoutMs = 12_000) {
|
|
@@ -126,72 +128,76 @@ async function connect() {
|
|
|
126
128
|
const requested = argv.flatMap((value, index) => value === "--agent" && argv[index + 1] ? [argv[index + 1]] : []);
|
|
127
129
|
for (const agent of requested) if (!AGENTS.includes(agent)) throw new Error(`unknown agent '${agent}'`);
|
|
128
130
|
const selected = requested.length ? installed.filter((agent) => requested.includes(agent)) : installed;
|
|
129
|
-
|
|
130
|
-
console.log(
|
|
131
|
+
const ui = createUi();
|
|
132
|
+
console.log(`\n${banner(VERSION)}\n`);
|
|
133
|
+
ui.intro(`${pc.bgCyan(pc.black(" tryharness "))} Setting up Harness on this computer`);
|
|
134
|
+
ui.step(installed.length ? `Found ${installed.join(", ")}` : "No supported coding agents found");
|
|
131
135
|
|
|
132
136
|
const pairOrigin = origin();
|
|
133
137
|
let config = readJson(paths.config, {});
|
|
134
138
|
const reusable = !has("--reconnect") && config.origin === pairOrigin && await validateConnection(config);
|
|
135
|
-
if (reusable)
|
|
139
|
+
if (reusable) ui.success(`Connected to Harness ${pc.dim(`· ${new URL(pairOrigin).host}`)}`);
|
|
136
140
|
else {
|
|
137
|
-
const token = await pair(pairOrigin, selected);
|
|
141
|
+
const token = await pair(pairOrigin, selected, ui);
|
|
138
142
|
config = { ...config, origin: pairOrigin, token };
|
|
139
143
|
writeJsonAtomic(paths.config, config);
|
|
140
|
-
console.log("pairing: approved");
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
// The persistent runtime is version-pinned; neither MCP clients nor the login service
|
|
144
147
|
// point into npm's temporary npx cache.
|
|
148
|
+
const spin = ui.spinner();
|
|
149
|
+
spin.start("Installing the Harness runtime and connecting your agents");
|
|
145
150
|
const runtimeRoot = installRuntime({ sourceRoot: SOURCE_ROOT, version: VERSION });
|
|
146
151
|
const states = configureAgents({ runtimeRoot, selected });
|
|
147
152
|
const stateMap = Object.fromEntries(states.map((state) => [state.agent, state]));
|
|
148
153
|
config = { ...readJson(paths.config, config), version: VERSION, runtimeRoot, agentStates: stateMap };
|
|
149
154
|
writeJsonAtomic(paths.config, config);
|
|
150
155
|
const service = installService({ runtimeRoot });
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
156
|
+
spin.message("Starting the daemon");
|
|
157
|
+
try { await waitForDaemon(); } catch (error) { spin.stop("The daemon did not connect to Harness", 2); throw error; }
|
|
158
|
+
spin.stop(`Daemon running ${pc.dim(`· ${service.manager}, connected to Harness`)}`);
|
|
159
|
+
await setupSwarm({ runtimeRoot, states, daemonRequest, ui });
|
|
154
160
|
try {
|
|
155
161
|
const devices = await daemonRequest({ command: "phone.list" });
|
|
156
162
|
if (devices.ok && !devices.phones.some((device) => device.kind === "browser" && !device.revoked_at)) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
const offer = await browserLink();
|
|
164
|
+
ui.info(offer.opened ? "Opened Harness in your browser to connect it to this computer end to end. The link works once, for 10 minutes."
|
|
165
|
+
: `Open this in the browser you use Harness in. It works once, for 10 minutes, and is the pairing secret:\n${offer.url}`);
|
|
166
|
+
} else if (devices.ok) ui.success("Browser connected end to end");
|
|
160
167
|
} catch (error) {
|
|
161
|
-
|
|
168
|
+
ui.warn(`Browser not connected (${error.message}). Run: npx tryharness link`);
|
|
162
169
|
}
|
|
163
|
-
if (!has("--no-desktop")) desktop();
|
|
170
|
+
if (!has("--no-desktop")) { const shown = desktop(); (shown.ok ? ui.success : ui.info)(shown.text); }
|
|
164
171
|
// The context space's embedder lives with the daemon and comes down with setup, so the machine
|
|
165
172
|
// can search a task by meaning before the first task exists. About 110 MB, once.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
173
|
+
if (modelPresent(paths.models)) ui.success("Embedder ready");
|
|
174
|
+
else {
|
|
175
|
+
const download = ui.spinner();
|
|
176
|
+
download.start("Downloading the embedder (about 110 MB, once)");
|
|
177
|
+
try { await ensureModel({ modelsDir: paths.models, log: (line) => download.message(`Downloading the embedder · ${line.slice(0, 30)}`) }); download.stop("Embedder ready"); }
|
|
178
|
+
catch (error) { download.stop(`Embedder not downloaded (${error.message}). Task search uses words until the next setup run`, 2); }
|
|
171
179
|
}
|
|
172
180
|
const failed = states.filter((state) => selected.includes(state.agent) && state.installed && !state.configured);
|
|
173
181
|
if (failed.length) {
|
|
174
182
|
throw new Error(`setup is incomplete for ${failed.map((state) => state.agent).join(", ")}. Nothing was hidden; run npx tryharness doctor after fixing the reported CLI error, then rerun setup.`);
|
|
175
183
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
ui.info("History indexes locally in the background");
|
|
185
|
+
ui.info(`Your coding agents start the tasks you ask for in folders they have worked in, up to 10 a day each, without a Start. Tasks another agent wrote wait for you.\n${pc.dim("Change it: npx tryharness board standing")}`);
|
|
186
|
+
ui.outro("Done. You can close this terminal.");
|
|
179
187
|
}
|
|
180
188
|
|
|
181
189
|
// The desktop app: installed from this machine's platform package and opened. Never fatal; the
|
|
182
190
|
// daemon works without it.
|
|
183
191
|
function desktop() {
|
|
184
|
-
if (!canShowDesktop({})) {
|
|
192
|
+
if (!canShowDesktop({})) return { ok: false, text: "Desktop app skipped (no screen on this login)" };
|
|
185
193
|
const source = desktopSource({ sourceRoot: SOURCE_ROOT });
|
|
186
|
-
if (!source) {
|
|
194
|
+
if (!source) return { ok: false, text: `No desktop app for ${process.platform} ${process.arch} yet` };
|
|
187
195
|
try {
|
|
188
196
|
const { target, updated } = installDesktop({ source, target: desktopTarget({ source }) });
|
|
189
197
|
openDesktop({ target });
|
|
190
|
-
|
|
191
|
-
return true;
|
|
198
|
+
return { ok: true, text: `Desktop app ${updated ? "installed and opened" : "opened"}. The Harness mark is at the top right of your screen; it starts when you log in.` };
|
|
192
199
|
} catch (error) {
|
|
193
|
-
|
|
194
|
-
return false;
|
|
200
|
+
return { ok: false, text: `Desktop app not opened (${error.message})` };
|
|
195
201
|
}
|
|
196
202
|
}
|
|
197
203
|
|
|
@@ -611,7 +617,10 @@ async function agents() {
|
|
|
611
617
|
console.log(response.note ? `Every agent now reads this on ${response.agent}'s card: ${response.note}` : `${response.agent}'s card has no note now.`);
|
|
612
618
|
return;
|
|
613
619
|
}
|
|
614
|
-
|
|
620
|
+
const ui = createUi(), only = sub === "setup" ? agent || null : null;
|
|
621
|
+
ui.intro(`${pc.bgCyan(pc.black(" tryharness "))} ${only ? `Setting up ${only}` : "Your swarm"}`);
|
|
622
|
+
await setupSwarm({ runtimeRoot: config.runtimeRoot, states: Object.values(config.agentStates || {}), daemonRequest, ui, only });
|
|
623
|
+
ui.outro("Done.");
|
|
615
624
|
}
|
|
616
625
|
|
|
617
626
|
async function phone() {
|
|
@@ -654,14 +663,15 @@ function openUrl(url) {
|
|
|
654
663
|
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
655
664
|
try { spawn(command, args, { detached: true, stdio: "ignore" }).on("error", () => {}).unref(); return true; } catch { return false; }
|
|
656
665
|
}
|
|
657
|
-
async function
|
|
666
|
+
async function browserLink() {
|
|
658
667
|
const response = await daemonRequest({ command: "phone.offer", kind: "browser" });
|
|
659
668
|
if (!response.ok) throw new Error(response.error || "the daemon could not make a link");
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
}
|
|
669
|
+
return { url: response.url, opened: !has("--print") && openUrl(response.url) };
|
|
670
|
+
}
|
|
671
|
+
async function link() {
|
|
672
|
+
const { url, opened } = await browserLink();
|
|
673
|
+
console.log(opened ? "Opened Harness in your browser to connect it to this computer end to end. The link works once, for 10 minutes." : `Open this in the browser you use Harness in. It works once, for 10 minutes, and is the pairing secret:\n\n${url}`);
|
|
674
|
+
console.log("Harness then carries what your browser and this computer say to each other sealed, and cannot read it.");
|
|
665
675
|
}
|
|
666
676
|
|
|
667
677
|
// Approving Harness in T3 Code (lib/t3.mjs): then what you send your T3 Code sessions from Harness, in the desktop app or
|
|
@@ -711,7 +721,7 @@ try {
|
|
|
711
721
|
else if (command === "t3") await t3();
|
|
712
722
|
else if (command === "peers") await peers();
|
|
713
723
|
else if (command === "connect") await connectAgent();
|
|
714
|
-
else if (command === "desktop") {
|
|
724
|
+
else if (command === "desktop") { const shown = desktop(); console.log(shown.text); if (!shown.ok) process.exitCode = 1; }
|
|
715
725
|
else if (command === "logs") logs();
|
|
716
726
|
else if (command === "disconnect") await disconnect();
|
|
717
727
|
else if (command === "daemon") { const { startDaemon } = await import("./lib/daemon.mjs"); await startDaemon(); }
|
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/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/ui.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { TextPrompt } from "@clack/core";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
|
|
5
|
+
// Setup's terminal interface, in the style of @clack/prompts (the skills installer's): a rail of steps, arrow-key
|
|
6
|
+
// menus, spinners while waiting. createUi draws it; scriptedUi answers from a list and keeps what a person would have
|
|
7
|
+
// seen, for tests. Prompts return null when the person cancels (Ctrl-C), so setup can skip that part and go on.
|
|
8
|
+
|
|
9
|
+
const unicode = process.platform !== "win32" || Boolean(process.env.WT_SESSION) || process.env.TERM_PROGRAM === "vscode";
|
|
10
|
+
const glyph = (fancy, plain) => (unicode ? fancy : plain);
|
|
11
|
+
const BAR = glyph("│", "|"), END = glyph("└", "—"), ACTIVE = glyph("◆", "*"), DONE = glyph("◇", "o"), STOP = glyph("■", "x"), WARN = glyph("▲", "!");
|
|
12
|
+
|
|
13
|
+
const visible = (text) => text.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
14
|
+
|
|
15
|
+
/** Lines of text wrapped to a width, each keeping its own indent. */
|
|
16
|
+
export function wrap(text, width) {
|
|
17
|
+
return text.split("\n").flatMap((line) => {
|
|
18
|
+
if (!line.trim()) return [""];
|
|
19
|
+
const indent = " ".repeat(line.length - line.trimStart().length), room = Math.max(20, width - indent.length), rows = [];
|
|
20
|
+
let row = "";
|
|
21
|
+
for (const word of line.trim().split(" ")) {
|
|
22
|
+
if (row && visible(row) + 1 + visible(word) > room) { rows.push(row); row = word; } else row = row ? `${row} ${word}` : word;
|
|
23
|
+
}
|
|
24
|
+
return [...rows, row].map((part) => `${indent}${part}`);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const columns = () => process.stdout.columns || 100;
|
|
29
|
+
|
|
30
|
+
// A text prompt Harness can close: shown while something else may finish first (Grok registering itself). `until`
|
|
31
|
+
// resolves to { ok, line }; the prompt then closes with that line. Returns { value } when the person types and presses
|
|
32
|
+
// Enter, { done } when `until` won, or { cancelled: true }.
|
|
33
|
+
async function waitOrType({ message, placeholder, until }) {
|
|
34
|
+
const stop = new AbortController();
|
|
35
|
+
let done = null;
|
|
36
|
+
void until.then((result) => { done = result; stop.abort(); });
|
|
37
|
+
const value = await new TextPrompt({
|
|
38
|
+
placeholder,
|
|
39
|
+
signal: stop.signal,
|
|
40
|
+
render() {
|
|
41
|
+
const head = (symbol) => `${pc.gray(BAR)}\n${symbol} ${message}\n`;
|
|
42
|
+
if (this.state === "submit") return `${head(pc.green(DONE))}${pc.gray(BAR)} ${pc.dim(this.value || "keep waiting")}`;
|
|
43
|
+
if (this.state === "cancel" && done) return `${pc.gray(BAR)}\n${done.ok ? pc.green(DONE) : pc.yellow(WARN)} ${done.line}`;
|
|
44
|
+
if (this.state === "cancel") return `${head(pc.red(STOP))}${pc.gray(BAR)} ${pc.dim("stopped")}`;
|
|
45
|
+
const shown = this.value ? this.valueWithCursor : `${pc.inverse(placeholder[0])}${pc.dim(placeholder.slice(1))}`;
|
|
46
|
+
return `${head(pc.cyan(ACTIVE))}${pc.cyan(BAR)} ${shown}\n${pc.cyan(END)}\n`;
|
|
47
|
+
},
|
|
48
|
+
}).prompt();
|
|
49
|
+
if (done) return { done };
|
|
50
|
+
if (p.isCancel(value)) return { cancelled: true };
|
|
51
|
+
return { value: String(value ?? "").trim() };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createUi({ interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) } = {}) {
|
|
55
|
+
const answer = (value) => (p.isCancel(value) ? null : value);
|
|
56
|
+
// clack does not wrap; a long line would run back to the left edge, off the rail.
|
|
57
|
+
const fit = (text) => wrap(text, columns() - 4).join("\n");
|
|
58
|
+
return {
|
|
59
|
+
interactive,
|
|
60
|
+
intro: (title) => p.intro(title),
|
|
61
|
+
outro: (text) => p.outro(text),
|
|
62
|
+
step: (text) => p.log.step(fit(text)),
|
|
63
|
+
success: (text) => p.log.success(fit(text)),
|
|
64
|
+
info: (text) => p.log.info(fit(text)),
|
|
65
|
+
warn: (text) => p.log.warn(fit(text)),
|
|
66
|
+
message: (text) => p.log.message(fit(text)),
|
|
67
|
+
note: (text, title) => p.note(text, title),
|
|
68
|
+
select: async (opts) => answer(await p.select(opts)),
|
|
69
|
+
text: async (opts) => answer(await p.text(opts)),
|
|
70
|
+
password: async (opts) => answer(await p.password(opts)),
|
|
71
|
+
confirm: async (opts) => answer(await p.confirm(opts)),
|
|
72
|
+
// clack's spinner stops on Ctrl-C but keeps the process alive; setup ends there instead. Without a terminal (piped
|
|
73
|
+
// or logged) it is two plain lines, not animation frames.
|
|
74
|
+
spinner: () => {
|
|
75
|
+
if (!process.stdout.isTTY) return { start: (text) => p.log.message(fit(text)), message: () => {}, stop: (text, code) => (code ? p.log.warn : p.log.step)(fit(text)) };
|
|
76
|
+
const spin = p.spinner(), quit = () => process.exit(130);
|
|
77
|
+
return {
|
|
78
|
+
start: (text) => { spin.start(text); process.once("SIGINT", quit); },
|
|
79
|
+
message: (text) => spin.message(text),
|
|
80
|
+
stop: (text, code) => { process.removeListener("SIGINT", quit); spin.stop(text, code); },
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
waitOrType,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The same interface answered from `answers` in order (undefined takes the default); `lines` is what was shown. */
|
|
88
|
+
export function scriptedUi(answers = [], lines = []) {
|
|
89
|
+
const say = (symbol) => (text) => { lines.push(...`${symbol} ${text}`.split("\n")); };
|
|
90
|
+
const next = (fallback) => { const value = answers.shift(); return value === undefined ? fallback : value; };
|
|
91
|
+
const ask = async (opts) => { lines.push(`? ${opts.message}`); const value = next(null); if (value !== null) lines.push(` ${value}`); return value; };
|
|
92
|
+
return {
|
|
93
|
+
interactive: true,
|
|
94
|
+
lines,
|
|
95
|
+
intro: say("┌"), outro: say("└"), step: say("◇"), success: say("◆"), info: say("●"), warn: say("▲"), message: say("│"),
|
|
96
|
+
note: (text, title) => { lines.push(`[${title}]`, ...text.split("\n")); },
|
|
97
|
+
select: async (opts) => { lines.push(`? ${opts.message}`, ...opts.options.map((option) => ` ${option.label}${option.hint ? ` (${option.hint})` : ""}`)); return next(opts.initialValue ?? opts.options[0].value); },
|
|
98
|
+
text: ask,
|
|
99
|
+
password: async (opts) => { lines.push(`? ${opts.message}`); return next(null); },
|
|
100
|
+
confirm: async (opts) => { lines.push(`? ${opts.message}`); return next(opts.initialValue ?? true); },
|
|
101
|
+
spinner: () => ({ start: say("◒"), message: () => {}, stop: (text, code = 0) => say(code ? "▲" : "◇")(text) }),
|
|
102
|
+
waitOrType: async ({ message, until }) => {
|
|
103
|
+
lines.push(`? ${message}`);
|
|
104
|
+
const value = next("");
|
|
105
|
+
if (value) return { value };
|
|
106
|
+
const done = await until;
|
|
107
|
+
lines.push(`${done.ok ? "◇" : "▲"} ${done.line}`);
|
|
108
|
+
return { done };
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tryharness",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.13",
|
|
4
4
|
"description": "Harness Relay: the daemon that turns the agents on this machine into your personal swarm. Memory, history, and work handed between any of them.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tryharness": "cli.mjs"
|
|
@@ -29,11 +29,14 @@
|
|
|
29
29
|
"homepage": "https://tryharness.ai",
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"dependencies": {
|
|
32
|
+
"@clack/core": "0.5.0",
|
|
33
|
+
"@clack/prompts": "0.11.0",
|
|
34
|
+
"@huggingface/tokenizers": "^0.1.3",
|
|
32
35
|
"node-sqlite3-wasm": "0.8.60",
|
|
36
|
+
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
|
|
37
|
+
"picocolors": "^1.1.1",
|
|
33
38
|
"qr": "0.4.2",
|
|
34
|
-
"ws": "^8.18.3"
|
|
35
|
-
"@huggingface/tokenizers": "^0.1.3",
|
|
36
|
-
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
|
|
39
|
+
"ws": "^8.18.3"
|
|
37
40
|
},
|
|
38
41
|
"optionalDependencies": {
|
|
39
42
|
"tryharness-desktop-darwin-arm64": "0.7.10",
|