tryharness 0.7.10 → 0.7.16
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 +61 -46
- package/lib/daemon.mjs +51 -11
- package/lib/desktop.mjs +68 -4
- package/lib/live.mjs +8 -3
- package/lib/onboard.mjs +132 -82
- package/lib/places.mjs +48 -2
- package/lib/runner.mjs +9 -0
- package/lib/runtime-install.mjs +1 -1
- package/lib/t3.mjs +96 -4
- package/lib/ui.mjs +111 -0
- package/lib/worktrees.mjs +24 -2
- package/package.json +9 -6
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";
|
|
@@ -14,7 +16,7 @@ import { paths } from "./lib/paths.mjs";
|
|
|
14
16
|
import { currentRuntime, installRuntime } from "./lib/runtime-install.mjs";
|
|
15
17
|
import { ensureModel, modelPresent } from "./lib/embedder.mjs";
|
|
16
18
|
import { installService, serviceInfo, uninstallService } from "./lib/service.mjs";
|
|
17
|
-
import { canShowDesktop,
|
|
19
|
+
import { canShowDesktop, openDesktop, retireDesktop, setupDesktop } from "./lib/desktop.mjs";
|
|
18
20
|
|
|
19
21
|
const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
20
22
|
const PACKAGE = readJson(path.join(SOURCE_ROOT, "package.json"), { version: "0.4.3" });
|
|
@@ -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,81 @@ 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`);
|
|
169
|
+
}
|
|
170
|
+
if (!has("--no-desktop")) {
|
|
171
|
+
const spin = ui.spinner();
|
|
172
|
+
spin.start("Getting the newest desktop app");
|
|
173
|
+
const shown = await desktop();
|
|
174
|
+
spin.stop(shown.text, shown.ok ? 0 : 2);
|
|
162
175
|
}
|
|
163
|
-
if (!has("--no-desktop")) desktop();
|
|
164
176
|
// The context space's embedder lives with the daemon and comes down with setup, so the machine
|
|
165
177
|
// can search a task by meaning before the first task exists. About 110 MB, once.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
178
|
+
if (modelPresent(paths.models)) ui.success("Embedder ready");
|
|
179
|
+
else {
|
|
180
|
+
const download = ui.spinner();
|
|
181
|
+
download.start("Downloading the embedder (about 110 MB, once)");
|
|
182
|
+
try { await ensureModel({ modelsDir: paths.models, log: (line) => download.message(`Downloading the embedder · ${line.slice(0, 30)}`) }); download.stop("Embedder ready"); }
|
|
183
|
+
catch (error) { download.stop(`Embedder not downloaded (${error.message}). Task search uses words until the next setup run`, 2); }
|
|
171
184
|
}
|
|
172
185
|
const failed = states.filter((state) => selected.includes(state.agent) && state.installed && !state.configured);
|
|
173
186
|
if (failed.length) {
|
|
174
187
|
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
188
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
189
|
+
ui.info("History indexes locally in the background");
|
|
190
|
+
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")}`);
|
|
191
|
+
ui.outro("Done. You can close this terminal.");
|
|
179
192
|
}
|
|
180
193
|
|
|
181
|
-
// The desktop app:
|
|
194
|
+
// The desktop app: the newest build for this machine, installed and opened. Never fatal; the
|
|
182
195
|
// daemon works without it.
|
|
183
|
-
function desktop() {
|
|
184
|
-
if (!canShowDesktop({})) {
|
|
185
|
-
const source = desktopSource({ sourceRoot: SOURCE_ROOT });
|
|
186
|
-
if (!source) { console.log(`desktop: no app for ${process.platform} ${process.arch} yet`); return false; }
|
|
196
|
+
async function desktop() {
|
|
197
|
+
if (!canShowDesktop({})) return { ok: false, text: "Desktop app skipped (no screen on this login)" };
|
|
187
198
|
try {
|
|
188
|
-
const
|
|
199
|
+
const installed = await setupDesktop({ sourceRoot: SOURCE_ROOT });
|
|
200
|
+
if (!installed) return { ok: false, text: `No desktop app for ${process.platform} ${process.arch} yet` };
|
|
201
|
+
const { target, updated } = installed;
|
|
189
202
|
openDesktop({ target });
|
|
190
|
-
|
|
191
|
-
return true;
|
|
203
|
+
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
204
|
} catch (error) {
|
|
193
|
-
|
|
194
|
-
return false;
|
|
205
|
+
return { ok: false, text: `Desktop app not opened (${error.message})` };
|
|
195
206
|
}
|
|
196
207
|
}
|
|
197
208
|
|
|
@@ -611,7 +622,10 @@ async function agents() {
|
|
|
611
622
|
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
623
|
return;
|
|
613
624
|
}
|
|
614
|
-
|
|
625
|
+
const ui = createUi(), only = sub === "setup" ? agent || null : null;
|
|
626
|
+
ui.intro(`${pc.bgCyan(pc.black(" tryharness "))} ${only ? `Setting up ${only}` : "Your swarm"}`);
|
|
627
|
+
await setupSwarm({ runtimeRoot: config.runtimeRoot, states: Object.values(config.agentStates || {}), daemonRequest, ui, only });
|
|
628
|
+
ui.outro("Done.");
|
|
615
629
|
}
|
|
616
630
|
|
|
617
631
|
async function phone() {
|
|
@@ -654,14 +668,15 @@ function openUrl(url) {
|
|
|
654
668
|
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
655
669
|
try { spawn(command, args, { detached: true, stdio: "ignore" }).on("error", () => {}).unref(); return true; } catch { return false; }
|
|
656
670
|
}
|
|
657
|
-
async function
|
|
671
|
+
async function browserLink() {
|
|
658
672
|
const response = await daemonRequest({ command: "phone.offer", kind: "browser" });
|
|
659
673
|
if (!response.ok) throw new Error(response.error || "the daemon could not make a link");
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
}
|
|
674
|
+
return { url: response.url, opened: !has("--print") && openUrl(response.url) };
|
|
675
|
+
}
|
|
676
|
+
async function link() {
|
|
677
|
+
const { url, opened } = await browserLink();
|
|
678
|
+
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}`);
|
|
679
|
+
console.log("Harness then carries what your browser and this computer say to each other sealed, and cannot read it.");
|
|
665
680
|
}
|
|
666
681
|
|
|
667
682
|
// 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 +726,7 @@ try {
|
|
|
711
726
|
else if (command === "t3") await t3();
|
|
712
727
|
else if (command === "peers") await peers();
|
|
713
728
|
else if (command === "connect") await connectAgent();
|
|
714
|
-
else if (command === "desktop") {
|
|
729
|
+
else if (command === "desktop") { const shown = await desktop(); console.log(shown.text); if (!shown.ok) process.exitCode = 1; }
|
|
715
730
|
else if (command === "logs") logs();
|
|
716
731
|
else if (command === "disconnect") await disconnect();
|
|
717
732
|
else if (command === "daemon") { const { startDaemon } = await import("./lib/daemon.mjs"); await startDaemon(); }
|
package/lib/daemon.mjs
CHANGED
|
@@ -28,10 +28,10 @@ import { fingerprint, openBound, openBytes, relayKey, sealBound, sealBytes } fro
|
|
|
28
28
|
import { claudeMemNotes } from "./claudemem.mjs";
|
|
29
29
|
import { machineActivity } from "./activity.mjs";
|
|
30
30
|
import { LIVE_AGENTS, focusTerminal, hostApp, liveSessions, processTable, sessionFeed, sessionFile, sessionOfProcess, workingDirectories } from "./live.mjs";
|
|
31
|
-
import { EDITOR_IDS, agentCommand, editorFolder, editorInstall, editorName, editorTerminalStarts, openEditorFolder, panelLink, placeEnv, placeFrom, placeKey, processEnv, resumeLine, t3App, t3Link, t3OpensLinks, t3Runs, t3Thread } from "./places.mjs";
|
|
31
|
+
import { EDITOR_IDS, agentCommand, editorFolder, editorInstall, editorName, editorTerminalStarts, openEditorFolder, panelLink, placeEnv, placeFrom, placeKey, processEnv, resumeLine, t3App, t3Link, t3Model, t3Open, t3OpensLinks, t3Project, t3Provider, t3Runs, t3Thread } from "./places.mjs";
|
|
32
32
|
import { detectAgentApps, detectMcpClients } from "./adapters.mjs";
|
|
33
33
|
import { cardLine, swarmBlock, swarmCards, swarmDigest } from "./cards.mjs";
|
|
34
|
-
import { forgetT3, sendToT3, startT3SignIn, t3Access } from "./t3.mjs";
|
|
34
|
+
import { forgetT3, handedToT3, runInT3, sendToT3, startT3SignIn, t3Access } from "./t3.mjs";
|
|
35
35
|
|
|
36
36
|
// What may be referenced on a thread with another person (the server's rule, src/server/relay/refs.ts, kept
|
|
37
37
|
// here since a sealed request is opened only on the machines).
|
|
@@ -599,6 +599,31 @@ export async function startDaemon() {
|
|
|
599
599
|
return !root || Boolean(store.getTask(root)?.run_authorized_at);
|
|
600
600
|
};
|
|
601
601
|
const roomDigest = (goalId, limit = 30) => store.roomPosts(goalId).slice(-limit).map((post) => `[${post.seq}] ${post.author}${post.kind !== "post" ? ` (${post.kind})` : ""}: ${post.text.slice(0, 1_200)}`).join("\n");
|
|
602
|
+
// A thread's coding agent works in T3 Code when the person uses it there: its home is T3, Harness may send into T3,
|
|
603
|
+
// T3 is open with a project for the thread's folder, and the agent has run in T3 before (its model there is the
|
|
604
|
+
// person's last choice). A thread already in Harness's own copy, or carrying on a session in place, stays there; one
|
|
605
|
+
// in T3 stays in T3, and a turn T3 can't take then fails saying why. Reviews keep the agent's read-only switch here.
|
|
606
|
+
const inT3 = (thread, agent, review) => {
|
|
607
|
+
if (review || !t3Provider(agent) || !thread.goal.workspace) return false;
|
|
608
|
+
const tree = worktrees.get(thread.id)?.state ?? null;
|
|
609
|
+
if (tree === "t3") return true;
|
|
610
|
+
return tree === null && t3Access()?.state === "send" && homeOf(agent)?.kind === "t3" && t3Open() && Boolean(t3Project(thread.goal.workspace)) && Boolean(t3Model(agent));
|
|
611
|
+
};
|
|
612
|
+
// A turn T3 carries out (t3.mjs runInT3): into the agent's T3 thread in this thread, else a new one in T3's project
|
|
613
|
+
// for the folder, on the thread's T3 checkout. The thread works in T3 from its first launch.
|
|
614
|
+
const t3Turn = (task, agent) => {
|
|
615
|
+
const thread = store.getTask(task.parent_id);
|
|
616
|
+
const key = `t3-thread:${thread.id}:${agent}`;
|
|
617
|
+
const model = t3Model(agent);
|
|
618
|
+
const project = t3Project(thread.goal.workspace);
|
|
619
|
+
return {
|
|
620
|
+
threadId: store.meta(key) || null,
|
|
621
|
+
title: thread.objective.slice(0, 120),
|
|
622
|
+
launch: { ...(project ? { projectId: project.id } : {}), ...(model ? { modelSelection: model } : {}), workspaceStrategy: worktrees.t3Workspace(thread) },
|
|
623
|
+
onThread: (threadId) => { store.meta(key, threadId); worktrees.inT3(thread.id); },
|
|
624
|
+
onWorktree: (where, branch) => worktrees.inT3(thread.id, where, branch),
|
|
625
|
+
};
|
|
626
|
+
};
|
|
602
627
|
// Wake one teammate. A coding agent here gets a run in the goal's folder that resumes its last session on this goal;
|
|
603
628
|
// Harness and agents that cannot be started here learn of it through the board change and their own reads.
|
|
604
629
|
const pendingWakes = new Map();
|
|
@@ -618,10 +643,13 @@ export async function startDaemon() {
|
|
|
618
643
|
if (store.openWake(goalId, agent)) { pendingWakes.set(`${goalId}:${agent}`, { reason, byPerson: byPerson || Boolean(pendingWakes.get(`${goalId}:${agent}`)?.byPerson) }); return; }
|
|
619
644
|
if (store.wakeCount(goalId, agent) >= goal.max_wakes) { pauseGoal(goalId, `${agent} was woken ${goal.max_wakes} times`); return; }
|
|
620
645
|
const brief = swarmBrief(task, agent, reason, { review });
|
|
621
|
-
// A coding thread in a repository works in its own worktree, so threads never share a checkout
|
|
622
|
-
|
|
623
|
-
|
|
646
|
+
// A coding thread in a repository works in its own worktree, so threads never share a checkout; one whose agents the
|
|
647
|
+
// person uses in T3 Code works in T3 (inT3), where T3 makes and keeps the checkout.
|
|
648
|
+
const t3 = inT3(task, agent, review);
|
|
649
|
+
let folder = goal.workspace;
|
|
650
|
+
if (!t3) try { folder = worktrees.folderFor(task); } catch (error) { store.roomPost({ goalId, author: "harness", kind: "note", text: `${agent} cannot start: ${error.message}` }); notifyBoard(store.getTask(goalId)); return; }
|
|
624
651
|
const wake = store.postTask({ objective: `Room: ${task.objective}`.slice(0, 500), brief, originAgent: "harness", provenance: { trust: "agent", chain: ["room"] }, targetAgent: agent, parentId: goalId, runWorkspace: folder, kind: "wake" });
|
|
652
|
+
if (t3) store.meta(`t3-run:${wake.id}`, "1");
|
|
625
653
|
// A review runs with the agent's own read-only switch (runner.mjs); only Claude Code and Codex have one.
|
|
626
654
|
if (review) store.meta(`review:${wake.id}`, "1");
|
|
627
655
|
if (threadMayRun(task)) {
|
|
@@ -925,7 +953,10 @@ export async function startDaemon() {
|
|
|
925
953
|
return title && !title.startsWith("<") ? title : null;
|
|
926
954
|
};
|
|
927
955
|
const linksOk = t3LinksOk();
|
|
956
|
+
const handed = handedToT3();
|
|
928
957
|
const sessions = liveSessions({ exclude: new Set(runningPids()), recent })
|
|
958
|
+
// A T3 thread running a turn Harness handed it is Harness's run, shown in its thread.
|
|
959
|
+
.filter((session) => !handed.has(session.t3Thread))
|
|
929
960
|
// A T3 Code that cannot open links is brought forward instead, and messages reach its sessions through their hooks.
|
|
930
961
|
.map((session) => ({ ...session, t3: linksOk ? session.t3 : null, title: titleOf(session), waiting: said(session.agent, session.sessionId).length }));
|
|
931
962
|
// A running session no hook placed (it started before Harness was set up, or its agent has none): read from its process.
|
|
@@ -1046,6 +1077,11 @@ export async function startDaemon() {
|
|
|
1046
1077
|
const openPlan = (agentId, sessionId, folder, act) => {
|
|
1047
1078
|
const live = sessionId ? liveNow().find((row) => row.agent === agentId && row.sessionId === sessionId) ?? null : null;
|
|
1048
1079
|
if (live) return runningPlan(live, act);
|
|
1080
|
+
// A T3 Code thread itself (a turn Harness handed to T3) opens there, wherever the agent usually works.
|
|
1081
|
+
if (sessionId && t3Thread(sessionId) === sessionId) {
|
|
1082
|
+
const plan = placePlan(agentId, usable({ kind: "t3", name: "T3 Code" }), sessionId, folder, act);
|
|
1083
|
+
if (plan) return plan;
|
|
1084
|
+
}
|
|
1049
1085
|
const own = sessionId ? placesOf(agentId)[sessionId] ?? null : null;
|
|
1050
1086
|
const where = folder ?? own?.folder ?? null;
|
|
1051
1087
|
if (own?.reopen?.link) return { opensIn: short(own.name) || nameOf(agentId), link: own.reopen.link };
|
|
@@ -1407,6 +1443,8 @@ export async function startDaemon() {
|
|
|
1407
1443
|
const text = `${defaults.map((rule) => rule.agent).join(", ")} now ${one ? "starts" : "start"} the tasks you ask for in folders ${one ? "it has" : "they have"} worked in, up to ${defaults[0].daily_cap} a day${one ? "" : " each"}, without a Start. Change it: npx tryharness board standing`;
|
|
1408
1444
|
log(text); notifyDesktop(text); store.meta("standing_announced", new Date().toISOString());
|
|
1409
1445
|
}
|
|
1446
|
+
// Where a run works, one at a time: its folder, or for a turn T3 Code carries out, its thread (T3 keeps the checkout).
|
|
1447
|
+
const slotOf = (task) => (store.meta(`t3-run:${task.id}`) === "1" ? `t3:${task.parent_id}` : task.run_workspace ? path.resolve(task.run_workspace) : task.target_agent);
|
|
1410
1448
|
// Lid-Closed Mode holds new runs while the Mac runs warm with the lid closed; running ones go on.
|
|
1411
1449
|
let runsHeld = false;
|
|
1412
1450
|
const runBoard = () => {
|
|
@@ -1414,7 +1452,7 @@ export async function startDaemon() {
|
|
|
1414
1452
|
// One run per folder at a time: each thread's worktree is its own folder, so threads run side by side.
|
|
1415
1453
|
for (const task of store.runnableTasks(installedAgents)) {
|
|
1416
1454
|
const agent = task.target_agent;
|
|
1417
|
-
const slot =
|
|
1455
|
+
const slot = slotOf(task);
|
|
1418
1456
|
if (activeRuns.has(slot)) continue;
|
|
1419
1457
|
const claimed = store.claimTask(task.id, agent, task.version, null);
|
|
1420
1458
|
if (!claimed.ok) continue;
|
|
@@ -1451,15 +1489,16 @@ export async function startDaemon() {
|
|
|
1451
1489
|
const readOnly = store.meta(`review:${task.id}`) === "1";
|
|
1452
1490
|
// Another agent's words on a goal's authority, not the person's tap: restricted (docs/member-runs.md).
|
|
1453
1491
|
const restricted = task.trust !== "user" && task.run_authorized_by === "goal" && store.meta(`person-wake:${task.id}`) !== "1";
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1492
|
+
const t3 = wake && store.meta(`t3-run:${task.id}`) === "1" ? t3Turn(task, agent) : null;
|
|
1493
|
+
startTask({ runId: task.id, agent, title: task.objective.slice(0, 120), brief: resumeSession && again && !wake ? again : [again, brief].filter(Boolean).join("\n\n"), cwd: task.run_workspace, ...(stoppedFor ? { again: true } : {}), ...(resumeSession && !t3 ? { resumeSession } : {}), ...(readOnly ? { readOnly } : {}), ...(restricted ? { restricted } : {}), ...(t3 ? { t3 } : {}) }, (kind, text, extra) => {
|
|
1494
|
+
// The agent's session in a thread is resumed when the thread calls on it again; in T3 it is the agent's T3 thread.
|
|
1495
|
+
if (kind === "session" && text) { store.setRunSession(task.id, text); if (!t3 && (wake || room?.kind === "goal")) store.setGoalSession(task.parent_id, agent, text); notifyBoard(store.getTask(task.id)); }
|
|
1457
1496
|
// A step the agent took: whoever has the thread open reads it again, at most once a second per run.
|
|
1458
1497
|
if (kind === "progress" && Date.now() - (lastStepNotice.get(task.id) ?? 0) >= 1_000) { lastStepNotice.set(task.id, Date.now()); notifyBoard(store.getTask(task.id)); }
|
|
1459
1498
|
if (kind === "accepted" && stoppedFor) store.meta(`requeued:${task.id}`, "");
|
|
1460
1499
|
if (kind === "done" || kind === "failed") {
|
|
1461
1500
|
lastStepNotice.delete(task.id);
|
|
1462
|
-
activeRuns.delete(
|
|
1501
|
+
activeRuns.delete(slotOf(task));
|
|
1463
1502
|
const reason = interrupting.get(task.id);
|
|
1464
1503
|
interrupting.delete(task.id);
|
|
1465
1504
|
if (kind === "failed" && reason && store.requeueRun(task.id).ok) {
|
|
@@ -1820,7 +1859,8 @@ export async function startDaemon() {
|
|
|
1820
1859
|
const observe = (stream, raw) => {
|
|
1821
1860
|
try { fs.appendFileSync(files.log, stream === "stderr" ? `\n[stderr] ${raw}` : raw, { mode: 0o600 }); } catch {}
|
|
1822
1861
|
};
|
|
1823
|
-
|
|
1862
|
+
if (message.t3) runInT3({ runId: message.runId, brief: message.brief, cwd: message.cwd, ...message.t3 }, emit, observe);
|
|
1863
|
+
else runAgentTask(message, emit, observe);
|
|
1824
1864
|
};
|
|
1825
1865
|
// Drops the connection and dials again in about a second, whatever the backoff had grown to.
|
|
1826
1866
|
const reconnectNow = (reason) => {
|
package/lib/desktop.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -7,12 +8,75 @@ import { run } from "./commands.mjs";
|
|
|
7
8
|
import { ensureDir, readJson, writeJsonAtomic } from "./files.mjs";
|
|
8
9
|
import { paths } from "./paths.mjs";
|
|
9
10
|
|
|
10
|
-
// The desktop app ships as one
|
|
11
|
-
// so `npx tryharness` brings the build for this machine and nothing else.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// The desktop app ships as one npm package per platform (tryharness-desktop-<os>-<cpu>), an optional
|
|
12
|
+
// dependency of tryharness, so `npx tryharness` brings the build for this machine and nothing else.
|
|
13
|
+
// Setup installs the newest build there is: npx may run a tryharness it cached before a newer app was
|
|
14
|
+
// published, so setup also asks npm, and never puts an older build over a newer one. It copies the app
|
|
15
|
+
// to a place it keeps, opens it, and the app registers itself to start at login from there.
|
|
14
16
|
|
|
17
|
+
const REGISTRY = "https://registry.npmjs.org";
|
|
15
18
|
const marker = () => path.join(paths.home, "desktop.json");
|
|
19
|
+
const downloads = () => path.join(paths.home, "desktop-download");
|
|
20
|
+
|
|
21
|
+
/** Whether version `a` is newer than `b`: x.y.z, a prerelease (0.7.13-local.ab12) before its release. */
|
|
22
|
+
export function newerVersion(a, b) {
|
|
23
|
+
const parse = (v) => /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(String(v ?? ""));
|
|
24
|
+
const x = parse(a), y = parse(b);
|
|
25
|
+
if (!x || !y) return Boolean(x);
|
|
26
|
+
for (let i = 1; i <= 3; i++) if (Number(x[i]) !== Number(y[i])) return Number(x[i]) > Number(y[i]);
|
|
27
|
+
return !x[4] && Boolean(y[4]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* This machine's desktop package as npm has it now, downloaded when it is newer than `than` and unpacked under
|
|
32
|
+
* Harness's home. The download must match the checksum npm publishes for it. Null when there is nothing newer,
|
|
33
|
+
* nothing for this machine, or npm cannot be reached: setup goes on with what it has.
|
|
34
|
+
*/
|
|
35
|
+
export async function latestDesktop({ than, platform = process.platform, arch = process.arch, registry = REGISTRY }) {
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(`${registry}/${desktopPackageName(platform, arch)}/latest`);
|
|
38
|
+
if (!response.ok) return null;
|
|
39
|
+
const meta = await response.json();
|
|
40
|
+
const tarball = String(meta.dist?.tarball ?? "");
|
|
41
|
+
if (!meta.harnessDesktop || !newerVersion(meta.version, than) || !tarball.startsWith(`${registry}/`)) return null;
|
|
42
|
+
const download = await fetch(tarball);
|
|
43
|
+
if (!download.ok) return null;
|
|
44
|
+
const bytes = Buffer.from(await download.arrayBuffer());
|
|
45
|
+
const [algorithm, digest] = String(meta.dist.integrity ?? "").split(/-(.*)/s);
|
|
46
|
+
if (algorithm !== "sha512" || createHash("sha512").update(bytes).digest("base64") !== digest) return null;
|
|
47
|
+
fs.rmSync(downloads(), { recursive: true, force: true });
|
|
48
|
+
ensureDir(downloads());
|
|
49
|
+
const file = path.join(downloads(), "package.tgz");
|
|
50
|
+
fs.writeFileSync(file, bytes);
|
|
51
|
+
if (!run("tar", ["-xzf", file, "-C", downloads()]).ok) return null;
|
|
52
|
+
const app = path.join(downloads(), "package", String(meta.harnessDesktop));
|
|
53
|
+
return fs.existsSync(app) ? { app, version: String(meta.version) } : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Setup's desktop app: the newest of the one npx brought, the one on npm and the one installed, never an older build
|
|
61
|
+
* over a newer one (a local build stays). Where it is and whether it changed, or null when this machine has none.
|
|
62
|
+
* HARNESS_DESKTOP_APP installs that app as it is.
|
|
63
|
+
*/
|
|
64
|
+
export async function setupDesktop({ sourceRoot, env = process.env, platform = process.platform, arch = process.arch, registry = REGISTRY, execute = run }) {
|
|
65
|
+
const bundled = desktopSource({ sourceRoot, env, platform, arch });
|
|
66
|
+
const install = (source) => installDesktop({ source, target: desktopTarget({ source, env, platform }), platform, execute });
|
|
67
|
+
if (env.HARNESS_DESKTOP_APP) return bundled && install(bundled);
|
|
68
|
+
const installed = readJson(marker(), {});
|
|
69
|
+
const have = installed.path && fs.existsSync(installed.path) ? installed : null;
|
|
70
|
+
const than = newerVersion(have?.version, bundled?.version) ? have.version : bundled?.version ?? have?.version;
|
|
71
|
+
const latest = await latestDesktop({ than, platform, arch, registry });
|
|
72
|
+
const source = latest ?? bundled;
|
|
73
|
+
try {
|
|
74
|
+
if (have && (!source || newerVersion(have.version, source.version))) return { target: have.path, updated: false };
|
|
75
|
+
return source && install(source);
|
|
76
|
+
} finally {
|
|
77
|
+
if (latest) fs.rmSync(downloads(), { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
16
80
|
|
|
17
81
|
export function desktopPackageName(platform = process.platform, arch = process.arch) {
|
|
18
82
|
return `tryharness-desktop-${platform}-${arch}`;
|
package/lib/live.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { readJson } from "./files.mjs";
|
|
|
6
6
|
import { historyRoots } from "./paths.mjs";
|
|
7
7
|
import { PS } from "./nudge.mjs";
|
|
8
8
|
import { stepsOf } from "./runner.mjs";
|
|
9
|
-
import { t3Link, t3Thread } from "./places.mjs";
|
|
9
|
+
import { t3App, t3Link, t3Thread, t3Working } from "./places.mjs";
|
|
10
10
|
|
|
11
11
|
// The person's own coding sessions running on this computer right now, each with the app it runs in: a terminal, an
|
|
12
12
|
// editor or T3 Code, found by walking up the session's process tree to the first process inside an app bundle. A
|
|
@@ -113,12 +113,17 @@ export function liveSessions({ exclude = new Set(), recent = [], table = process
|
|
|
113
113
|
const id = String(session?.session_id || "").replace(/\.jsonl$/, "").match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0];
|
|
114
114
|
if (id && !sessions.some((row) => row.sessionId === id)) sessions.push({ agent: "codex", sessionId: id, folder: session.cwd, pid, busy: null, at: Date.parse(session.started_at) || 0 });
|
|
115
115
|
}
|
|
116
|
+
// Codex in T3 Code runs on that shared server too, so no process says which T3 thread it works in: T3's own record does.
|
|
117
|
+
const t3 = t3App();
|
|
118
|
+
if (t3) for (const row of t3Working("codex")) {
|
|
119
|
+
if (!sessions.some((session) => session.sessionId === row.sessionId)) sessions.push({ agent: "codex", sessionId: row.sessionId, folder: row.folder, pid: null, busy: true, at: row.at, host: { name: t3.name, app: t3.app }, t3Thread: row.thread });
|
|
120
|
+
}
|
|
116
121
|
return sessions.sort((a, b) => b.at - a.at).map((session) => {
|
|
117
122
|
const tty = table.get(session.pid)?.tty ?? null;
|
|
118
123
|
// Inside tmux the app is the terminal a tmux client shows the pane in.
|
|
119
124
|
const tmux = underTmux(table, session.pid) && tty ? tmuxPlace(table, tty) : null;
|
|
120
|
-
const host = tmux?.client?.host ?? hostApp(table, session.pid);
|
|
121
|
-
const t3 = host && /^T3 Code\b/.test(host.name) ? t3Link(t3Thread(session.sessionId)) : null;
|
|
125
|
+
const host = session.host ?? tmux?.client?.host ?? hostApp(table, session.pid);
|
|
126
|
+
const t3 = host && /^T3 Code\b/.test(host.name) ? t3Link(session.t3Thread ?? t3Thread(session.sessionId)) : null;
|
|
122
127
|
return { ...session, tty, tmux: tmux?.target ?? null, host, t3 };
|
|
123
128
|
});
|
|
124
129
|
}
|
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/places.mjs
CHANGED
|
@@ -154,6 +154,8 @@ const AGENTS = {
|
|
|
154
154
|
opencode: { command: "opencode", resume: "opencode --session" },
|
|
155
155
|
};
|
|
156
156
|
export const agentCommand = (agent) => AGENTS[agent]?.command ?? null;
|
|
157
|
+
/** An agent's provider in T3 Code ("claudeAgent", "codex"); null for an agent T3 does not run. */
|
|
158
|
+
export const t3Provider = (agent) => AGENTS[agent]?.t3 ?? null;
|
|
157
159
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
158
160
|
const PLAIN_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
159
161
|
const quote = (text) => `'${String(text).replace(/'/g, `'\\''`)}'`;
|
|
@@ -196,7 +198,8 @@ const t3Tables = (home) => new Set((t3Query(home, "select name from sqlite_maste
|
|
|
196
198
|
|
|
197
199
|
/**
|
|
198
200
|
* The T3 Code thread that has a provider session: the thread T3 ran it in (nightlies record the session on the thread's
|
|
199
|
-
* provider thread; 0.0.45 in provider_session_runtime), else T3's import of it.
|
|
201
|
+
* provider thread; 0.0.45 in provider_session_runtime), else T3's import of it. A T3 thread's own id (a turn Harness
|
|
202
|
+
* handed to T3, t3.mjs runInT3) is that thread.
|
|
200
203
|
*/
|
|
201
204
|
export function t3Thread(sessionId, home = t3Home()) {
|
|
202
205
|
if (!PLAIN_ID.test(String(sessionId || ""))) return null;
|
|
@@ -204,7 +207,7 @@ export function t3Thread(sessionId, home = t3Home()) {
|
|
|
204
207
|
// The id is checked above: letters, digits and . _ : - only.
|
|
205
208
|
const parts = [];
|
|
206
209
|
if (tables.has("orchestration_v2_projection_provider_threads")) parts.push(`select thread_id, updated_at as at from orchestration_v2_projection_provider_threads where thread_id is not null and json_extract(payload_json, '$.nativeThreadRef.nativeId') = '${sessionId}'`);
|
|
207
|
-
if (tables.has("orchestration_v2_projection_threads")) parts.push(`select thread_id, updated_at as at from orchestration_v2_projection_threads where deleted_at is null and thread_id in ('import:claudeAgent:${sessionId}', 'import:codex:${sessionId}')`);
|
|
210
|
+
if (tables.has("orchestration_v2_projection_threads")) parts.push(`select thread_id, updated_at as at from orchestration_v2_projection_threads where deleted_at is null and thread_id in ('${sessionId}', 'import:claudeAgent:${sessionId}', 'import:codex:${sessionId}')`);
|
|
208
211
|
else if (tables.has("provider_session_runtime")) parts.push(`select thread_id, last_seen_at as at from provider_session_runtime where thread_id like '%:${sessionId}' or json_extract(resume_cursor_json, '$.resume') = '${sessionId}'`);
|
|
209
212
|
if (!parts.length) return null;
|
|
210
213
|
return t3Query(home, `select thread_id from (${parts.join(" union all ")}) order by at desc limit 1;`) || null;
|
|
@@ -217,6 +220,49 @@ export function t3Link(threadId, home = t3Home()) {
|
|
|
217
220
|
return threadId && PLAIN_ID.test(environment) ? `t3code://threads/${environment}/${encodeURIComponent(threadId)}` : null;
|
|
218
221
|
}
|
|
219
222
|
|
|
223
|
+
const realDir = (dir) => { try { return fs.realpathSync(dir); } catch { return null; } };
|
|
224
|
+
|
|
225
|
+
/** The T3 Code project for a folder: the one T3 opened on the folder itself or on the repository it is in. */
|
|
226
|
+
export function t3Project(folder, home = t3Home()) {
|
|
227
|
+
if (!folder || !t3Tables(home).has("projection_projects")) return null;
|
|
228
|
+
const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: folder, encoding: "utf8" });
|
|
229
|
+
const wanted = new Set([realDir(folder), top.status === 0 ? realDir(top.stdout.trim()) : null].filter(Boolean));
|
|
230
|
+
const rows = (t3Query(home, "select project_id || char(9) || workspace_root from projection_projects where deleted_at is null;") || "").split("\n").map((line) => line.split("\t"));
|
|
231
|
+
const found = rows.find(([id, root]) => id && root && wanted.has(realDir(root)));
|
|
232
|
+
return found ? { id: found[0], root: realDir(found[1]) } : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The model (and its options) the person last ran an agent with in T3 Code, as T3 records each thread's choice. */
|
|
236
|
+
export function t3Model(agent, home = t3Home()) {
|
|
237
|
+
const provider = t3Provider(agent);
|
|
238
|
+
if (!provider || !t3Tables(home).has("orchestration_v2_projection_threads")) return null;
|
|
239
|
+
const raw = t3Query(home, `select json_extract(payload_json, '$.modelSelection') from orchestration_v2_projection_threads where deleted_at is null and json_extract(payload_json, '$.modelSelection.instanceId') = '${provider}' order by updated_at desc limit 1;`);
|
|
240
|
+
try { const model = JSON.parse(raw || "null"); return typeof model?.model === "string" ? model : null; } catch { return null; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The threads T3 Code is running a turn in right now for an agent: the thread, the agent's own session id there, and
|
|
245
|
+
* its folder (the thread's worktree, else its project's). Codex in T3 runs on Codex's shared background server, outside
|
|
246
|
+
* T3's app, so this is how its running sessions there are found.
|
|
247
|
+
*/
|
|
248
|
+
export function t3Working(agent, home = t3Home()) {
|
|
249
|
+
const provider = t3Provider(agent);
|
|
250
|
+
const tables = t3Tables(home);
|
|
251
|
+
if (!provider || !tables.has("orchestration_v2_projection_provider_threads") || !tables.has("orchestration_v2_projection_threads")) return [];
|
|
252
|
+
const projects = tables.has("projection_projects");
|
|
253
|
+
const rows = t3Query(home, `select p.thread_id || char(9) || json_extract(p.payload_json, '$.nativeThreadRef.nativeId') || char(9) || coalesce(json_extract(t.payload_json, '$.worktreePath'), ${projects ? "r.workspace_root, " : ""}'') || char(9) || p.updated_at
|
|
254
|
+
from orchestration_v2_projection_provider_threads p join orchestration_v2_projection_threads t on t.thread_id = p.thread_id${projects ? " left join projection_projects r on r.project_id = t.project_id" : ""}
|
|
255
|
+
where p.provider = '${provider}' and p.status = 'active' and t.deleted_at is null;`);
|
|
256
|
+
return (rows || "").split("\n").map((line) => line.split("\t")).filter(([thread, session]) => thread && PLAIN_ID.test(session || ""))
|
|
257
|
+
.map(([thread, session, folder, at]) => ({ thread, sessionId: session, folder: folder || null, at: Date.parse(at) || 0 }));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** True while T3 Code is open on this computer. */
|
|
261
|
+
export function t3Open() {
|
|
262
|
+
const running = spawnSync(PS, ["-axo", "comm="], { encoding: "utf8", maxBuffer: 16 << 20 }).stdout || "";
|
|
263
|
+
return /\/T3 Code\b[^/\n]*\.app\/Contents\/MacOS\//.test(running);
|
|
264
|
+
}
|
|
265
|
+
|
|
220
266
|
/** How many threads the person ran an agent in T3 Code since a time: threads T3 itself ran (an import it never ran is not use). */
|
|
221
267
|
export function t3Runs(agent, sinceIso, home = t3Home()) {
|
|
222
268
|
const provider = AGENTS[agent]?.t3;
|
package/lib/runner.mjs
CHANGED
|
@@ -14,6 +14,13 @@ export function stopAgentTask(runId) {
|
|
|
14
14
|
return true;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** A run another app carries out (T3 Code, t3.mjs): Stop reaches it through `stop`. Returns its release. */
|
|
18
|
+
export function trackRun(runId, stop) {
|
|
19
|
+
const entry = { pid: null, kill: stop };
|
|
20
|
+
children.set(runId, entry);
|
|
21
|
+
return () => { if (children.get(runId) === entry) children.delete(runId); };
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
/** The process ids of the runs in progress, so they are not mistaken for the person's own sessions. */
|
|
18
25
|
export function runningPids() {
|
|
19
26
|
return [...children.values()].map((child) => child.pid).filter(Boolean);
|
|
@@ -105,6 +112,8 @@ const brief = (input) => {
|
|
|
105
112
|
};
|
|
106
113
|
export function stepsOf(event) {
|
|
107
114
|
if (!event || typeof event !== "object") return [];
|
|
115
|
+
// A step as Harness writes it for a run another app carries out (t3.mjs).
|
|
116
|
+
if (event.type === "harness.step" && (event.kind === "say" || event.kind === "do") && typeof event.text === "string") return [{ kind: event.kind, text: event.text }];
|
|
108
117
|
if (event.type === "assistant" && Array.isArray(event.message?.content)) {
|
|
109
118
|
return event.message.content.flatMap((block) => block.type === "text" && block.text?.trim() ? [{ kind: "say", text: block.text.trim() }]
|
|
110
119
|
: block.type === "tool_use" ? [{ kind: "do", text: `${block.name}: ${brief(block.input)}` }] : []);
|
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/t3.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { readJson, writeJsonAtomic } from "./files.mjs";
|
|
6
6
|
import { paths } from "./paths.mjs";
|
|
7
7
|
import { t3Home } from "./places.mjs";
|
|
8
|
+
import { trackRun } from "./runner.mjs";
|
|
8
9
|
|
|
9
10
|
// T3 Code's door for agents it did not start (its /mcp; T3's docs/user/outside-agents.md). Harness signs in once and the
|
|
10
11
|
// person approves it inside T3, choosing what it may do; T3 asks for a pairing code from its Settings → Connections, and
|
|
@@ -151,8 +152,8 @@ async function callTool(origin, token, name, args) {
|
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
/**
|
|
155
|
-
|
|
155
|
+
/** One of T3's tools, as Harness was approved to use them, at T3's current address. */
|
|
156
|
+
async function t3Tool(name, args) {
|
|
156
157
|
const saved = readJson(file(), null);
|
|
157
158
|
const access = t3Access();
|
|
158
159
|
if (!access) throw new T3Error("not_connected", "Harness is not approved in T3 Code yet.");
|
|
@@ -163,10 +164,101 @@ export async function sendToT3(threadId, text) {
|
|
|
163
164
|
if (!t3 || t3.environmentId !== saved.environmentId) throw new T3Error("not_running", "T3 Code is not running on this computer.");
|
|
164
165
|
if (t3.origin !== saved.origin) writeJsonAtomic(file(), { ...saved, origin: t3.origin });
|
|
165
166
|
try {
|
|
166
|
-
|
|
167
|
-
return sent.delivery === "queued" ? "queued" : "started";
|
|
167
|
+
return await callTool(t3.origin, saved.token, name, args);
|
|
168
168
|
} catch (error) {
|
|
169
169
|
if (error.code === "expired") writeJsonAtomic(file(), { ...saved, expiresAt: new Date(0).toISOString() });
|
|
170
170
|
throw error;
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
|
+
|
|
174
|
+
/** Sends the person's words into a T3 Code thread: "started" or "queued" behind its turn. */
|
|
175
|
+
export async function sendToT3(threadId, text) {
|
|
176
|
+
const sent = await t3Tool("t3_thread_send", { threadId, message: text, mode: "queue", clientRequestId: randomUUID() });
|
|
177
|
+
return sent.delivery === "queued" ? "queued" : "started";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── A thread's turn, carried out by T3 Code ──────────────────────────────────
|
|
181
|
+
|
|
182
|
+
// How often a turn handed to T3 is read back for its steps and its end: as often as the buddy reads a session's feed.
|
|
183
|
+
const READ_MS = 2_000;
|
|
184
|
+
const SETTLED = new Set(["completed", "failed", "cancelled", "interrupted"]);
|
|
185
|
+
const ENDED = new Set(["interrupted", "failed", "cancelled", "rolled_back"]);
|
|
186
|
+
const handed = new Map();
|
|
187
|
+
|
|
188
|
+
/** The T3 threads running a turn Harness handed them: Harness's runs, not the person's own sessions. */
|
|
189
|
+
export const handedToT3 = () => new Set(handed.values());
|
|
190
|
+
|
|
191
|
+
/** One item of a T3 thread as a step on the board: its words, or what it did in a line. */
|
|
192
|
+
function stepOf(item) {
|
|
193
|
+
const text = String(item.text || "").trim();
|
|
194
|
+
if (item.type === "assistant_message") return text ? { kind: "say", text } : null;
|
|
195
|
+
if (item.type === "command_execution") return { kind: "do", text: `ran: ${(item.title === "Workspace ready" ? item.title : text.split("\n")[0].replace(/^\$\s*/, "")).slice(0, 200)}` };
|
|
196
|
+
if (item.type === "web_search") return { kind: "do", text: "searched the web" };
|
|
197
|
+
if (["user_message", "reasoning", "checkpoint"].includes(item.type)) return null;
|
|
198
|
+
return { kind: "do", text: `${item.title || String(item.type).replace(/_/g, " ")}`.slice(0, 200) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A coding agent's turn in a Harness thread, carried out by T3 Code, so the person finds it in T3 like any thread there.
|
|
203
|
+
* The agent's first turn launches its own T3 thread (`launch`: T3's project, workspace and the model the person last
|
|
204
|
+
* chose for the agent there; T3 applies the mode the person approved Harness with); later turns go into that thread,
|
|
205
|
+
* queued behind whatever it is doing. It reports as runner.mjs does: accepted, its session (the T3 thread, which is
|
|
206
|
+
* how Open finds it), each step, then done with the agent's last words or failed; Stop interrupts it in T3.
|
|
207
|
+
*/
|
|
208
|
+
export function runInT3(run, emit, observe) {
|
|
209
|
+
let threadId = run.threadId ?? null, runId = null, cursor = null, timer = null, stopped = false, last = "";
|
|
210
|
+
const seen = new Set();
|
|
211
|
+
const interrupt = () => t3Tool("t3_thread_interrupt", { threadId, ...(runId ? { runId } : {}), reason: "Stopped from Harness.", clientRequestId: `${run.runId}:stop` }).catch(() => {});
|
|
212
|
+
const release = trackRun(run.runId, () => { stopped = true; if (threadId) void interrupt(); });
|
|
213
|
+
const finish = (kind, text) => { clearTimeout(timer); handed.delete(run.runId); release(); emit(kind, text.slice(0, 20_000), { usage: null }); };
|
|
214
|
+
const read = async () => {
|
|
215
|
+
try {
|
|
216
|
+
let page, after = cursor, settled = true;
|
|
217
|
+
do {
|
|
218
|
+
page = await t3Tool("t3_thread_read", { threadId, view: "activity", limit: 100, maxCharsPerItem: 20_000, runLimit: 5, ...(after === null ? {} : { afterPosition: after }) });
|
|
219
|
+
for (const item of page.items ?? []) {
|
|
220
|
+
if (item.runId === runId && SETTLED.has(item.status) && !seen.has(item.itemId)) {
|
|
221
|
+
seen.add(item.itemId);
|
|
222
|
+
const step = stepOf(item);
|
|
223
|
+
if (step) {
|
|
224
|
+
if (step.kind === "say") last = step.text;
|
|
225
|
+
observe("stdout", `${JSON.stringify({ type: "harness.step", ...step })}\n`);
|
|
226
|
+
emit("progress", step.text.slice(0, 200), { step });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Read again from the first item still going; everything before it is settled.
|
|
230
|
+
settled &&= SETTLED.has(item.status);
|
|
231
|
+
if (settled) cursor = item.position;
|
|
232
|
+
}
|
|
233
|
+
after = page.nextPosition ?? after;
|
|
234
|
+
} while (page.hasMore && page.items?.length);
|
|
235
|
+
if (page.thread?.worktreePath) run.onWorktree?.(page.thread.worktreePath, page.thread.branch ?? null);
|
|
236
|
+
const status = (page.recentRuns ?? []).find((entry) => entry.runId === runId)?.status;
|
|
237
|
+
if (status === "completed") return finish("done", last || "The run finished in T3 Code; its changes are in T3's checkout.");
|
|
238
|
+
if (ENDED.has(status)) return finish("failed", stopped ? "Stopped." : `T3 Code ended the turn (${status})${last ? `: ${last}` : "."}`);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (["expired", "not_connected", "read_only"].includes(error.code)) return finish("failed", `${error.message} The turn may still be going in T3 Code.`);
|
|
241
|
+
// T3 restarting or busy: read again next time.
|
|
242
|
+
}
|
|
243
|
+
timer = setTimeout(() => void read(), READ_MS);
|
|
244
|
+
};
|
|
245
|
+
emit("accepted");
|
|
246
|
+
void (async () => {
|
|
247
|
+
try {
|
|
248
|
+
if (threadId) runId = (await t3Tool("t3_thread_send", { threadId, message: run.brief, mode: "queue", clientRequestId: run.runId })).runId ?? null;
|
|
249
|
+
else {
|
|
250
|
+
const launched = await t3Tool("t3_thread_launch", { title: run.title, message: run.brief, ...run.launch });
|
|
251
|
+
threadId = launched.threadId;
|
|
252
|
+
runId = launched.runId ?? null;
|
|
253
|
+
run.onThread?.(threadId);
|
|
254
|
+
}
|
|
255
|
+
if (!threadId || !runId) throw new T3Error("t3", "T3 Code did not say which thread and turn it started");
|
|
256
|
+
handed.set(run.runId, threadId);
|
|
257
|
+
emit("session", threadId, { cwd: run.cwd });
|
|
258
|
+
if (stopped) await interrupt();
|
|
259
|
+
void read();
|
|
260
|
+
} catch (error) {
|
|
261
|
+
finish("failed", `T3 Code did not take it: ${error.message}`);
|
|
262
|
+
}
|
|
263
|
+
})();
|
|
264
|
+
}
|
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/lib/worktrees.mjs
CHANGED
|
@@ -37,6 +37,7 @@ export function createWorktrees({ store, root }) {
|
|
|
37
37
|
if (!folder) return null;
|
|
38
38
|
const current = get(thread.id);
|
|
39
39
|
if (current?.state === "in_place") return folder;
|
|
40
|
+
if (current?.state === "t3") return current.path && fs.existsSync(current.path) ? current.path : folder;
|
|
40
41
|
if (current && current.state === "open" && fs.existsSync(current.path)) return path.join(current.path, current.inside);
|
|
41
42
|
const repo = repositoryOf(folder);
|
|
42
43
|
if (!repo) return folder;
|
|
@@ -86,7 +87,8 @@ export function createWorktrees({ store, root }) {
|
|
|
86
87
|
|
|
87
88
|
const summary = (threadId) => {
|
|
88
89
|
const tree = get(threadId);
|
|
89
|
-
|
|
90
|
+
// A thread T3 Code works in keeps its changes in T3's own checkout, where T3 shows and merges them.
|
|
91
|
+
if (!tree || tree.state === "in_place" || tree.state === "t3") return null;
|
|
90
92
|
const files = tree.state === "open" && fs.existsSync(tree.path) ? stat(tree.path, `${tree.base}..HEAD`).length : 0;
|
|
91
93
|
return { state: tree.state, branch: tree.branch, repo: tree.repo, files };
|
|
92
94
|
};
|
|
@@ -128,5 +130,25 @@ export function createWorktrees({ store, root }) {
|
|
|
128
130
|
/** A thread that carries on a session from a terminal works where that session did: agents find sessions by folder. */
|
|
129
131
|
const inPlace = (threadId) => save(threadId, { state: "in_place" });
|
|
130
132
|
|
|
131
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Where T3 Code runs a thread's agents (t3.mjs runInT3): the checkout T3 made for the thread's first agent, shared by
|
|
135
|
+
* the rest as its own worktree is; before that, a new one on its own branch from what the person has checked out (as
|
|
136
|
+
* folderFor's), or T3's project folder when the thread's folder is in no repository.
|
|
137
|
+
*/
|
|
138
|
+
const t3Workspace = (thread) => {
|
|
139
|
+
const current = get(thread.id);
|
|
140
|
+
if (current?.state === "t3" && current.path) return { type: "existing_worktree", worktreePath: current.path, ...(current.branch ? { branch: current.branch } : {}) };
|
|
141
|
+
const repo = repositoryOf(thread.goal.workspace);
|
|
142
|
+
if (!repo) return { type: "root" };
|
|
143
|
+
const branch = git(repo.root, ["rev-parse", "--abbrev-ref", "HEAD"]).out;
|
|
144
|
+
return { type: "worktree", baseRef: branch && branch !== "HEAD" ? branch : git(repo.root, ["rev-parse", "HEAD"]).out,
|
|
145
|
+
branch: `harness/${slug(thread.objective)}-${thread.id.slice(0, 8)}`, startFromOrigin: false };
|
|
146
|
+
};
|
|
147
|
+
/** The thread works in T3 Code from now on; `where` and `branch` once T3 has made its checkout. */
|
|
148
|
+
const inT3 = (threadId, where = null, branch = null) => {
|
|
149
|
+
const current = get(threadId);
|
|
150
|
+
if (current?.state !== "t3" || (where && where !== current.path)) save(threadId, { state: "t3", path: where ?? current?.path ?? null, branch: branch ?? current?.branch ?? null });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return { folderFor, commitTurn, diff, summary, merge, discard, inPlace, t3Workspace, inT3, get };
|
|
132
154
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tryharness",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.16",
|
|
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,15 +29,18 @@
|
|
|
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
|
-
"tryharness-desktop-darwin-arm64": "0.7.
|
|
40
|
-
"tryharness-desktop-darwin-x64": "0.7.
|
|
42
|
+
"tryharness-desktop-darwin-arm64": "0.7.16",
|
|
43
|
+
"tryharness-desktop-darwin-x64": "0.7.16",
|
|
41
44
|
"tryharness-desktop-linux-x64": "0.7.4",
|
|
42
45
|
"tryharness-desktop-win32-x64": "0.7.4"
|
|
43
46
|
}
|