tryharness 0.7.8 → 0.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/opencode-plugin.js +9 -2
- package/assets/pi-extension.ts +8 -3
- package/cli.mjs +42 -1
- package/lib/adapters.mjs +12 -0
- package/lib/cards.mjs +113 -0
- package/lib/coordinator.mjs +5 -6
- package/lib/daemon.mjs +191 -24
- package/lib/files.mjs +15 -0
- package/lib/mcp-stdio.mjs +36 -5
- package/lib/members.json +26 -13
- package/lib/nudge.mjs +29 -13
- package/lib/paths.mjs +1 -0
- package/lib/phones.mjs +3 -1
- package/lib/sealed.mjs +9 -0
- package/lib/store.mjs +65 -3
- package/lib/swarm.mjs +12 -7
- package/lib/t3.mjs +172 -0
- package/package.json +3 -3
package/lib/nudge.mjs
CHANGED
|
@@ -11,7 +11,13 @@ import { placeEnv } from "./places.mjs";
|
|
|
11
11
|
// SessionStart hook (stdout lands in its context); the Pi extension and OpenCode plugin
|
|
12
12
|
// build the same text in-process. Nothing waiting, or no daemon, prints nothing.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// The swarm block (cards.mjs), when given, follows the queue: who else is in the person's swarm and what each is for.
|
|
15
|
+
export function nudgeText(agent, tasks, unread = [], goals = [], swarm = "") {
|
|
16
|
+
const queue = queueText(agent, tasks, unread, goals);
|
|
17
|
+
return [queue, swarm].filter(Boolean).join("\n\n");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function queueText(agent, tasks, unread, goals) {
|
|
15
21
|
const waiting = (tasks || []).filter((task) => task.status === "open" && (!task.target_agent || task.target_agent === agent));
|
|
16
22
|
const held = (tasks || []).filter((task) => task.claimed_by === agent && (task.status === "claimed" || task.status === "needs_input"));
|
|
17
23
|
if (!waiting.length && !held.length && !unread.length && !goals.length) return "";
|
|
@@ -38,31 +44,41 @@ export function queueDigest(agent, tasks, unread = [], goals = []) {
|
|
|
38
44
|
|
|
39
45
|
export async function boardQueue(agent) {
|
|
40
46
|
try {
|
|
41
|
-
const response = await daemonRequest({ command: "board.list", agent, unread: true }, 1_500);
|
|
42
|
-
return response?.ok ? { tasks: response.tasks, unread: response.unread || [], goals: response.goals || [] } : null;
|
|
47
|
+
const response = await daemonRequest({ command: "board.list", agent, unread: true, swarm: true }, 1_500);
|
|
48
|
+
return response?.ok ? { tasks: response.tasks, unread: response.unread || [], goals: response.goals || [], swarm: response.swarm || null } : null;
|
|
43
49
|
} catch {
|
|
44
50
|
return null;
|
|
45
51
|
}
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
// What this agent was last told, in the Harness home: the queue's digest and the swarm's.
|
|
55
|
+
const toldFile = (agent, homeDir) => path.join(homeDir, `nudge-${agent}.json`);
|
|
56
|
+
function lastTold(agent, homeDir) { try { return JSON.parse(fs.readFileSync(toldFile(agent, homeDir), "utf8")); } catch { return {}; } }
|
|
57
|
+
function noteTold(agent, homeDir, told) { try { fs.writeFileSync(toldFile(agent, homeDir), JSON.stringify({ ...lastTold(agent, homeDir), ...told, at: new Date().toISOString() }), { mode: 0o600 }); } catch {} }
|
|
58
|
+
|
|
59
|
+
/** What a session is told when it starts: its queue and the whole swarm. */
|
|
60
|
+
export async function boardNudge(agent, homeDir = paths.home) {
|
|
49
61
|
const queue = await boardQueue(agent);
|
|
50
|
-
|
|
62
|
+
if (!queue) return "";
|
|
63
|
+
if (queue.swarm?.digest) noteTold(agent, homeDir, { swarm: queue.swarm.digest });
|
|
64
|
+
return nudgeText(agent, queue.tasks, queue.unread, queue.goals, queue.swarm?.text || "");
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
// Only what changed since this agent last saw its queue: a hook that runs on every prompt
|
|
54
|
-
// prints once per change, never the same block again. The
|
|
68
|
+
// prints once per change, never the same block again. The swarm is told again only when who is
|
|
69
|
+
// in it, their facts or the person's notes changed, not each time an agent finishes something.
|
|
55
70
|
export async function boardNudgeIfChanged(agent, homeDir = paths.home) {
|
|
56
71
|
const queue = await boardQueue(agent);
|
|
57
72
|
if (!queue) return "";
|
|
58
|
-
const { tasks, unread, goals } = queue;
|
|
73
|
+
const { tasks, unread, goals, swarm } = queue;
|
|
59
74
|
const digest = queueDigest(agent, tasks, unread, goals);
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
const last = lastTold(agent, homeDir);
|
|
76
|
+
const queueMoved = digest !== (last.digest ?? null);
|
|
77
|
+
const swarmMoved = Boolean(swarm?.digest) && swarm.digest !== last.swarm;
|
|
78
|
+
if (!queueMoved && !swarmMoved) return "";
|
|
79
|
+
noteTold(agent, homeDir, { digest, ...(swarm?.digest ? { swarm: swarm.digest } : {}) });
|
|
80
|
+
const told = !queueMoved ? "" : digest ? queueText(agent, tasks, unread, goals) : (last.digest ? "Harness task board: nothing waits for you now." : "");
|
|
81
|
+
return [told, swarmMoved ? swarm.text : ""].filter(Boolean).join("\n\n");
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
// The prompt hook also tells the daemon the person typed in this session: the agent process is the hook's parent, or
|
package/lib/paths.mjs
CHANGED
|
@@ -16,6 +16,7 @@ export const paths = {
|
|
|
16
16
|
log: path.join(harnessHome, "daemon.log"),
|
|
17
17
|
phoneIdentity: path.join(harnessHome, "phone-identity.json"),
|
|
18
18
|
runs: path.join(harnessHome, "runs"),
|
|
19
|
+
files: path.join(harnessHome, "files"),
|
|
19
20
|
runtime: path.join(harnessHome, "runtime"),
|
|
20
21
|
models: path.join(harnessHome, "models"),
|
|
21
22
|
legacyBridgeConfig: path.join(harnessHome, "bridge.json"),
|
package/lib/phones.mjs
CHANGED
|
@@ -23,7 +23,9 @@ const PHONE_COMMANDS = new Set([
|
|
|
23
23
|
"board.thread.read", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.auto", "board.thread.end",
|
|
24
24
|
"board.goal.list", "board.goal.get", "board.goal.stop", "board.room.read",
|
|
25
25
|
"board.swarm.list", "board.swarm.read", "board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.folder", "board.swarm.coordinator",
|
|
26
|
-
"board.swarm.diff", "board.swarm.merge", "board.swarm.discard", "board.swarm.start_run",
|
|
26
|
+
"board.swarm.diff", "board.swarm.merge", "board.swarm.discard", "board.swarm.start_run", "board.swarm.agents", "board.swarm.note",
|
|
27
|
+
// The person's own running sessions, read and messaged as from the desktop app; never queued (a session may be gone).
|
|
28
|
+
"board.live", "board.live.read", "board.live.say",
|
|
27
29
|
]);
|
|
28
30
|
// What the chat owner does on the board, in Harness's name (was src/server/mcp/board.ts HARNESS_COMMANDS).
|
|
29
31
|
const HARNESS_COMMANDS = new Set([
|
package/lib/sealed.mjs
CHANGED
|
@@ -96,6 +96,15 @@ export async function openBound(key, bound, sealed) {
|
|
|
96
96
|
const plain = await subtle.decrypt({ name: "AES-GCM", iv: fromB64u(sealed.iv), additionalData: encoder.encode(`HSC1|${bound}`) }, key, fromB64u(sealed.ct));
|
|
97
97
|
return JSON.parse(decoder.decode(plain));
|
|
98
98
|
}
|
|
99
|
+
// A file's bytes, sealed as they are (no JSON or base64 inside): bound to its thread and file id like a message.
|
|
100
|
+
export async function sealBytes(key, bound, bytes) {
|
|
101
|
+
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
102
|
+
const ct = await subtle.encrypt({ name: "AES-GCM", iv, additionalData: encoder.encode(`HSC1|${bound}`) }, key, bytes);
|
|
103
|
+
return { iv: b64u(iv), ct: b64u(new Uint8Array(ct)) };
|
|
104
|
+
}
|
|
105
|
+
export async function openBytes(key, bound, sealed) {
|
|
106
|
+
return Buffer.from(await subtle.decrypt({ name: "AES-GCM", iv: fromB64u(sealed.iv), additionalData: encoder.encode(`HSC1|${bound}`) }, key, fromB64u(sealed.ct)));
|
|
107
|
+
}
|
|
99
108
|
// What two people read to each other to check they hold each other's key: SHA-256 of the key, 20 hex digits in groups of four.
|
|
100
109
|
export async function fingerprint(publicKey) {
|
|
101
110
|
const digest = Buffer.from(await subtle.digest("SHA-256", fromB64u(publicKey))).toString("hex");
|
package/lib/store.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import sqliteWasm from "node-sqlite3-wasm";
|
|
|
3
3
|
import { parseSession } from "./parsers.mjs";
|
|
4
4
|
import { provenanceFor } from "./provenance.mjs";
|
|
5
5
|
|
|
6
|
-
const SCHEMA_VERSION =
|
|
6
|
+
const SCHEMA_VERSION = 10;
|
|
7
7
|
const INDEX_FORMAT_VERSION = 2;
|
|
8
8
|
const { Database } = sqliteWasm;
|
|
9
9
|
|
|
@@ -185,6 +185,20 @@ export function openStore(file) {
|
|
|
185
185
|
version text not null,
|
|
186
186
|
vector blob not null
|
|
187
187
|
);
|
|
188
|
+
create table if not exists board_files (
|
|
189
|
+
id text primary key,
|
|
190
|
+
task_id text not null,
|
|
191
|
+
name text not null,
|
|
192
|
+
mime text not null,
|
|
193
|
+
bytes integer,
|
|
194
|
+
sha256 text,
|
|
195
|
+
author text not null,
|
|
196
|
+
cloud integer not null default 0,
|
|
197
|
+
remote_id text,
|
|
198
|
+
present integer not null default 0,
|
|
199
|
+
created_at text not null
|
|
200
|
+
);
|
|
201
|
+
create index if not exists board_files_task_idx on board_files(task_id, created_at);
|
|
188
202
|
`);
|
|
189
203
|
const sessionColumns = new Set(db.all("pragma table_info(sessions)").map((column) => column.name));
|
|
190
204
|
if (!sessionColumns.has("parser_version")) {
|
|
@@ -689,6 +703,46 @@ export function openStore(file) {
|
|
|
689
703
|
relayPeers() {
|
|
690
704
|
return execute("all", "select handle, public_key, pinned_at from relay_peers order by pinned_at desc");
|
|
691
705
|
},
|
|
706
|
+
// Files on a task. A file this machine has is present; one another person's computer is sending is named by its
|
|
707
|
+
// message first or arrives first, and is matched by the id that computer gave it (remote_id), never by ours.
|
|
708
|
+
addFile({ taskId, name, mime, bytes, sha256, author, cloud = false }) {
|
|
709
|
+
const id = randomUUID();
|
|
710
|
+
execute("run", `insert into board_files(id, task_id, name, mime, bytes, sha256, author, cloud, present, created_at) values(@id, @taskId, @name, @mime, @bytes, @sha256, @author, @cloud, 1, @now)`,
|
|
711
|
+
{ id, taskId, name, mime, bytes, sha256, author, cloud: cloud ? 1 : 0, now: new Date().toISOString() });
|
|
712
|
+
execute("run", "update board_tasks set updated_at = ?, version = version + 1 where id = ?", new Date().toISOString(), taskId);
|
|
713
|
+
return this.file(id);
|
|
714
|
+
},
|
|
715
|
+
file(id) {
|
|
716
|
+
const row = execute("get", "select * from board_files where id = ?", String(id));
|
|
717
|
+
return row ? { ...row, bytes: row.bytes == null ? null : Number(row.bytes), cloud: Number(row.cloud) === 1, present: Number(row.present) === 1 } : null;
|
|
718
|
+
},
|
|
719
|
+
taskFiles(taskId) {
|
|
720
|
+
return execute("all", "select id from board_files where task_id = ? order by created_at asc", taskId).map((row) => this.file(row.id));
|
|
721
|
+
},
|
|
722
|
+
// The other computer's message names a file it sends: kept with the hash it promised, which its bytes must match.
|
|
723
|
+
expectFile({ taskId, remoteId, name, mime, sha256, author }) {
|
|
724
|
+
const known = execute("get", "select id, sha256, present from board_files where task_id = ? and remote_id = ?", taskId, remoteId);
|
|
725
|
+
if (known && Number(known.present) === 1 && known.sha256 !== sha256) return { ok: false, error: "a file arrived with other bytes than its message promised" };
|
|
726
|
+
if (known) {
|
|
727
|
+
execute("run", "update board_files set name = ?, mime = ?, sha256 = ?, author = ? where id = ?", name, mime, sha256, author, known.id);
|
|
728
|
+
return { ok: true, file: this.file(known.id) };
|
|
729
|
+
}
|
|
730
|
+
const id = randomUUID();
|
|
731
|
+
execute("run", `insert into board_files(id, task_id, name, mime, sha256, author, remote_id, present, created_at) values(@id, @taskId, @name, @mime, @sha256, @author, @remoteId, 0, @now)`,
|
|
732
|
+
{ id, taskId, name, mime, sha256, author, remoteId, now: new Date().toISOString() });
|
|
733
|
+
return { ok: true, file: this.file(id) };
|
|
734
|
+
},
|
|
735
|
+
receiveFile({ taskId, remoteId, sha256, bytes, author }) {
|
|
736
|
+
const known = execute("get", "select id, sha256, present from board_files where task_id = ? and remote_id = ?", taskId, remoteId);
|
|
737
|
+
if (known && Number(known.present) === 1) return { ok: true, duplicate: true, file: this.file(known.id) };
|
|
738
|
+
if (known && known.sha256 && known.sha256 !== sha256) return { ok: false, error: "a file arrived with other bytes than its message promised" };
|
|
739
|
+
const id = known?.id || randomUUID();
|
|
740
|
+
if (known) execute("run", "update board_files set sha256 = ?, bytes = ?, present = 1 where id = ?", sha256, bytes, id);
|
|
741
|
+
else execute("run", `insert into board_files(id, task_id, name, mime, bytes, sha256, author, remote_id, present, created_at) values(@id, @taskId, 'file', 'application/octet-stream', @bytes, @sha256, @author, @remoteId, 1, @now)`,
|
|
742
|
+
{ id, taskId, bytes, sha256, author, remoteId, now: new Date().toISOString() });
|
|
743
|
+
execute("run", "update board_tasks set updated_at = ?, version = version + 1 where id = ?", new Date().toISOString(), taskId);
|
|
744
|
+
return { ok: true, file: this.file(id) };
|
|
745
|
+
},
|
|
692
746
|
getThread(taskId) {
|
|
693
747
|
const row = execute("get", "select * from board_threads where task_id = ?", taskId);
|
|
694
748
|
return row ? { ...row, auto_send: Number(row.auto_send) === 1 } : null;
|
|
@@ -713,11 +767,11 @@ export function openStore(file) {
|
|
|
713
767
|
this.addContribution({ id: `msg:${id}`, taskId, kind: "message", author: sender, text, source: `message:${id}` });
|
|
714
768
|
return { ok: true, message: this.threadMessages(taskId).find((message) => message.id === id) };
|
|
715
769
|
},
|
|
716
|
-
postMessage({ id, taskId, sender, text, refs = [], flagReason = null }) {
|
|
770
|
+
postMessage({ id, taskId, sender, text, refs = [], flagReason = null, hold = false }) {
|
|
717
771
|
const thread = this.getThread(taskId);
|
|
718
772
|
if (!thread) return { ok: false, error: "no thread on this task" };
|
|
719
773
|
if (thread.closed_at) return { ok: false, error: "the thread is closed" };
|
|
720
|
-
const state = flagReason ? "flagged" : thread.auto_send ? "sent" : "held";
|
|
774
|
+
const state = flagReason ? "flagged" : thread.auto_send && !hold ? "sent" : "held";
|
|
721
775
|
const now = new Date().toISOString();
|
|
722
776
|
execute("run", `insert into board_messages(id, task_id, seq, direction, sender, recipient, text, refs, state, flag_reason, created_at, sent_at)
|
|
723
777
|
values(@id, @taskId, @seq, 'out', @sender, @recipient, @text, @refs, @state, @flagReason, @now, @sentAt)`,
|
|
@@ -819,6 +873,14 @@ export function openStore(file) {
|
|
|
819
873
|
agentsSeen() {
|
|
820
874
|
return execute("all", "select agent, last_seen_at from board_agents order by last_seen_at desc");
|
|
821
875
|
},
|
|
876
|
+
// Each agent's work on the board, newest first, for its card (cards.mjs): what it holds and finished, and when it
|
|
877
|
+
// could start (the person's Start, else the post) and reported.
|
|
878
|
+
agentWork() {
|
|
879
|
+
return execute("all", `select coalesce(claimed_by, target_agent) as agent, status, target_agent is not null as targeted,
|
|
880
|
+
coalesce(run_authorized_at, created_at) as started_at, reported_at from board_tasks
|
|
881
|
+
where kind in ('task', 'wake') and removed_at is null and coalesce(claimed_by, target_agent) is not null
|
|
882
|
+
order by updated_at desc limit 5000`);
|
|
883
|
+
},
|
|
822
884
|
listTasks({ agent, status, all = false, limit = 20 } = {}) {
|
|
823
885
|
const clauses = ["b.removed_at is null"];
|
|
824
886
|
const params = { limit: Math.max(1, Math.min(100, Number(limit) || 20)) };
|
package/lib/swarm.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { readRunSteps } from "./runner.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { nameOf, resolveMember } from "./members.mjs";
|
|
6
|
+
import { setNote, swarmBlock, swarmCards, swarmDigest } from "./cards.mjs";
|
|
6
7
|
|
|
7
8
|
// The person's swarm as threads. A thread is a goal under the hood: its room is the transcript, its team the agents in
|
|
8
9
|
// it, its folder where coding agents work, and it runs on its own authority. A message the person sends wakes the
|
|
@@ -11,9 +12,6 @@ import { member, nameOf } from "./members.mjs";
|
|
|
11
12
|
|
|
12
13
|
const PROCESS_NAMES = { "claude-code": "claude", codex: "codex", pi: "pi", opencode: "opencode" };
|
|
13
14
|
const SAY_LIMIT = 4_000;
|
|
14
|
-
// How a member hears that work is waiting for it, by its roster wake.
|
|
15
|
-
const ANSWERS = { run: "starts as soon as it may", email: "woken by email when work is posted", webhook: "woken when work is posted", "local-webhook": "woken when work is posted",
|
|
16
|
-
schedule: "picks work up on its own schedule", none: "only when the person next talks to it" };
|
|
17
15
|
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
18
16
|
|
|
19
17
|
// The id an agent resumes a session by: the index keys sessions by their file name.
|
|
@@ -155,10 +153,11 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
155
153
|
const mine = request.teamOnly ? store.goals({ all: true }).filter((thread) => thread.goal.team.includes(agent)).map((thread) => thread.id) : null;
|
|
156
154
|
return { ok: true, threads: store.searchThreads(query, { goalIds: mine, k: 8 }), sessions: request.teamOnly ? [] : store.search({ query, limit: 5 }) };
|
|
157
155
|
}
|
|
158
|
-
// Who can be asked
|
|
156
|
+
// Who can be asked: every other agent's card (cards.mjs), and the same cards as the block an agent is told when its
|
|
157
|
+
// session starts. Open to every caller, so an agent picks a name instead of guessing one.
|
|
159
158
|
if (command === "board.swarm.agents") {
|
|
160
|
-
const
|
|
161
|
-
return { ok: true, agents
|
|
159
|
+
const agents = swarmCards(store, roster().filter((entry) => entry.id !== agent));
|
|
160
|
+
return { ok: true, agents, text: swarmBlock(agents), digest: swarmDigest(agents) };
|
|
162
161
|
}
|
|
163
162
|
// Any of the person's agents may start a thread and stop one it started. The thread carries the agent's trust, so
|
|
164
163
|
// nothing in it runs without the person's Start, and one started through Harness gets no coordinator rounds (they
|
|
@@ -186,6 +185,12 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
186
185
|
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
187
186
|
}
|
|
188
187
|
if (agent !== "user") return { ok: false, error: "only the person writes in, stops or resumes a thread they did not start; post in it with post_to_room" };
|
|
188
|
+
// The person's own note on one agent's card ("use it for reviews"), told to every other agent; no text clears it.
|
|
189
|
+
if (command === "board.swarm.note") {
|
|
190
|
+
const named = resolveMember(request.target);
|
|
191
|
+
if (!named.ok) return { ok: false, error: named.candidates.length ? `"${request.target}" could be ${named.candidates.join(" or ")}; name one` : `no agent called "${request.target}"` };
|
|
192
|
+
return { ok: true, agent: named.id, note: setNote(store, named.id, request.text) };
|
|
193
|
+
}
|
|
189
194
|
// The coordinator's record: what each round saw, chose and cost. The evidence for its limits.
|
|
190
195
|
if (command === "board.swarm.rounds") return { ok: true, rounds: store.rounds(request.id ? String(request.id) : null, request.limit) };
|
|
191
196
|
if (command === "board.swarm.coordinator") {
|
package/lib/t3.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { readJson, writeJsonAtomic } from "./files.mjs";
|
|
6
|
+
import { paths } from "./paths.mjs";
|
|
7
|
+
import { t3Home } from "./places.mjs";
|
|
8
|
+
|
|
9
|
+
// 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
|
+
// person approves it inside T3, choosing what it may do; T3 asks for a pairing code from its Settings → Connections, and
|
|
11
|
+
// no T3 account. From then on a message the person sends a session running in T3 Code (from the desktop app or the
|
|
12
|
+
// phone) goes into that session's T3 thread as T3 sends any message: started when the thread is idle, queued behind its
|
|
13
|
+
// turn when it is busy. T3 shows it as sent by an agent through MCP. The key lives in ~/.harness/t3.json (0600), works
|
|
14
|
+
// only on T3's /mcp on this computer's loopback, lasts 30 days, and is revoked in T3 under Settings → Connections.
|
|
15
|
+
|
|
16
|
+
// T3's desktop app binds the first free port from 3773 up (T3 apps/server/src/auth/utils.ts).
|
|
17
|
+
const FIRST_PORT = 3773;
|
|
18
|
+
const PORTS = 20;
|
|
19
|
+
// Its descriptor answered in at most 1.7 ms over 40 tries while T3 ran four busy sessions (October 12); a closed port
|
|
20
|
+
// refuses in under 0.4 ms. The limit only keeps a port held by something else from stalling the search.
|
|
21
|
+
const PROBE_MS = 1_000;
|
|
22
|
+
const PROTOCOL = "2025-06-18";
|
|
23
|
+
|
|
24
|
+
const b64u = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
25
|
+
const file = () => path.join(paths.home, "t3.json");
|
|
26
|
+
|
|
27
|
+
class T3Error extends Error {
|
|
28
|
+
constructor(code, message) { super(message); this.code = code; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** This computer's T3 Code: its environment (from T3's own data folder) and the loopback address that answers as it. */
|
|
32
|
+
export async function findT3(home = t3Home(), hint = null) {
|
|
33
|
+
let environmentId = "";
|
|
34
|
+
try { environmentId = fs.readFileSync(path.join(home, "environment-id"), "utf8").trim(); } catch { return null; }
|
|
35
|
+
const answers = async (origin) => {
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(`${origin}/.well-known/t3/environment`, { signal: AbortSignal.timeout(PROBE_MS) });
|
|
38
|
+
return response.ok && (await response.json())?.environmentId === environmentId;
|
|
39
|
+
} catch { return false; }
|
|
40
|
+
};
|
|
41
|
+
if (hint && await answers(hint)) return { origin: hint, environmentId };
|
|
42
|
+
for (let port = FIRST_PORT; port < FIRST_PORT + PORTS; port++) {
|
|
43
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
44
|
+
if (origin !== hint && await answers(origin)) return { origin, environmentId };
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What Harness may do in T3 Code: "send", "read", "expired", or null before the person approves it. */
|
|
50
|
+
export function t3Access() {
|
|
51
|
+
const saved = readJson(file(), null);
|
|
52
|
+
if (!saved?.token) return null;
|
|
53
|
+
if (!(Date.parse(saved.expiresAt) > Date.now())) return { state: "expired", at: saved.expiresAt };
|
|
54
|
+
return { state: String(saved.scope || "").split(" ").includes("orchestration:operate") ? "send" : "read", until: saved.expiresAt };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function forgetT3() {
|
|
58
|
+
try { fs.rmSync(file()); } catch {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const json = async (url, body) => {
|
|
62
|
+
const response = await fetch(url, body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : {});
|
|
63
|
+
const value = await response.json().catch(() => null);
|
|
64
|
+
if (!response.ok || !value) throw new T3Error("t3", value?.error_description || `T3 Code answered ${response.status}`);
|
|
65
|
+
return value;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const page = (title, words) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>`
|
|
69
|
+
+ `<body style="font-family:-apple-system,system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0"><main style="max-width:420px;padding:24px"><h1 style="font-size:20px">${title}</h1><p style="line-height:1.5">${words}</p></main>`;
|
|
70
|
+
|
|
71
|
+
let pending = null;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Starts the sign-in: registers Harness with this computer's T3 Code and returns the page where the person approves it.
|
|
75
|
+
* The approval comes back to a loopback address here, which keeps listening until it does or another sign-in starts.
|
|
76
|
+
*/
|
|
77
|
+
export async function startT3SignIn() {
|
|
78
|
+
const t3 = await findT3(t3Home(), readJson(file(), null)?.origin ?? null);
|
|
79
|
+
if (!t3) throw new T3Error("not_running", "T3 Code is not running on this computer. Open it, then try again.");
|
|
80
|
+
// T3 Code takes outside agents from its 0.0.46 builds on; an older one has no sign-in to offer.
|
|
81
|
+
const server = await json(`${t3.origin}/.well-known/oauth-authorization-server`).catch(() => {
|
|
82
|
+
throw new T3Error("too_old", "This T3 Code can't approve outside agents yet. Update it (0.0.46 or newer), then try again.");
|
|
83
|
+
});
|
|
84
|
+
pending?.close();
|
|
85
|
+
const listener = http.createServer();
|
|
86
|
+
pending = listener;
|
|
87
|
+
await new Promise((resolve, reject) => { listener.once("error", reject); listener.listen(0, "127.0.0.1", resolve); });
|
|
88
|
+
const redirect = `http://127.0.0.1:${listener.address().port}/t3/callback`;
|
|
89
|
+
const client = await json(server.registration_endpoint, { client_name: "Harness", redirect_uris: [redirect] });
|
|
90
|
+
const verifier = b64u(randomBytes(32));
|
|
91
|
+
const state = b64u(randomBytes(16));
|
|
92
|
+
const resource = `${t3.origin}/mcp`;
|
|
93
|
+
listener.on("request", async (request, response) => {
|
|
94
|
+
const url = new URL(request.url, redirect);
|
|
95
|
+
const answer = (status, title, words) => { response.writeHead(status, { "content-type": "text/html; charset=utf-8" }); response.end(page(title, words)); };
|
|
96
|
+
if (url.pathname !== "/t3/callback" || url.searchParams.get("state") !== state) return answer(400, "Not this sign-in", "This page is not the sign-in Harness started. Start it again from Harness.");
|
|
97
|
+
const done = () => { listener.close(); if (pending === listener) pending = null; };
|
|
98
|
+
if (url.searchParams.get("error")) { done(); return answer(200, "Harness was not approved", "Nothing changed. Harness keeps leaving your messages for T3 Code's sessions to pick up themselves."); }
|
|
99
|
+
if (url.searchParams.has("iss") && url.searchParams.get("iss") !== server.issuer) return answer(400, "Not this sign-in", "The approval came from another server.");
|
|
100
|
+
try {
|
|
101
|
+
const exchanged = await fetch(server.token_endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
102
|
+
body: new URLSearchParams({ grant_type: "authorization_code", code: url.searchParams.get("code") || "", redirect_uri: redirect, client_id: client.client_id, code_verifier: verifier, resource }) });
|
|
103
|
+
const token = await exchanged.json().catch(() => null);
|
|
104
|
+
if (!exchanged.ok || !token?.access_token) throw new Error(token?.error_description || `T3 Code answered ${exchanged.status}`);
|
|
105
|
+
writeJsonAtomic(file(), { origin: t3.origin, environmentId: t3.environmentId, clientId: client.client_id, token: token.access_token, scope: token.scope || "",
|
|
106
|
+
expiresAt: new Date(Date.now() + Number(token.expires_in || 0) * 1000).toISOString(), approvedAt: new Date().toISOString() });
|
|
107
|
+
done();
|
|
108
|
+
const send = t3Access()?.state === "send";
|
|
109
|
+
answer(200, send ? "Harness can send into T3 Code" : "Harness can read T3 Code", send
|
|
110
|
+
? "Messages you send your T3 Code sessions from Harness, on this computer or your phone, now go straight into their threads. You can close this tab."
|
|
111
|
+
: "You approved read only, so messages still wait for your sessions to pick them up. To send into T3 Code's threads, approve Harness again with the access your threads run with.");
|
|
112
|
+
} catch (error) {
|
|
113
|
+
answer(502, "The sign-in did not finish", `${String(error.message).replace(/[<>&]/g, "")}. Start it again from Harness.`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
const url = `${server.authorization_endpoint}?${new URLSearchParams({ response_type: "code", client_id: client.client_id, redirect_uri: redirect,
|
|
117
|
+
code_challenge: b64u(createHash("sha256").update(verifier).digest()), code_challenge_method: "S256", state, resource })}`;
|
|
118
|
+
return { url };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One JSON-RPC message to T3's /mcp (Streamable HTTP): the answer comes as JSON or as a server-sent event. */
|
|
122
|
+
async function rpc(origin, token, session, message) {
|
|
123
|
+
const response = await fetch(`${origin}/mcp`, { method: "POST", body: JSON.stringify({ jsonrpc: "2.0", ...message }), headers: {
|
|
124
|
+
authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": PROTOCOL,
|
|
125
|
+
...(session ? { "mcp-session-id": session } : {}) } });
|
|
126
|
+
if (response.status === 401 || response.status === 403) throw new T3Error("expired", "T3 Code no longer accepts Harness's sign-in.");
|
|
127
|
+
if (!response.ok) throw new T3Error("t3", `T3 Code answered ${response.status}`);
|
|
128
|
+
const next = response.headers.get("mcp-session-id") || session;
|
|
129
|
+
if (message.id === undefined) return { session: next };
|
|
130
|
+
const text = await response.text();
|
|
131
|
+
const replies = (response.headers.get("content-type") || "").includes("text/event-stream")
|
|
132
|
+
? text.split("\n").filter((line) => line.startsWith("data:")).map((line) => { try { return JSON.parse(line.slice(5)); } catch { return null; } })
|
|
133
|
+
: [JSON.parse(text)];
|
|
134
|
+
const reply = replies.find((entry) => entry?.id === message.id);
|
|
135
|
+
if (!reply) throw new T3Error("t3", "T3 Code sent no answer");
|
|
136
|
+
if (reply.error) throw new T3Error("t3", reply.error.message || "T3 Code refused");
|
|
137
|
+
return { session: next, result: reply.result };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function callTool(origin, token, name, args) {
|
|
141
|
+
let { session } = await rpc(origin, token, null, { id: 1, method: "initialize", params: { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: "Harness", version: "1" } } });
|
|
142
|
+
try {
|
|
143
|
+
({ session } = await rpc(origin, token, session, { method: "notifications/initialized" }));
|
|
144
|
+
const { result } = await rpc(origin, token, session, { id: 2, method: "tools/call", params: { name, arguments: args } });
|
|
145
|
+
const words = (result?.content || []).map((part) => part?.text || "").join("\n").trim();
|
|
146
|
+
if (result?.isError) throw new T3Error("refused", words || "T3 Code refused");
|
|
147
|
+
if (result?.structuredContent) return result.structuredContent;
|
|
148
|
+
try { return JSON.parse(words); } catch { return {}; }
|
|
149
|
+
} finally {
|
|
150
|
+
if (session) fetch(`${origin}/mcp`, { method: "DELETE", headers: { authorization: `Bearer ${token}`, "mcp-session-id": session } }).catch(() => {});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Sends the person's words into a T3 Code thread: "started" or "queued" behind its turn. */
|
|
155
|
+
export async function sendToT3(threadId, text) {
|
|
156
|
+
const saved = readJson(file(), null);
|
|
157
|
+
const access = t3Access();
|
|
158
|
+
if (!access) throw new T3Error("not_connected", "Harness is not approved in T3 Code yet.");
|
|
159
|
+
if (access.state === "expired") throw new T3Error("expired", "Harness's approval in T3 Code ran out.");
|
|
160
|
+
if (access.state !== "send") throw new T3Error("read_only", "Harness was approved in T3 Code to read only.");
|
|
161
|
+
// T3 may come back on another port after a restart: the saved address first, else the search again.
|
|
162
|
+
const t3 = await findT3(t3Home(), saved.origin);
|
|
163
|
+
if (!t3 || t3.environmentId !== saved.environmentId) throw new T3Error("not_running", "T3 Code is not running on this computer.");
|
|
164
|
+
if (t3.origin !== saved.origin) writeJsonAtomic(file(), { ...saved, origin: t3.origin });
|
|
165
|
+
try {
|
|
166
|
+
const sent = await callTool(t3.origin, saved.token, "t3_thread_send", { threadId, message: text, mode: "queue", clientRequestId: randomUUID() });
|
|
167
|
+
return sent.delivery === "queued" ? "queued" : "started";
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error.code === "expired") writeJsonAtomic(file(), { ...saved, expiresAt: new Date(0).toISOString() });
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tryharness",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.10",
|
|
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"
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
|
|
37
37
|
},
|
|
38
38
|
"optionalDependencies": {
|
|
39
|
-
"tryharness-desktop-darwin-arm64": "0.7.
|
|
40
|
-
"tryharness-desktop-darwin-x64": "0.7.
|
|
39
|
+
"tryharness-desktop-darwin-arm64": "0.7.10",
|
|
40
|
+
"tryharness-desktop-darwin-x64": "0.7.10",
|
|
41
41
|
"tryharness-desktop-linux-x64": "0.7.4",
|
|
42
42
|
"tryharness-desktop-win32-x64": "0.7.4"
|
|
43
43
|
}
|