tryharness 0.7.7 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/swarm.mjs CHANGED
@@ -114,6 +114,9 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
114
114
 
115
115
  const sessionOpen = (agent, sessionId) => sessions().find((session) => session.agent === agent && session.sessionId === sessionId)?.open ?? false;
116
116
 
117
+ // The agent a request names in `to`: null for none, false for one no thread can have.
118
+ const recipient = (request) => (request.to == null || request.to === "" ? null : roomAgents.includes(String(request.to)) ? String(request.to) : false);
119
+
117
120
  const lastFolder = (agent) => store.recentSessions(100).find((session) => session.agent === agent && isDir(session.cwd))?.cwd ?? null;
118
121
 
119
122
  const read = (thread) => {
@@ -125,16 +128,17 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
125
128
  return { ok: true, thread: summary(thread), posts: store.roomPosts(thread.id, 0, 500), runs };
126
129
  };
127
130
 
128
- const post = (thread, text) => {
131
+ // `to`: the one agent the person wrote to (the desktop's agent list), so their words go as written, with no @name.
132
+ const post = (thread, text, to = null) => {
129
133
  // A new name in the person's message joins the thread.
130
- const joining = mentionsIn(text, roomAgents).filter((agent) => !thread.goal.team.includes(agent));
134
+ const joining = [...new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])])].filter((agent) => !thread.goal.team.includes(agent));
131
135
  if (joining.length) store.setGoalTeam(thread.id, [...thread.goal.team, ...joining]);
132
136
  if (thread.status !== "open") {
133
137
  store.setGoalState(thread.id, { status: "open" });
134
138
  store.roomPost({ goalId: thread.id, author: "user", kind: "note", text: "Picked up again by you." });
135
139
  }
136
140
  const current = store.getTask(thread.id);
137
- return goalCommand({ command: "board.room.post", id: thread.id, text, mentions: addressed(current, text) }, "user");
141
+ return goalCommand({ command: "board.room.post", id: thread.id, text, mentions: to ? [to] : addressed(current, text) }, "user");
138
142
  };
139
143
 
140
144
  return function swarmCommand(request, agent) {
@@ -198,7 +202,9 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
198
202
  const from = request.continueSession && typeof request.continueSession === "object" ? request.continueSession : null;
199
203
  let folder = request.folder ? String(request.folder) : null;
200
204
  if (folder && !isDir(folder)) return { ok: false, error: `${folder} is not an absolute path to a folder` };
201
- const team = new Set(mentionsIn(text, roomAgents));
205
+ const to = recipient(request);
206
+ if (to === false) return { ok: false, error: `${request.to} can't be written to in a thread` };
207
+ const team = new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])]);
202
208
  if (from) {
203
209
  const agentName = String(from.agent || ""), sessionId = String(from.sessionId || "");
204
210
  if (!PROCESS_NAMES[agentName]) return { ok: false, error: "only a coding agent's session continues in a thread" };
@@ -245,7 +251,9 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
245
251
  if (command === "board.swarm.post") {
246
252
  const text = String(request.text || "").trim().slice(0, 20_000);
247
253
  if (!text) return { ok: false, error: "a message needs text" };
248
- const posted = post(thread, text);
254
+ const to = recipient(request);
255
+ if (to === false) return { ok: false, error: `${request.to} can't be written to in a thread` };
256
+ const posted = post(thread, text, to);
249
257
  return posted.ok ? { ...posted, thread: summary(store.getTask(thread.id)) } : posted;
250
258
  }
251
259
  if (command === "board.swarm.stop") {
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.7",
3
+ "version": "0.7.9",
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.7",
40
- "tryharness-desktop-darwin-x64": "0.7.7",
39
+ "tryharness-desktop-darwin-arm64": "0.7.9",
40
+ "tryharness-desktop-darwin-x64": "0.7.9",
41
41
  "tryharness-desktop-linux-x64": "0.7.4",
42
42
  "tryharness-desktop-win32-x64": "0.7.4"
43
43
  }