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.
- package/assets/opencode-plugin.js +48 -11
- package/assets/pi-extension.ts +105 -26
- package/cli.mjs +1238 -322
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +383 -83
- package/lib/cards.mjs +174 -40
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +409 -71
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +274 -57
- package/lib/daemon.mjs +5268 -1024
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +54 -13
- package/lib/environment.mjs +103 -21
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +169 -35
- package/lib/folders.mjs +179 -36
- package/lib/frames.mjs +214 -40
- package/lib/inference.mjs +463 -126
- package/lib/learn.mjs +78 -16
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +424 -83
- package/lib/mcp-stdio.mjs +868 -151
- package/lib/media.mjs +138 -28
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +184 -33
- package/lib/ocr.mjs +223 -60
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +51 -13
- package/lib/phones.mjs +384 -60
- package/lib/places.mjs +555 -107
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +51 -11
- package/lib/redact.mjs +512 -116
- package/lib/runner.mjs +345 -55
- package/lib/runtime-install.mjs +55 -14
- package/lib/screens.mjs +154 -38
- package/lib/sealed.mjs +157 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +170 -34
- package/lib/snippets.mjs +94 -18
- package/lib/speech.mjs +688 -175
- package/lib/store.mjs +1654 -307
- package/lib/swarm.mjs +596 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +285 -61
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +247 -48
- package/lib/worktrees.mjs +186 -38
- 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) {
|
|
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 {
|
|
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`, {
|
|
39
|
-
|
|
40
|
-
|
|
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))
|
|
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()))
|
|
55
|
-
|
|
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 {
|
|
83
|
+
try {
|
|
84
|
+
fs.rmSync(file());
|
|
85
|
+
} catch {}
|
|
60
86
|
}
|
|
61
87
|
|
|
62
88
|
const json = async (url, body) => {
|
|
63
|
-
const response = await fetch(
|
|
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)
|
|
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) =>
|
|
70
|
-
|
|
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)
|
|
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(
|
|
83
|
-
|
|
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) => {
|
|
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, {
|
|
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) => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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, {
|
|
103
|
-
|
|
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)
|
|
106
|
-
|
|
107
|
-
|
|
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(
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
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({
|
|
118
|
-
|
|
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`, {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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(
|
|
133
|
-
|
|
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)
|
|
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, {
|
|
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, {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 {
|
|
315
|
+
try {
|
|
316
|
+
return JSON.parse(words);
|
|
317
|
+
} catch {
|
|
318
|
+
return {};
|
|
319
|
+
}
|
|
150
320
|
} finally {
|
|
151
|
-
if (session)
|
|
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)
|
|
160
|
-
|
|
161
|
-
|
|
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)
|
|
165
|
-
|
|
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")
|
|
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", {
|
|
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")
|
|
195
|
-
|
|
196
|
-
if (item.type === "
|
|
197
|
-
|
|
198
|
-
|
|
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,
|
|
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 = () =>
|
|
212
|
-
|
|
213
|
-
|
|
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,
|
|
459
|
+
let page,
|
|
460
|
+
after = cursor,
|
|
461
|
+
settled = true;
|
|
217
462
|
do {
|
|
218
|
-
page = await t3Tool("t3_thread_read", {
|
|
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 (
|
|
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(
|
|
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)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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))
|
|
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)
|
|
251
|
-
|
|
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", {
|
|
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)
|
|
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();
|