tryharness 0.7.23 → 0.7.26

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.
Files changed (61) hide show
  1. package/assets/opencode-plugin.js +48 -11
  2. package/assets/pi-extension.ts +105 -26
  3. package/cli.mjs +1238 -322
  4. package/lib/activity.mjs +197 -40
  5. package/lib/adapters.mjs +337 -54
  6. package/lib/agentnode.mjs +175 -35
  7. package/lib/atlas.mjs +383 -83
  8. package/lib/cards.mjs +174 -40
  9. package/lib/claudemem.mjs +74 -18
  10. package/lib/commands.mjs +13 -5
  11. package/lib/context.mjs +409 -71
  12. package/lib/control-client.mjs +5 -2
  13. package/lib/coordinator.mjs +274 -57
  14. package/lib/daemon.mjs +5268 -1024
  15. package/lib/desktop.mjs +149 -35
  16. package/lib/digest.mjs +54 -13
  17. package/lib/environment.mjs +103 -21
  18. package/lib/files.mjs +246 -13
  19. package/lib/firewall.mjs +169 -35
  20. package/lib/folders.mjs +179 -36
  21. package/lib/frames.mjs +214 -40
  22. package/lib/inference.mjs +463 -126
  23. package/lib/learn.mjs +78 -16
  24. package/lib/live.mjs +286 -61
  25. package/lib/localmodels.mjs +424 -83
  26. package/lib/mcp-stdio.mjs +868 -151
  27. package/lib/media.mjs +138 -28
  28. package/lib/members.json +1 -1
  29. package/lib/members.mjs +35 -9
  30. package/lib/models.mjs +342 -80
  31. package/lib/network.mjs +201 -42
  32. package/lib/notify.mjs +49 -12
  33. package/lib/nudge.mjs +184 -33
  34. package/lib/ocr.mjs +223 -60
  35. package/lib/onboard.mjs +321 -70
  36. package/lib/parsers.mjs +132 -41
  37. package/lib/paths.mjs +33 -10
  38. package/lib/personal.mjs +51 -13
  39. package/lib/phones.mjs +384 -60
  40. package/lib/places.mjs +555 -107
  41. package/lib/provenance.mjs +19 -4
  42. package/lib/receipts.mjs +51 -11
  43. package/lib/redact.mjs +512 -116
  44. package/lib/runner.mjs +345 -55
  45. package/lib/runtime-install.mjs +55 -14
  46. package/lib/screens.mjs +154 -38
  47. package/lib/sealed.mjs +157 -28
  48. package/lib/secrets.mjs +18 -5
  49. package/lib/service.mjs +170 -34
  50. package/lib/snippets.mjs +94 -18
  51. package/lib/speech.mjs +688 -175
  52. package/lib/store.mjs +1654 -307
  53. package/lib/swarm.mjs +596 -117
  54. package/lib/t3.mjs +400 -79
  55. package/lib/trails.mjs +285 -61
  56. package/lib/ui.mjs +95 -23
  57. package/lib/vectors.mjs +5 -2
  58. package/lib/wakes.mjs +284 -68
  59. package/lib/watches.mjs +247 -48
  60. package/lib/worktrees.mjs +186 -38
  61. package/package.json +3 -3
package/lib/t3.mjs CHANGED
@@ -26,23 +26,39 @@ const b64u = (bytes) => Buffer.from(bytes).toString("base64url");
26
26
  const file = () => path.join(paths.home, "t3.json");
27
27
 
28
28
  class T3Error extends Error {
29
- constructor(code, message) { super(message); this.code = code; }
29
+ constructor(code, message) {
30
+ super(message);
31
+ this.code = code;
32
+ }
30
33
  }
31
34
 
32
35
  /** This computer's T3 Code: its environment (from T3's own data folder) and the loopback address that answers as it. */
33
36
  export async function findT3(home = t3Home(), hint = null) {
34
37
  let environmentId = "";
35
- try { environmentId = fs.readFileSync(path.join(home, "environment-id"), "utf8").trim(); } catch { return null; }
38
+ try {
39
+ environmentId = fs
40
+ .readFileSync(path.join(home, "environment-id"), "utf8")
41
+ .trim();
42
+ } catch {
43
+ return null;
44
+ }
36
45
  const answers = async (origin) => {
37
46
  try {
38
- const response = await fetch(`${origin}/.well-known/t3/environment`, { signal: AbortSignal.timeout(PROBE_MS) });
39
- return response.ok && (await response.json())?.environmentId === environmentId;
40
- } catch { return false; }
47
+ const response = await fetch(`${origin}/.well-known/t3/environment`, {
48
+ signal: AbortSignal.timeout(PROBE_MS),
49
+ });
50
+ return (
51
+ response.ok && (await response.json())?.environmentId === environmentId
52
+ );
53
+ } catch {
54
+ return false;
55
+ }
41
56
  };
42
- if (hint && await answers(hint)) return { origin: hint, environmentId };
57
+ if (hint && (await answers(hint))) return { origin: hint, environmentId };
43
58
  for (let port = FIRST_PORT; port < FIRST_PORT + PORTS; port++) {
44
59
  const origin = `http://127.0.0.1:${port}`;
45
- if (origin !== hint && await answers(origin)) return { origin, environmentId };
60
+ if (origin !== hint && (await answers(origin)))
61
+ return { origin, environmentId };
46
62
  }
47
63
  return null;
48
64
  }
@@ -51,23 +67,47 @@ export async function findT3(home = t3Home(), hint = null) {
51
67
  export function t3Access() {
52
68
  const saved = readJson(file(), null);
53
69
  if (!saved?.token) return null;
54
- if (!(Date.parse(saved.expiresAt) > Date.now())) return { state: "expired", at: saved.expiresAt };
55
- return { state: String(saved.scope || "").split(" ").includes("orchestration:operate") ? "send" : "read", until: saved.expiresAt };
70
+ if (!(Date.parse(saved.expiresAt) > Date.now()))
71
+ return { state: "expired", at: saved.expiresAt };
72
+ return {
73
+ state: String(saved.scope || "")
74
+ .split(" ")
75
+ .includes("orchestration:operate")
76
+ ? "send"
77
+ : "read",
78
+ until: saved.expiresAt,
79
+ };
56
80
  }
57
81
 
58
82
  export function forgetT3() {
59
- try { fs.rmSync(file()); } catch {}
83
+ try {
84
+ fs.rmSync(file());
85
+ } catch {}
60
86
  }
61
87
 
62
88
  const json = async (url, body) => {
63
- const response = await fetch(url, body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : {});
89
+ const response = await fetch(
90
+ url,
91
+ body
92
+ ? {
93
+ method: "POST",
94
+ headers: { "content-type": "application/json" },
95
+ body: JSON.stringify(body),
96
+ }
97
+ : {}
98
+ );
64
99
  const value = await response.json().catch(() => null);
65
- if (!response.ok || !value) throw new T3Error("t3", value?.error_description || `T3 Code answered ${response.status}`);
100
+ if (!response.ok || !value)
101
+ throw new T3Error(
102
+ "t3",
103
+ value?.error_description || `T3 Code answered ${response.status}`
104
+ );
66
105
  return value;
67
106
  };
68
107
 
69
- const page = (title, words) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>`
70
- + `<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>`;
108
+ const page = (title, words) =>
109
+ `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>` +
110
+ `<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>`;
71
111
 
72
112
  let pending = null;
73
113
 
@@ -77,78 +117,215 @@ let pending = null;
77
117
  */
78
118
  export async function startT3SignIn() {
79
119
  const t3 = await findT3(t3Home(), readJson(file(), null)?.origin ?? null);
80
- if (!t3) throw new T3Error("not_running", "T3 Code is not running on this computer. Open it, then try again.");
120
+ if (!t3)
121
+ throw new T3Error(
122
+ "not_running",
123
+ "T3 Code is not running on this computer. Open it, then try again."
124
+ );
81
125
  // T3 Code takes outside agents from its 0.0.46 builds on; an older one has no sign-in to offer.
82
- const server = await json(`${t3.origin}/.well-known/oauth-authorization-server`).catch(() => {
83
- throw new T3Error("too_old", "This T3 Code can't approve outside agents yet. Update it (0.0.46 or newer), then try again.");
126
+ const server = await json(
127
+ `${t3.origin}/.well-known/oauth-authorization-server`
128
+ ).catch(() => {
129
+ throw new T3Error(
130
+ "too_old",
131
+ "This T3 Code can't approve outside agents yet. Update it (0.0.46 or newer), then try again."
132
+ );
84
133
  });
85
134
  pending?.close();
86
135
  const listener = http.createServer();
87
136
  pending = listener;
88
- await new Promise((resolve, reject) => { listener.once("error", reject); listener.listen(0, "127.0.0.1", resolve); });
137
+ await new Promise((resolve, reject) => {
138
+ listener.once("error", reject);
139
+ listener.listen(0, "127.0.0.1", resolve);
140
+ });
89
141
  const redirect = `http://127.0.0.1:${listener.address().port}/t3/callback`;
90
- const client = await json(server.registration_endpoint, { client_name: "Harness", redirect_uris: [redirect] });
142
+ const client = await json(server.registration_endpoint, {
143
+ client_name: "Harness",
144
+ redirect_uris: [redirect],
145
+ });
91
146
  const verifier = b64u(randomBytes(32));
92
147
  const state = b64u(randomBytes(16));
93
148
  const resource = `${t3.origin}/mcp`;
94
149
  listener.on("request", async (request, response) => {
95
150
  const url = new URL(request.url, redirect);
96
- const answer = (status, title, words) => { response.writeHead(status, { "content-type": "text/html; charset=utf-8" }); response.end(page(title, words)); };
97
- 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.");
98
- const done = () => { listener.close(); if (pending === listener) pending = null; };
99
- 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."); }
100
- if (url.searchParams.has("iss") && url.searchParams.get("iss") !== server.issuer) return answer(400, "Not this sign-in", "The approval came from another server.");
151
+ const answer = (status, title, words) => {
152
+ response.writeHead(status, {
153
+ "content-type": "text/html; charset=utf-8",
154
+ });
155
+ response.end(page(title, words));
156
+ };
157
+ if (
158
+ url.pathname !== "/t3/callback" ||
159
+ url.searchParams.get("state") !== state
160
+ )
161
+ return answer(
162
+ 400,
163
+ "Not this sign-in",
164
+ "This page is not the sign-in Harness started. Start it again from Harness."
165
+ );
166
+ const done = () => {
167
+ listener.close();
168
+ if (pending === listener) pending = null;
169
+ };
170
+ if (url.searchParams.get("error")) {
171
+ done();
172
+ return answer(
173
+ 200,
174
+ "Harness was not approved",
175
+ "Nothing changed. Harness keeps leaving your messages for T3 Code's sessions to pick up themselves."
176
+ );
177
+ }
178
+ if (
179
+ url.searchParams.has("iss") &&
180
+ url.searchParams.get("iss") !== server.issuer
181
+ )
182
+ return answer(
183
+ 400,
184
+ "Not this sign-in",
185
+ "The approval came from another server."
186
+ );
101
187
  try {
102
- const exchanged = await fetch(server.token_endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
103
- body: new URLSearchParams({ grant_type: "authorization_code", code: url.searchParams.get("code") || "", redirect_uri: redirect, client_id: client.client_id, code_verifier: verifier, resource }) });
188
+ const exchanged = await fetch(server.token_endpoint, {
189
+ method: "POST",
190
+ headers: { "content-type": "application/x-www-form-urlencoded" },
191
+ body: new URLSearchParams({
192
+ grant_type: "authorization_code",
193
+ code: url.searchParams.get("code") || "",
194
+ redirect_uri: redirect,
195
+ client_id: client.client_id,
196
+ code_verifier: verifier,
197
+ resource,
198
+ }),
199
+ });
104
200
  const token = await exchanged.json().catch(() => null);
105
- if (!exchanged.ok || !token?.access_token) throw new Error(token?.error_description || `T3 Code answered ${exchanged.status}`);
106
- writeJsonAtomic(file(), { origin: t3.origin, environmentId: t3.environmentId, clientId: client.client_id, token: token.access_token, scope: token.scope || "",
107
- expiresAt: new Date(Date.now() + Number(token.expires_in || 0) * 1000).toISOString(), approvedAt: new Date().toISOString() });
201
+ if (!exchanged.ok || !token?.access_token)
202
+ throw new Error(
203
+ token?.error_description || `T3 Code answered ${exchanged.status}`
204
+ );
205
+ writeJsonAtomic(file(), {
206
+ origin: t3.origin,
207
+ environmentId: t3.environmentId,
208
+ clientId: client.client_id,
209
+ token: token.access_token,
210
+ scope: token.scope || "",
211
+ expiresAt: new Date(
212
+ Date.now() + Number(token.expires_in || 0) * 1000
213
+ ).toISOString(),
214
+ approvedAt: new Date().toISOString(),
215
+ });
108
216
  done();
109
217
  const send = t3Access()?.state === "send";
110
- answer(200, send ? "Harness can send into T3 Code" : "Harness can read T3 Code", send
111
- ? "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."
112
- : "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.");
218
+ answer(
219
+ 200,
220
+ send ? "Harness can send into T3 Code" : "Harness can read T3 Code",
221
+ send
222
+ ? "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."
223
+ : "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."
224
+ );
113
225
  } catch (error) {
114
- answer(502, "The sign-in did not finish", `${String(error.message).replace(/[<>&]/g, "")}. Start it again from Harness.`);
226
+ answer(
227
+ 502,
228
+ "The sign-in did not finish",
229
+ `${String(error.message).replace(/[<>&]/g, "")}. Start it again from Harness.`
230
+ );
115
231
  }
116
232
  });
117
- const url = `${server.authorization_endpoint}?${new URLSearchParams({ response_type: "code", client_id: client.client_id, redirect_uri: redirect,
118
- code_challenge: b64u(createHash("sha256").update(verifier).digest()), code_challenge_method: "S256", state, resource })}`;
233
+ const url = `${server.authorization_endpoint}?${new URLSearchParams({
234
+ response_type: "code",
235
+ client_id: client.client_id,
236
+ redirect_uri: redirect,
237
+ code_challenge: b64u(createHash("sha256").update(verifier).digest()),
238
+ code_challenge_method: "S256",
239
+ state,
240
+ resource,
241
+ })}`;
119
242
  return { url };
120
243
  }
121
244
 
122
245
  /** One JSON-RPC message to T3's /mcp (Streamable HTTP): the answer comes as JSON or as a server-sent event. */
123
246
  async function rpc(origin, token, session, message) {
124
- const response = await fetch(`${origin}/mcp`, { method: "POST", body: JSON.stringify({ jsonrpc: "2.0", ...message }), headers: {
125
- authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-protocol-version": PROTOCOL,
126
- ...(session ? { "mcp-session-id": session } : {}) } });
127
- if (response.status === 401 || response.status === 403) throw new T3Error("expired", "T3 Code no longer accepts Harness's sign-in.");
128
- if (!response.ok) throw new T3Error("t3", `T3 Code answered ${response.status}`);
247
+ const response = await fetch(`${origin}/mcp`, {
248
+ method: "POST",
249
+ body: JSON.stringify({ jsonrpc: "2.0", ...message }),
250
+ headers: {
251
+ authorization: `Bearer ${token}`,
252
+ "content-type": "application/json",
253
+ accept: "application/json, text/event-stream",
254
+ "mcp-protocol-version": PROTOCOL,
255
+ ...(session ? { "mcp-session-id": session } : {}),
256
+ },
257
+ });
258
+ if (response.status === 401 || response.status === 403)
259
+ throw new T3Error(
260
+ "expired",
261
+ "T3 Code no longer accepts Harness's sign-in."
262
+ );
263
+ if (!response.ok)
264
+ throw new T3Error("t3", `T3 Code answered ${response.status}`);
129
265
  const next = response.headers.get("mcp-session-id") || session;
130
266
  if (message.id === undefined) return { session: next };
131
267
  const text = await response.text();
132
- const replies = (response.headers.get("content-type") || "").includes("text/event-stream")
133
- ? text.split("\n").filter((line) => line.startsWith("data:")).map((line) => { try { return JSON.parse(line.slice(5)); } catch { return null; } })
268
+ const replies = (response.headers.get("content-type") || "").includes(
269
+ "text/event-stream"
270
+ )
271
+ ? text
272
+ .split("\n")
273
+ .filter((line) => line.startsWith("data:"))
274
+ .map((line) => {
275
+ try {
276
+ return JSON.parse(line.slice(5));
277
+ } catch {
278
+ return null;
279
+ }
280
+ })
134
281
  : [JSON.parse(text)];
135
282
  const reply = replies.find((entry) => entry?.id === message.id);
136
283
  if (!reply) throw new T3Error("t3", "T3 Code sent no answer");
137
- if (reply.error) throw new T3Error("t3", reply.error.message || "T3 Code refused");
284
+ if (reply.error)
285
+ throw new T3Error("t3", reply.error.message || "T3 Code refused");
138
286
  return { session: next, result: reply.result };
139
287
  }
140
288
 
141
289
  async function callTool(origin, token, name, args) {
142
- let { session } = await rpc(origin, token, null, { id: 1, method: "initialize", params: { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: "Harness", version: "1" } } });
290
+ let { session } = await rpc(origin, token, null, {
291
+ id: 1,
292
+ method: "initialize",
293
+ params: {
294
+ protocolVersion: PROTOCOL,
295
+ capabilities: {},
296
+ clientInfo: { name: "Harness", version: "1" },
297
+ },
298
+ });
143
299
  try {
144
- ({ session } = await rpc(origin, token, session, { method: "notifications/initialized" }));
145
- const { result } = await rpc(origin, token, session, { id: 2, method: "tools/call", params: { name, arguments: args } });
146
- const words = (result?.content || []).map((part) => part?.text || "").join("\n").trim();
147
- if (result?.isError) throw new T3Error("refused", words || "T3 Code refused");
300
+ ({ session } = await rpc(origin, token, session, {
301
+ method: "notifications/initialized",
302
+ }));
303
+ const { result } = await rpc(origin, token, session, {
304
+ id: 2,
305
+ method: "tools/call",
306
+ params: { name, arguments: args },
307
+ });
308
+ const words = (result?.content || [])
309
+ .map((part) => part?.text || "")
310
+ .join("\n")
311
+ .trim();
312
+ if (result?.isError)
313
+ throw new T3Error("refused", words || "T3 Code refused");
148
314
  if (result?.structuredContent) return result.structuredContent;
149
- try { return JSON.parse(words); } catch { return {}; }
315
+ try {
316
+ return JSON.parse(words);
317
+ } catch {
318
+ return {};
319
+ }
150
320
  } finally {
151
- if (session) fetch(`${origin}/mcp`, { method: "DELETE", headers: { authorization: `Bearer ${token}`, "mcp-session-id": session } }).catch(() => {});
321
+ if (session)
322
+ fetch(`${origin}/mcp`, {
323
+ method: "DELETE",
324
+ headers: {
325
+ authorization: `Bearer ${token}`,
326
+ "mcp-session-id": session,
327
+ },
328
+ }).catch(() => {});
152
329
  }
153
330
  }
154
331
 
@@ -156,24 +333,47 @@ async function callTool(origin, token, name, args) {
156
333
  async function t3Tool(name, args) {
157
334
  const saved = readJson(file(), null);
158
335
  const access = t3Access();
159
- if (!access) throw new T3Error("not_connected", "Harness is not approved in T3 Code yet.");
160
- if (access.state === "expired") throw new T3Error("expired", "Harness's approval in T3 Code ran out.");
161
- if (access.state !== "send") throw new T3Error("read_only", "Harness was approved in T3 Code to read only.");
336
+ if (!access)
337
+ throw new T3Error(
338
+ "not_connected",
339
+ "Harness is not approved in T3 Code yet."
340
+ );
341
+ if (access.state === "expired")
342
+ throw new T3Error("expired", "Harness's approval in T3 Code ran out.");
343
+ if (access.state !== "send")
344
+ throw new T3Error(
345
+ "read_only",
346
+ "Harness was approved in T3 Code to read only."
347
+ );
162
348
  // T3 may come back on another port after a restart: the saved address first, else the search again.
163
349
  const t3 = await findT3(t3Home(), saved.origin);
164
- if (!t3 || t3.environmentId !== saved.environmentId) throw new T3Error("not_running", "T3 Code is not running on this computer.");
165
- if (t3.origin !== saved.origin) writeJsonAtomic(file(), { ...saved, origin: t3.origin });
350
+ if (!t3 || t3.environmentId !== saved.environmentId)
351
+ throw new T3Error(
352
+ "not_running",
353
+ "T3 Code is not running on this computer."
354
+ );
355
+ if (t3.origin !== saved.origin)
356
+ writeJsonAtomic(file(), { ...saved, origin: t3.origin });
166
357
  try {
167
358
  return await callTool(t3.origin, saved.token, name, args);
168
359
  } catch (error) {
169
- if (error.code === "expired") writeJsonAtomic(file(), { ...saved, expiresAt: new Date(0).toISOString() });
360
+ if (error.code === "expired")
361
+ writeJsonAtomic(file(), {
362
+ ...saved,
363
+ expiresAt: new Date(0).toISOString(),
364
+ });
170
365
  throw error;
171
366
  }
172
367
  }
173
368
 
174
369
  /** Sends the person's words into a T3 Code thread: "started" or "queued" behind its turn. */
175
370
  export async function sendToT3(threadId, text) {
176
- const sent = await t3Tool("t3_thread_send", { threadId, message: text, mode: "queue", clientRequestId: randomUUID() });
371
+ const sent = await t3Tool("t3_thread_send", {
372
+ threadId,
373
+ message: text,
374
+ mode: "queue",
375
+ clientRequestId: randomUUID(),
376
+ });
177
377
  return sent.delivery === "queued" ? "queued" : "started";
178
378
  }
179
379
 
@@ -184,6 +384,18 @@ const READ_MS = 2_000;
184
384
  const SETTLED = new Set(["completed", "failed", "cancelled", "interrupted"]);
185
385
  const ENDED = new Set(["interrupted", "failed", "cancelled", "rolled_back"]);
186
386
  const handed = new Map();
387
+ // The turns handed to T3 that words can still reach while they run, by Harness's run id.
388
+ const steerable = new Map();
389
+
390
+ /**
391
+ * The person's words into a turn handed to T3 while it runs: T3 steers them in at the agent's next step, or restarts
392
+ * the turn with them for an agent that cannot take words mid-turn. "steered" or "restarted"; null when that run has
393
+ * no turn going, so the words wait for its next turn.
394
+ */
395
+ export async function steerT3Run(runId, text) {
396
+ const steer = steerable.get(runId);
397
+ return steer ? steer(text) : null;
398
+ }
187
399
 
188
400
  /** The T3 threads running a turn Harness handed them: Harness's runs, not the person's own sessions. */
189
401
  export const handedToT3 = () => new Set(handed.values());
@@ -191,11 +403,21 @@ export const handedToT3 = () => new Set(handed.values());
191
403
  /** One item of a T3 thread as a step on the board: its words, or what it did in a line. */
192
404
  function stepOf(item) {
193
405
  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) };
406
+ if (item.type === "assistant_message")
407
+ return text ? { kind: "say", text } : null;
408
+ if (item.type === "command_execution")
409
+ return {
410
+ kind: "do",
411
+ text: `ran: ${(item.title === "Workspace ready" ? item.title : text.split("\n")[0].replace(/^\$\s*/, "")).slice(0, 200)}`,
412
+ };
413
+ if (item.type === "web_search")
414
+ return { kind: "do", text: "searched the web" };
415
+ if (["user_message", "reasoning", "checkpoint"].includes(item.type))
416
+ return null;
417
+ return {
418
+ kind: "do",
419
+ text: `${item.title || String(item.type).replace(/_/g, " ")}`.slice(0, 200),
420
+ };
199
421
  }
200
422
 
201
423
  /**
@@ -206,23 +428,60 @@ function stepOf(item) {
206
428
  * how Open finds it), each step, then done with the agent's last words or failed; Stop interrupts it in T3.
207
429
  */
208
430
  export function runInT3(run, emit, observe) {
209
- let threadId = run.threadId ?? null, runId = null, cursor = null, timer = null, stopped = false, last = "";
431
+ let threadId = run.threadId ?? null,
432
+ runId = null,
433
+ cursor = null,
434
+ timer = null,
435
+ stopped = false,
436
+ steering = 0,
437
+ last = "";
210
438
  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 }); };
439
+ const interrupt = () =>
440
+ t3Tool("t3_thread_interrupt", {
441
+ threadId,
442
+ ...(runId ? { runId } : {}),
443
+ reason: "Stopped from Harness.",
444
+ clientRequestId: `${run.runId}:stop`,
445
+ }).catch(() => {});
446
+ const release = trackRun(run.runId, () => {
447
+ stopped = true;
448
+ if (threadId) void interrupt();
449
+ });
450
+ const finish = (kind, text) => {
451
+ clearTimeout(timer);
452
+ handed.delete(run.runId);
453
+ steerable.delete(run.runId);
454
+ release();
455
+ emit(kind, text.slice(0, 20_000), { usage: null });
456
+ };
214
457
  const read = async () => {
215
458
  try {
216
- let page, after = cursor, settled = true;
459
+ let page,
460
+ after = cursor,
461
+ settled = true;
217
462
  do {
218
- page = await t3Tool("t3_thread_read", { threadId, view: "activity", limit: 100, maxCharsPerItem: 20_000, runLimit: 5, ...(after === null ? {} : { afterPosition: after }) });
463
+ page = await t3Tool("t3_thread_read", {
464
+ threadId,
465
+ view: "activity",
466
+ limit: 100,
467
+ maxCharsPerItem: 20_000,
468
+ runLimit: 5,
469
+ ...(after === null ? {} : { afterPosition: after }),
470
+ });
219
471
  for (const item of page.items ?? []) {
220
- if (item.runId === runId && SETTLED.has(item.status) && !seen.has(item.itemId)) {
472
+ if (
473
+ item.runId === runId &&
474
+ SETTLED.has(item.status) &&
475
+ !seen.has(item.itemId)
476
+ ) {
221
477
  seen.add(item.itemId);
222
478
  const step = stepOf(item);
223
479
  if (step) {
224
480
  if (step.kind === "say") last = step.text;
225
- observe("stdout", `${JSON.stringify({ type: "harness.step", ...step })}\n`);
481
+ observe(
482
+ "stdout",
483
+ `${JSON.stringify({ type: "harness.step", ...step })}\n`
484
+ );
226
485
  emit("progress", step.text.slice(0, 200), { step });
227
486
  }
228
487
  }
@@ -232,30 +491,92 @@ export function runInT3(run, emit, observe) {
232
491
  }
233
492
  after = page.nextPosition ?? after;
234
493
  } 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}` : "."}`);
494
+ if (page.thread?.worktreePath)
495
+ run.onWorktree?.(page.thread.worktreePath, page.thread.branch ?? null);
496
+ const status = (page.recentRuns ?? []).find(
497
+ (entry) => entry.runId === runId
498
+ )?.status;
499
+ if (status === "completed")
500
+ return finish(
501
+ "done",
502
+ last ||
503
+ "The run finished in T3 Code; its changes are in T3's checkout."
504
+ );
505
+ // A turn restarted with the person's words ends as the next one starts: that one is followed instead.
506
+ if (ENDED.has(status) && !steering)
507
+ return finish(
508
+ "failed",
509
+ stopped
510
+ ? "Stopped."
511
+ : `T3 Code ended the turn (${status})${last ? `: ${last}` : "."}`
512
+ );
239
513
  } 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.`);
514
+ if (["expired", "not_connected", "read_only"].includes(error.code))
515
+ return finish(
516
+ "failed",
517
+ `${error.message} The turn may still be going in T3 Code.`
518
+ );
241
519
  // T3 restarting or busy: read again next time.
242
520
  }
243
521
  timer = setTimeout(() => void read(), READ_MS);
244
522
  };
523
+ // T3 refuses words for a thread with no turn it can steer, so they never start a turn Harness would not follow.
524
+ steerable.set(run.runId, async (text) => {
525
+ if (!threadId || !runId || stopped) return null;
526
+ steering++;
527
+ try {
528
+ const sent = await t3Tool("t3_thread_send", {
529
+ threadId,
530
+ message: text,
531
+ mode: "steer",
532
+ clientRequestId: randomUUID(),
533
+ });
534
+ if (sent.delivery !== "steered" && sent.delivery !== "restarted")
535
+ return null;
536
+ // A restart goes on as a new run: follow that one to its end.
537
+ if (sent.runId) runId = sent.runId;
538
+ return sent.delivery;
539
+ } catch {
540
+ return null;
541
+ } finally {
542
+ steering--;
543
+ }
544
+ });
245
545
  emit("accepted");
246
546
  void (async () => {
247
547
  try {
248
548
  // A model the relay chose for this turn (models.mjs) is the thread's selection from this turn on.
249
549
  if (threadId) {
250
- if (run.model) await t3Tool("t3_thread_configure", { threadId, modelSelection: run.model });
251
- runId = (await t3Tool("t3_thread_send", { threadId, message: run.brief, mode: "queue", clientRequestId: run.runId })).runId ?? null;
550
+ if (run.model)
551
+ await t3Tool("t3_thread_configure", {
552
+ threadId,
553
+ modelSelection: run.model,
554
+ });
555
+ runId =
556
+ (
557
+ await t3Tool("t3_thread_send", {
558
+ threadId,
559
+ message: run.brief,
560
+ mode: "queue",
561
+ clientRequestId: run.runId,
562
+ })
563
+ ).runId ?? null;
252
564
  } else {
253
- const launched = await t3Tool("t3_thread_launch", { title: run.title, message: run.brief, ...run.launch, ...(run.model ? { modelSelection: run.model } : {}) });
565
+ const launched = await t3Tool("t3_thread_launch", {
566
+ title: run.title,
567
+ message: run.brief,
568
+ ...run.launch,
569
+ ...(run.model ? { modelSelection: run.model } : {}),
570
+ });
254
571
  threadId = launched.threadId;
255
572
  runId = launched.runId ?? null;
256
573
  run.onThread?.(threadId);
257
574
  }
258
- if (!threadId || !runId) throw new T3Error("t3", "T3 Code did not say which thread and turn it started");
575
+ if (!threadId || !runId)
576
+ throw new T3Error(
577
+ "t3",
578
+ "T3 Code did not say which thread and turn it started"
579
+ );
259
580
  handed.set(run.runId, threadId);
260
581
  emit("session", threadId, { cwd: run.cwd });
261
582
  if (stopped) await interrupt();