tryharness 0.7.16 → 0.7.22
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/README.md +9 -6
- package/assets/opencode-plugin.js +46 -66
- package/assets/pi-extension.ts +90 -90
- package/cli.mjs +31 -1
- package/lib/adapters.mjs +19 -11
- package/lib/agentnode.mjs +154 -0
- package/lib/cards.mjs +11 -3
- package/lib/coordinator.mjs +19 -9
- package/lib/daemon.mjs +234 -62
- package/lib/folders.mjs +138 -0
- package/lib/live.mjs +30 -5
- package/lib/mcp-stdio.mjs +10 -2
- package/lib/members.json +1 -1
- package/lib/models.mjs +233 -0
- package/lib/network.mjs +118 -0
- package/lib/nudge.mjs +6 -5
- package/lib/phones.mjs +5 -1
- package/lib/places.mjs +49 -1
- package/lib/runner.mjs +35 -6
- package/lib/store.mjs +32 -3
- package/lib/swarm.mjs +84 -20
- package/lib/t3.mjs +7 -4
- package/lib/worktrees.mjs +36 -4
- package/package.json +3 -3
package/lib/nudge.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { placeEnv } from "./places.mjs";
|
|
|
9
9
|
// The session-start nudge: what an interactive agent is told when a session opens, so work
|
|
10
10
|
// waiting on the board is not silent until someone asks. Claude Code runs this as a
|
|
11
11
|
// SessionStart hook (stdout lands in its context); the Pi extension and OpenCode plugin
|
|
12
|
-
//
|
|
12
|
+
// import this file from the runtime and call the same functions. 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
15
|
export function nudgeText(agent, tasks, unread = [], goals = [], swarm = "") {
|
|
@@ -94,14 +94,15 @@ function agentPids() {
|
|
|
94
94
|
// Where the session runs, as its own environment says (the agent's, which the hook shares): places.mjs reads it.
|
|
95
95
|
const placeOf = (input) => ({ sessionId: input?.session_id, folder: input?.cwd || process.cwd(), env: placeEnv(process.env) });
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// An extension or plugin running inside the agent passes its own process ids.
|
|
98
|
+
export async function notePrompt(agent, input = null, pids = agentPids()) {
|
|
99
|
+
try { await daemonRequest({ command: "board.prompted", agent, pids, ...(input?.session_id ? placeOf(input) : {}) }, 1_500); } catch {}
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
/** A session starting says where it runs. */
|
|
102
|
-
export async function notePlace(agent, input) {
|
|
103
|
+
export async function notePlace(agent, input, pids = agentPids()) {
|
|
103
104
|
if (!input?.session_id) return;
|
|
104
|
-
try { await daemonRequest({ command: "board.live.place", agent, pids
|
|
105
|
+
try { await daemonRequest({ command: "board.live.place", agent, pids, ...placeOf(input) }, 1_500); } catch {}
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
// What the person sent this session from Harness (daemon board.live.say), taken once.
|
package/lib/phones.mjs
CHANGED
|
@@ -24,6 +24,8 @@ const PHONE_COMMANDS = new Set([
|
|
|
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
26
|
"board.swarm.diff", "board.swarm.merge", "board.swarm.discard", "board.swarm.start_run", "board.swarm.agents", "board.swarm.note",
|
|
27
|
+
// Where a thread works: the folders offered, any folder browsed to, and where a message would work (folders.mjs).
|
|
28
|
+
"board.swarm.places", "board.swarm.browse", "board.swarm.place",
|
|
27
29
|
// The person's own running sessions, read and messaged as from the desktop app; never queued (a session may be gone).
|
|
28
30
|
"board.live", "board.live.read", "board.live.say",
|
|
29
31
|
]);
|
|
@@ -33,7 +35,9 @@ const HARNESS_COMMANDS = new Set([
|
|
|
33
35
|
"board.goal.create", "board.goal.get", "board.goal.list", "board.goal.finish", "board.room.read", "board.room.post", "board.room.search", "board.children", "board.agents",
|
|
34
36
|
"board.swarm.list", "board.swarm.read", "board.swarm.diff",
|
|
35
37
|
]);
|
|
36
|
-
|
|
38
|
+
// Swarms talking to swarms (network.mjs), from the Harness window: the person's own, never Harness's.
|
|
39
|
+
const RELAY_PERSON_COMMANDS = ["board.relay.list", "board.relay.network", "board.relay.decide", "board.relay.request", "board.relay.seen", "board.relay.defaults", "board.relay.peers"];
|
|
40
|
+
const BROWSER_PERSON_COMMANDS = new Set([...PHONE_COMMANDS, "board.goal.resume", "board.standing", "board.standing.check", "board.standing.off", ...RELAY_PERSON_COMMANDS]);
|
|
37
41
|
const BROWSER_READS = new Set(["sessions.search", "sessions.read", "sessions.raw", "tasks.status", "relay.request", "relay.open"]);
|
|
38
42
|
|
|
39
43
|
export async function createPhoneDesk({ store, identityFile, boardCommand, reads = {}, version, onMessage = () => {}, machine = () => null }) {
|
package/lib/places.mjs
CHANGED
|
@@ -3,6 +3,9 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { paths } from "./paths.mjs";
|
|
7
|
+
import { agentEnvironment } from "./agentnode.mjs";
|
|
8
|
+
import { mainCheckout } from "./worktrees.mjs";
|
|
6
9
|
|
|
7
10
|
// Where a coding session runs, and how it opens there again. A session's place comes from its own environment: the
|
|
8
11
|
// Harness piece inside the agent sends it when the session starts (the Claude Code and Codex hooks, nudge.mjs; the
|
|
@@ -167,6 +170,43 @@ export function resumeLine(agent, sessionId, folder = null) {
|
|
|
167
170
|
return `${folder && path.isAbsolute(folder) ? `cd ${quote(folder)} && ` : ""}${resume} ${sessionId}`;
|
|
168
171
|
}
|
|
169
172
|
|
|
173
|
+
/** Whether an editor runs a workspace's tasks when its window opens: it asks the person once, unless they turned that off. */
|
|
174
|
+
export function editorRunsOpenTasks(editorId) {
|
|
175
|
+
const editor = editorById(editorId);
|
|
176
|
+
if (!editor) return false;
|
|
177
|
+
let settings = "";
|
|
178
|
+
try { settings = fs.readFileSync(path.join(editorData(editor), "User", "settings.json"), "utf8"); } catch { return true; }
|
|
179
|
+
return !/"task\.allowAutomaticTasks"\s*:\s*"off"/.test(settings);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A session to resume in an editor's own terminal: a workspace on the folder the editor opens around it (`window`), whose
|
|
184
|
+
* task resumes the session in a terminal when the window opens (runOn folderOpen; the editor asks once to trust the folder
|
|
185
|
+
* and to allow such tasks). Through the person's login shell, so the agent runs with their own setup. The file lives in
|
|
186
|
+
* Harness's folder, never in the person's; its path, or null when the agent has no resume command.
|
|
187
|
+
*/
|
|
188
|
+
export function editorSessionWorkspace(agent, sessionId, folder, window = folder) {
|
|
189
|
+
const resume = AGENTS[agent]?.resume;
|
|
190
|
+
if (!resume || !PLAIN_ID.test(String(sessionId || "")) || !folder || !path.isAbsolute(folder)) return null;
|
|
191
|
+
// Through the person's login shell (VS Code's default terminal on Windows), with a Node the agent accepts first.
|
|
192
|
+
const shell = process.platform === "win32" ? null : process.env.SHELL || os.userInfo().shell || "/bin/sh";
|
|
193
|
+
const { bin } = agentEnvironment(AGENTS[agent].command);
|
|
194
|
+
const name = `${path.basename(window || folder)} - ${agent} ${String(sessionId).replace(/^ses_/, "").slice(0, 8)}`.replace(/[^A-Za-z0-9 ._-]/g, "-");
|
|
195
|
+
const file = path.join(paths.home, "editor", `${name}.code-workspace`);
|
|
196
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
197
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
198
|
+
folders: [{ path: window || folder }],
|
|
199
|
+
tasks: { version: "2.0.0", tasks: [{
|
|
200
|
+
label: `${agent} session ${sessionId}`, type: "shell", command: `${resume} ${sessionId}`,
|
|
201
|
+
options: { cwd: folder, ...(shell ? { shell: { executable: shell, args: ["-l", "-i", "-c"] } } : {}), ...(bin ? { env: { PATH: `${bin}${path.delimiter}\${env:PATH}` } } : {}) },
|
|
202
|
+
runOptions: { runOn: "folderOpen" },
|
|
203
|
+
presentation: { reveal: "always", focus: true, panel: "dedicated", clear: true },
|
|
204
|
+
problemMatcher: [],
|
|
205
|
+
}] },
|
|
206
|
+
}, null, 2), { mode: 0o600 });
|
|
207
|
+
return file;
|
|
208
|
+
}
|
|
209
|
+
|
|
170
210
|
/** A session in its agent's own panel in an editor (Claude Code's, OpenAI's Codex extension), when it is installed there. */
|
|
171
211
|
export function panelLink(agent, editorId, sessionId) {
|
|
172
212
|
const panel = AGENTS[agent]?.panel;
|
|
@@ -226,12 +266,20 @@ const realDir = (dir) => { try { return fs.realpathSync(dir); } catch { return n
|
|
|
226
266
|
export function t3Project(folder, home = t3Home()) {
|
|
227
267
|
if (!folder || !t3Tables(home).has("projection_projects")) return null;
|
|
228
268
|
const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: folder, encoding: "utf8" });
|
|
229
|
-
const
|
|
269
|
+
const root = top.status === 0 ? top.stdout.trim() : null;
|
|
270
|
+
// A worktree of a project's checkout (a thread's copy here, T3's own) is that project's, as T3 counts it.
|
|
271
|
+
const wanted = new Set([realDir(folder), root && realDir(root), root && realDir(mainCheckout(root))].filter(Boolean));
|
|
230
272
|
const rows = (t3Query(home, "select project_id || char(9) || workspace_root from projection_projects where deleted_at is null;") || "").split("\n").map((line) => line.split("\t"));
|
|
231
273
|
const found = rows.find(([id, root]) => id && root && wanted.has(realDir(root)));
|
|
232
274
|
return found ? { id: found[0], root: realDir(found[1]) } : null;
|
|
233
275
|
}
|
|
234
276
|
|
|
277
|
+
/** Every folder T3 Code has a project for. */
|
|
278
|
+
export function t3Projects(home = t3Home()) {
|
|
279
|
+
if (!t3Tables(home).has("projection_projects")) return [];
|
|
280
|
+
return (t3Query(home, "select workspace_root from projection_projects where deleted_at is null;") || "").split("\n").map(realDir).filter(Boolean);
|
|
281
|
+
}
|
|
282
|
+
|
|
235
283
|
/** The model (and its options) the person last ran an agent with in T3 Code, as T3 records each thread's choice. */
|
|
236
284
|
export function t3Model(agent, home = t3Home()) {
|
|
237
285
|
const provider = t3Provider(agent);
|
package/lib/runner.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import { agentEnvironment } from "./agentnode.mjs";
|
|
5
6
|
|
|
6
7
|
const running = new Set();
|
|
7
8
|
const children = new Map();
|
|
@@ -21,6 +22,13 @@ export function trackRun(runId, stop) {
|
|
|
21
22
|
return () => { if (children.get(runId) === entry) children.delete(runId); };
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** A short command the daemon runs itself (an agent listing its models): while it runs, not the person's session either. */
|
|
26
|
+
export function trackHelper(child) {
|
|
27
|
+
const id = `helper:${child.pid}`;
|
|
28
|
+
children.set(id, child);
|
|
29
|
+
child.on("close", () => children.delete(id));
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
/** The process ids of the runs in progress, so they are not mistaken for the person's own sessions. */
|
|
25
33
|
export function runningPids() {
|
|
26
34
|
return [...children.values()].map((child) => child.pid).filter(Boolean);
|
|
@@ -48,12 +56,21 @@ export const restrictedMcpConfig = () => ({ mcpServers: { harness: { command: pr
|
|
|
48
56
|
const restrictedClaude = (task) => ["--permission-mode", "dontAsk", "--allowedTools", task.readOnly ? "mcp__harness" : "Edit Write mcp__harness",
|
|
49
57
|
"--setting-sources", "user", "--settings", JSON.stringify(RESTRICTED_CLAUDE_SETTINGS), "--strict-mcp-config", "--mcp-config", JSON.stringify(restrictedMcpConfig())];
|
|
50
58
|
|
|
59
|
+
// The model and effort the relay chose for the run (models.mjs), in each agent's own flags; none leaves its settings as they are.
|
|
60
|
+
const MODEL_FLAGS = {
|
|
61
|
+
"claude-code": { model: (id) => ["--model", id], effort: (level) => ["--effort", level] },
|
|
62
|
+
codex: { model: (id) => ["-m", id], effort: (level) => ["-c", `model_reasoning_effort="${level}"`] },
|
|
63
|
+
pi: { model: (id) => ["--model", id], effort: (level) => ["--thinking", level] },
|
|
64
|
+
opencode: { model: (id) => ["-m", id], effort: (level) => ["--variant", level] },
|
|
65
|
+
};
|
|
66
|
+
const modelFlags = ({ agent, model, effort }) => [...(model ? MODEL_FLAGS[agent]?.model(model) ?? [] : []), ...(effort ? MODEL_FLAGS[agent]?.effort(effort) ?? [] : [])];
|
|
67
|
+
|
|
51
68
|
export function agentInvocation(task) {
|
|
52
69
|
if (task.agent === "claude-code") {
|
|
53
70
|
return {
|
|
54
71
|
command: "claude",
|
|
55
72
|
// A review reads and reports: plan mode lets Claude Code read and run read-only tools but never edit.
|
|
56
|
-
args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", ...(task.restricted ? restrictedClaude(task) : task.readOnly ? ["--permission-mode", "plan"] : ["--dangerously-skip-permissions"]), "--name", `Harness: ${task.title}`, ...(task.resumeSession ? ["--resume", task.resumeSession] : [])],
|
|
73
|
+
args: ["-p", task.brief, "--output-format", "stream-json", "--verbose", ...(task.restricted ? restrictedClaude(task) : task.readOnly ? ["--permission-mode", "plan"] : ["--dangerously-skip-permissions"]), "--name", `Harness: ${task.title}`, ...modelFlags(task), ...(task.resumeSession ? ["--resume", task.resumeSession] : [])],
|
|
57
74
|
};
|
|
58
75
|
}
|
|
59
76
|
// Only Claude Code has a restricted mode; any other agent's restricted run is refused here, whatever let it start.
|
|
@@ -61,15 +78,15 @@ export function agentInvocation(task) {
|
|
|
61
78
|
if (task.agent === "codex") {
|
|
62
79
|
return {
|
|
63
80
|
command: "codex",
|
|
64
|
-
args: ["exec", ...(task.resumeSession ? ["resume", task.resumeSession] : []), "--json", ...(task.readOnly ? ["-c", 'sandbox_mode="read-only"', "-c", 'approval_policy="never"'] : ["--dangerously-bypass-approvals-and-sandbox"]), "--skip-git-repo-check", "-"],
|
|
81
|
+
args: ["exec", ...(task.resumeSession ? ["resume", task.resumeSession] : []), "--json", ...modelFlags(task), ...(task.readOnly ? ["-c", 'sandbox_mode="read-only"', "-c", 'approval_policy="never"'] : ["--dangerously-bypass-approvals-and-sandbox"]), "--skip-git-repo-check", "-"],
|
|
65
82
|
stdin: task.brief,
|
|
66
83
|
};
|
|
67
84
|
}
|
|
68
85
|
// Pi has no permission prompts. --approve trusts project-local configuration for
|
|
69
86
|
// this non-interactive run; its complete built-in tool set remains enabled.
|
|
70
|
-
if (task.agent === "pi") return { command: "pi", args: ["--mode", "json", "--approve", "-p", task.brief] };
|
|
87
|
+
if (task.agent === "pi") return { command: "pi", args: ["--mode", "json", "--approve", ...modelFlags(task), "-p", task.brief] };
|
|
71
88
|
if (task.agent === "opencode") {
|
|
72
|
-
return { command: "opencode", args: ["run", "--format", "json", "--auto", ...(task.resumeSession ? ["--session", task.resumeSession] : ["--title", task.title]), "--", task.brief] };
|
|
89
|
+
return { command: "opencode", args: ["run", "--format", "json", "--auto", ...modelFlags(task), ...(task.resumeSession ? ["--session", task.resumeSession] : ["--title", task.title]), "--", task.brief] };
|
|
73
90
|
}
|
|
74
91
|
throw new Error(`Unsupported agent: ${task.agent}`);
|
|
75
92
|
}
|
|
@@ -160,9 +177,13 @@ export function runAgentTask(task, emit, observe = () => {}) {
|
|
|
160
177
|
}
|
|
161
178
|
let cwd;
|
|
162
179
|
let invocation;
|
|
180
|
+
let environment;
|
|
163
181
|
try {
|
|
164
182
|
cwd = resolveWorkingDirectory(task.cwd);
|
|
165
183
|
invocation = agentInvocation(task);
|
|
184
|
+
// A Node agent runs on a Node it accepts, whichever Node Harness itself runs on.
|
|
185
|
+
environment = agentEnvironment(invocation.command);
|
|
186
|
+
if (environment.error) throw new Error(environment.error);
|
|
166
187
|
} catch (error) {
|
|
167
188
|
emit("failed", error.message);
|
|
168
189
|
return;
|
|
@@ -170,7 +191,7 @@ export function runAgentTask(task, emit, observe = () => {}) {
|
|
|
170
191
|
emit("accepted");
|
|
171
192
|
let child;
|
|
172
193
|
try {
|
|
173
|
-
child = spawn(invocation.command, invocation.args, { cwd });
|
|
194
|
+
child = spawn(invocation.command, invocation.args, { cwd, env: environment.env });
|
|
174
195
|
child.stdin.end(invocation.stdin);
|
|
175
196
|
} catch (error) {
|
|
176
197
|
running.delete(slot);
|
|
@@ -181,6 +202,7 @@ export function runAgentTask(task, emit, observe = () => {}) {
|
|
|
181
202
|
children.set(task.runId, child);
|
|
182
203
|
|
|
183
204
|
let resultText = "";
|
|
205
|
+
let modelError = "";
|
|
184
206
|
let tail = "";
|
|
185
207
|
let buffer = "";
|
|
186
208
|
let sessionReported = false;
|
|
@@ -205,6 +227,12 @@ export function runAgentTask(task, emit, observe = () => {}) {
|
|
|
205
227
|
if (event.type === "result" && typeof event.result === "string") resultText = event.result;
|
|
206
228
|
if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") resultText = event.item.text;
|
|
207
229
|
if (["text", "message.part.updated"].includes(event.type) && typeof event.part?.text === "string") resultText = event.part.text;
|
|
230
|
+
// Pi: its last words are its answer; when its model call fails, Pi still exits 0 with the error on the message.
|
|
231
|
+
if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
232
|
+
const said = (Array.isArray(event.message.content) ? event.message.content : []).filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
233
|
+
if (said) resultText = said;
|
|
234
|
+
modelError = event.message.stopReason === "error" ? String(event.message.errorMessage || "no error message") : "";
|
|
235
|
+
}
|
|
208
236
|
for (const step of stepsOf(event)) emit("progress", step.text.slice(0, 200), { step });
|
|
209
237
|
}
|
|
210
238
|
});
|
|
@@ -216,7 +244,8 @@ export function runAgentTask(task, emit, observe = () => {}) {
|
|
|
216
244
|
child.on("close", (code) => {
|
|
217
245
|
running.delete(slot);
|
|
218
246
|
children.delete(task.runId);
|
|
219
|
-
if (code === 0) emit("
|
|
247
|
+
if (code === 0 && modelError) emit("failed", `${task.agent} stopped on an error from its model: ${modelError}`.slice(0, 4_000), { usage: usage.result() });
|
|
248
|
+
else if (code === 0) emit("done", (resultText || "The run finished; check the repository for its changes.").slice(0, 20_000), { usage: usage.result() });
|
|
220
249
|
else emit("failed", (`${task.agent} exited with code ${code}.\n${tail}`).slice(0, 4_000), { usage: usage.result() });
|
|
221
250
|
});
|
|
222
251
|
child.on("error", (error) => {
|
package/lib/store.mjs
CHANGED
|
@@ -78,6 +78,7 @@ export function openStore(file) {
|
|
|
78
78
|
trust text not null default 'agent',
|
|
79
79
|
origin_chain text not null default '[]',
|
|
80
80
|
usage text,
|
|
81
|
+
run_model text,
|
|
81
82
|
removed_at text,
|
|
82
83
|
created_at text not null,
|
|
83
84
|
updated_at text not null
|
|
@@ -223,6 +224,7 @@ export function openStore(file) {
|
|
|
223
224
|
}
|
|
224
225
|
if (!boardColumns.has("kind") && boardColumns.size) db.exec("alter table board_tasks add column kind text not null default 'task'");
|
|
225
226
|
if (!boardColumns.has("usage") && boardColumns.size) db.exec("alter table board_tasks add column usage text");
|
|
227
|
+
if (!boardColumns.has("run_model") && boardColumns.size) db.exec("alter table board_tasks add column run_model text");
|
|
226
228
|
if (!boardColumns.has("removed_at") && boardColumns.size) db.exec("alter table board_tasks add column removed_at text");
|
|
227
229
|
if (!boardColumns.has("trust") && boardColumns.size) db.exec("alter table board_tasks add column trust text not null default 'agent'; alter table board_tasks add column origin_chain text not null default '[]'");
|
|
228
230
|
|
|
@@ -559,9 +561,9 @@ export function openStore(file) {
|
|
|
559
561
|
},
|
|
560
562
|
// A thread's runs (wakes and jobs), oldest first, for its transcript.
|
|
561
563
|
threadRuns(goalId) {
|
|
562
|
-
return execute("all", `select id, kind, objective, target_agent, claimed_by, status, holder, result, session_id, run_workspace, run_authorized_at, usage, created_at, updated_at, reported_at
|
|
564
|
+
return execute("all", `select id, kind, objective, target_agent, claimed_by, status, holder, result, session_id, run_workspace, run_authorized_at, usage, run_model, created_at, updated_at, reported_at
|
|
563
565
|
from board_tasks where parent_id = ? and kind in ('wake', 'task') order by created_at asc`, goalId)
|
|
564
|
-
.map((row) => ({ ...row, usage: row.usage ? JSON.parse(row.usage) : null }));
|
|
566
|
+
.map((row) => ({ ...row, usage: row.usage ? JSON.parse(row.usage) : null, run_model: row.run_model ? JSON.parse(row.run_model) : null }));
|
|
565
567
|
},
|
|
566
568
|
// The coding sessions on this machine, newest first: the person's own and the ones runs started.
|
|
567
569
|
recentSessions(limit = 30) {
|
|
@@ -569,6 +571,11 @@ export function openStore(file) {
|
|
|
569
571
|
order by coalesce(ended_at, started_at, indexed_at) desc limit ?`, Math.max(1, Math.min(100, Number(limit) || 30)))
|
|
570
572
|
.map((row) => ({ ...row, message_count: Number(row.message_count) }));
|
|
571
573
|
},
|
|
574
|
+
// Each folder an agent's sessions ran in, with its latest, newest first.
|
|
575
|
+
sessionFolders(limit = 300) {
|
|
576
|
+
return execute("all", `select agent, cwd, max(coalesce(ended_at, started_at, indexed_at)) as at from sessions where cwd is not null
|
|
577
|
+
group by agent, cwd order by at desc limit ?`, Math.max(1, Math.min(1000, Number(limit) || 300)));
|
|
578
|
+
},
|
|
572
579
|
// The thread that already carries a session, if any.
|
|
573
580
|
threadForSession(agent, sessionId) {
|
|
574
581
|
const row = execute("all", "select goal_id, sessions from board_goals").find((entry) => JSON.parse(entry.sessions || "{}")[agent] === sessionId);
|
|
@@ -605,6 +612,16 @@ export function openStore(file) {
|
|
|
605
612
|
setRunUsage(id, usage) {
|
|
606
613
|
execute("run", "update board_tasks set usage = ? where id = ?", JSON.stringify(usage), id);
|
|
607
614
|
},
|
|
615
|
+
// The model and effort a run started with and who chose them (models.mjs); null fields: the agent's own settings.
|
|
616
|
+
setRunModel(id, choice) {
|
|
617
|
+
execute("run", "update board_tasks set run_model = ? where id = ?", JSON.stringify(choice), id);
|
|
618
|
+
},
|
|
619
|
+
// An agent's finished runs by the model they ran on, for Auto's record of each model.
|
|
620
|
+
modelWork(agent) {
|
|
621
|
+
return execute("all", `select json_extract(run_model, '$.model') as model, status, coalesce(run_authorized_at, created_at) as started_at, reported_at, usage from board_tasks
|
|
622
|
+
where coalesce(claimed_by, target_agent) = ? and json_extract(run_model, '$.model') is not null and status in ('done', 'failed') order by updated_at desc limit 2000`, agent)
|
|
623
|
+
.map((row) => ({ ...row, usage: row.usage ? JSON.parse(row.usage) : null }));
|
|
624
|
+
},
|
|
608
625
|
setRunSession(id, sessionId) {
|
|
609
626
|
execute("run", "update board_tasks set session_id = ?, updated_at = ? where id = ?", sessionId, new Date().toISOString(), id);
|
|
610
627
|
},
|
|
@@ -750,6 +767,18 @@ export function openStore(file) {
|
|
|
750
767
|
threadMessages(taskId) {
|
|
751
768
|
return execute("all", "select * from board_messages where task_id = ? order by seq asc", taskId).map((row) => ({ ...row, seq: Number(row.seq), refs: JSON.parse(row.refs || "[]") }));
|
|
752
769
|
},
|
|
770
|
+
// Every thread with another person's swarm, newest first, with what the Swarms list shows of each (network.mjs).
|
|
771
|
+
relayThreads(limit = 200) {
|
|
772
|
+
return execute("all", `select th.task_id, th.with_agent, th.role, th.auto_send, th.opened_at, th.closed_at, th.outcome, b.objective, b.target_agent, b.status, b.updated_at,
|
|
773
|
+
(select count(*) from board_messages m where m.task_id = th.task_id and m.state in ('held', 'flagged')) as held,
|
|
774
|
+
(select coalesce(max(seq), 0) from board_messages m where m.task_id = th.task_id and m.direction = 'in') as last_in_seq,
|
|
775
|
+
(select count(*) from board_messages m where m.task_id = th.task_id) as messages
|
|
776
|
+
from board_threads th join board_tasks b on b.id = th.task_id order by b.updated_at desc limit ?`, limit)
|
|
777
|
+
.map((row) => {
|
|
778
|
+
const last = execute("get", "select direction, sender, text, state, created_at, seq from board_messages where task_id = ? order by seq desc limit 1", row.task_id) || null;
|
|
779
|
+
return { ...row, auto_send: Number(row.auto_send) === 1, held: Number(row.held), last_in_seq: Number(row.last_in_seq), messages: Number(row.messages), last: last ? { ...last, seq: Number(last.seq) } : null };
|
|
780
|
+
});
|
|
781
|
+
},
|
|
753
782
|
nextSeq(taskId) {
|
|
754
783
|
const row = execute("get", "select coalesce(max(seq), 0) as seq from board_messages where task_id = ?", taskId);
|
|
755
784
|
return Number(row.seq) + 1;
|
|
@@ -917,7 +946,7 @@ export function openStore(file) {
|
|
|
917
946
|
getTask(id) {
|
|
918
947
|
const row = execute("get", "select * from board_tasks where id = ?", id);
|
|
919
948
|
if (!row) return null;
|
|
920
|
-
const task = { ...row, version: Number(row.version), context_refs: JSON.parse(row.context_refs || "[]"), origin_chain: JSON.parse(row.origin_chain || "[]"), usage: row.usage ? JSON.parse(row.usage) : null, stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < new Date().toISOString() };
|
|
949
|
+
const task = { ...row, version: Number(row.version), context_refs: JSON.parse(row.context_refs || "[]"), origin_chain: JSON.parse(row.origin_chain || "[]"), usage: row.usage ? JSON.parse(row.usage) : null, run_model: row.run_model ? JSON.parse(row.run_model) : null, stale: row.status === "claimed" && Boolean(row.lease_until) && row.lease_until < new Date().toISOString() };
|
|
921
950
|
if (row.kind === "goal") return { ...task, goal: this.goal(id) };
|
|
922
951
|
return row.kind === "group" ? { ...task, children: this.children(id) } : task;
|
|
923
952
|
},
|
package/lib/swarm.mjs
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { readRunSteps } from "./runner.mjs";
|
|
5
6
|
import { nameOf, resolveMember } from "./members.mjs";
|
|
6
|
-
import { setNote, swarmBlock, swarmCards, swarmDigest } from "./cards.mjs";
|
|
7
|
+
import { modelOf, setNote, swarmBlock, swarmCards, swarmDigest } from "./cards.mjs";
|
|
8
|
+
import { DEFAULT_ROUTING, PICKABLE, choiceOf, listingOf, picksIn, routingOf, setChoice, setRouting } from "./models.mjs";
|
|
7
9
|
|
|
8
10
|
// The person's swarm as threads. A thread is a goal under the hood: its room is the transcript, its team the agents in
|
|
9
11
|
// it, its folder where coding agents work, and it runs on its own authority. A message the person sends wakes the
|
|
10
12
|
// agents it names (or the only agent in the thread), each resuming its own session; their runs' steps are read back
|
|
11
13
|
// from the run logs. The person's own terminal sessions are listed too and continue in a thread once idle.
|
|
12
14
|
|
|
13
|
-
const PROCESS_NAMES = { "claude-code": "claude", codex: "codex", pi: "pi", opencode: "opencode" };
|
|
15
|
+
export const PROCESS_NAMES = { "claude-code": "claude", codex: "codex", pi: "pi", opencode: "opencode" };
|
|
14
16
|
const SAY_LIMIT = 4_000;
|
|
15
17
|
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
16
18
|
|
|
17
19
|
// The id an agent resumes a session by: the index keys sessions by their file name.
|
|
18
20
|
export function nativeSessionId(agent, sessionId) {
|
|
19
21
|
const bare = String(sessionId).replace(/\.jsonl$/, "");
|
|
20
|
-
|
|
22
|
+
// Codex and pi name a session's file by its time and id; the id is what resumes it.
|
|
23
|
+
return agent === "codex" || agent === "pi" ? bare.match(UUID)?.[0] ?? bare : bare;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
// The folder each running coding agent works in, by agent, leaving out the runs the daemon started itself.
|
|
@@ -49,7 +52,7 @@ export function agentProcessCwds(exclude = new Set()) {
|
|
|
49
52
|
return cwds;
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoard, goalCommand, stopThreadRuns, runLog, runningPids, provenanceFor, defaults, coordinator, worktrees, roster, authorizeRun }) {
|
|
55
|
+
export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoard, goalCommand, stopThreadRuns, runLog, runningPids, provenanceFor, defaults, coordinator, worktrees, roster, authorizeRun, pickable, listModels, folders, runsIn }) {
|
|
53
56
|
const awaitingStart = (run) => run.status === "open" && Boolean(run.run_workspace) && !run.run_authorized_at;
|
|
54
57
|
const isDir = (folder) => typeof folder === "string" && path.isAbsolute(folder) && fs.statSync(folder, { throwIfNoEntry: false })?.isDirectory();
|
|
55
58
|
const agentsOf = (thread) => thread.goal.team.filter((agent) => agent !== thread.goal.facilitator);
|
|
@@ -115,12 +118,16 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
115
118
|
// The agent a request names in `to`: null for none, false for one no thread can have.
|
|
116
119
|
const recipient = (request) => (request.to == null || request.to === "" ? null : roomAgents.includes(String(request.to)) ? String(request.to) : false);
|
|
117
120
|
|
|
118
|
-
|
|
121
|
+
// A thread needs a folder when a coding agent is in it, or when no one is named and the coordinator picks who works.
|
|
122
|
+
const needsFolder = (team) => !team.length || team.some((name) => PROCESS_NAMES[name]);
|
|
123
|
+
// Where each coding agent in a thread would run its turn there: in T3 Code, or here, and why not T3 when the person uses it there.
|
|
124
|
+
const placeOf = (folder, team, by) => ({ ok: true, folder, name: folder ? path.basename(folder) : null, by,
|
|
125
|
+
runs: folder ? team.filter((name) => PROCESS_NAMES[name]).map((name) => ({ agent: name, ...runsIn(name, folder) })) : [] });
|
|
119
126
|
|
|
120
127
|
const read = (thread) => {
|
|
121
128
|
const runs = store.threadRuns(thread.id).map((run) => ({
|
|
122
129
|
id: run.id, agent: run.claimed_by || run.target_agent, kind: run.kind, status: run.status, awaitingStart: awaitingStart(run), startedAt: run.created_at, endedAt: run.reported_at,
|
|
123
|
-
sessionId: run.session_id, folder: run.run_workspace, usage: run.usage,
|
|
130
|
+
sessionId: run.session_id, folder: run.run_workspace, usage: run.usage, model: run.run_model,
|
|
124
131
|
steps: readRunSteps(runLog(run.id), 300).map((step) => ({ ...step, text: step.text.slice(0, SAY_LIMIT) })),
|
|
125
132
|
}));
|
|
126
133
|
return { ok: true, thread: summary(thread), posts: store.roomPosts(thread.id, 0, 500), runs };
|
|
@@ -128,6 +135,8 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
128
135
|
|
|
129
136
|
// `to`: the one agent the person wrote to (the desktop's agent list), so their words go as written, with no @name.
|
|
130
137
|
const post = (thread, text, to = null) => {
|
|
138
|
+
const picked = picksIn(store, text, roomAgents);
|
|
139
|
+
if (picked.error) return { ok: false, error: picked.error };
|
|
131
140
|
// A new name in the person's message joins the thread.
|
|
132
141
|
const joining = [...new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])])].filter((agent) => !thread.goal.team.includes(agent));
|
|
133
142
|
if (joining.length) store.setGoalTeam(thread.id, [...thread.goal.team, ...joining]);
|
|
@@ -159,6 +168,19 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
159
168
|
const agents = swarmCards(store, roster().filter((entry) => entry.id !== agent));
|
|
160
169
|
return { ok: true, agents, text: swarmBlock(agents), digest: swarmDigest(agents) };
|
|
161
170
|
}
|
|
171
|
+
// Each coding agent's models as it lists them, the person's choice for it and their routing words (models.mjs). Open
|
|
172
|
+
// to every caller, so an agent names a model that exists: post_task's model, or `@codex:model/effort` in a post.
|
|
173
|
+
if (command === "board.swarm.models") {
|
|
174
|
+
const named = request.target ? resolveMember(request.target) : null;
|
|
175
|
+
if (named && !named.ok) return { ok: false, error: `no agent called "${request.target}"` };
|
|
176
|
+
const ids = pickable().filter((id) => !named || id === named.id);
|
|
177
|
+
if (named && !ids.length) return { ok: false, error: `the relay sets the model of ${[...PICKABLE].join(", ")} when they are installed here, not ${nameOf(named.id)}` };
|
|
178
|
+
const listing = () => ({ ok: true, routing: routingOf(store), defaultRouting: DEFAULT_ROUTING, agents: ids.map((id) => {
|
|
179
|
+
const listed = listingOf(store, id);
|
|
180
|
+
return { id, name: nameOf(id), choice: choiceOf(store, id), ownModel: modelOf(id), listedAt: listed?.at ?? null, ...(listed?.error ? { error: listed.error } : {}), models: listed?.models ?? [] };
|
|
181
|
+
}) });
|
|
182
|
+
return request.refresh === true && agent === "user" ? { pending: listModels().then(listing) } : listing();
|
|
183
|
+
}
|
|
162
184
|
// Any of the person's agents may start a thread and stop one it started. The thread carries the agent's trust, so
|
|
163
185
|
// nothing in it runs without the person's Start, and one started through Harness gets no coordinator rounds (they
|
|
164
186
|
// are paid) until the person sets a spend ceiling.
|
|
@@ -168,8 +190,10 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
168
190
|
const folder = request.folder ? String(request.folder) : null;
|
|
169
191
|
if (folder && !isDir(folder)) return { ok: false, error: `${folder} is not an absolute path to a folder` };
|
|
170
192
|
const team = new Set(mentionsIn(text, roomAgents));
|
|
193
|
+
const picked = picksIn(store, text, roomAgents);
|
|
194
|
+
if (picked.error) return { ok: false, error: picked.error };
|
|
171
195
|
if (!team.size) return { ok: false, error: `name who the thread is for with @, one of: ${roomAgents.filter((name) => name !== agent && name !== "harness").join(", ")}` };
|
|
172
|
-
const objective = text.split("\n").map((line) => line.trim()).find(Boolean).replace(/@([a-z][a-z0-9-]*)
|
|
196
|
+
const objective = text.split("\n").map((line) => line.trim()).find(Boolean).replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "").slice(0, 120) || text.slice(0, 120);
|
|
173
197
|
const thread = store.createGoal({ objective, originAgent: agent, provenance: provenanceFor(agent), team: [...team, agent, "harness"], facilitator: "harness", workspace: folder ? path.resolve(folder) : null, maxPosts: defaults.maxPosts, maxWakes: defaults.maxWakes });
|
|
174
198
|
if (request.hostedCaller) store.meta(`hosted-thread:${thread.id}`, "1");
|
|
175
199
|
const first = store.roomPost({ goalId: thread.id, author: agent, kind: "post", text, mentions: addressed(store.getTask(thread.id), text) });
|
|
@@ -185,12 +209,37 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
185
209
|
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
186
210
|
}
|
|
187
211
|
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" };
|
|
212
|
+
// Where a thread works, for the composer (folders.mjs): the folders the person works in, any folder on the computer
|
|
213
|
+
// from a typed path, and where a message would work: the folder chosen (`folder`) or the one Harness suggests, and
|
|
214
|
+
// where each coding agent named in it (`text`, `to`, `agents`) runs its turns there.
|
|
215
|
+
if (command === "board.swarm.places") return { ok: true, places: folders.known(), home: os.homedir() };
|
|
216
|
+
if (command === "board.swarm.browse") return folders.browse(request.path);
|
|
217
|
+
if (command === "board.swarm.place") {
|
|
218
|
+
const text = String(request.text || "").slice(0, 20_000);
|
|
219
|
+
const to = recipient(request);
|
|
220
|
+
const team = [...new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : []), ...(Array.isArray(request.agents) ? request.agents.map(String).filter((name) => roomAgents.includes(name)) : [])])];
|
|
221
|
+
if (request.folder) {
|
|
222
|
+
const folder = String(request.folder);
|
|
223
|
+
return isDir(folder) ? placeOf(path.resolve(folder), team, "you") : { ok: false, error: `${folder} is not a folder on this computer` };
|
|
224
|
+
}
|
|
225
|
+
if (!needsFolder(team)) return placeOf(null, team, null);
|
|
226
|
+
return { pending: folders.suggest(text, team).then(({ folder, by }) => placeOf(folder, team, by)) };
|
|
227
|
+
}
|
|
188
228
|
// The person's own note on one agent's card ("use it for reviews"), told to every other agent; no text clears it.
|
|
189
229
|
if (command === "board.swarm.note") {
|
|
190
230
|
const named = resolveMember(request.target);
|
|
191
231
|
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
232
|
return { ok: true, agent: named.id, note: setNote(store, named.id, request.text) };
|
|
193
233
|
}
|
|
234
|
+
// The person's choice of model and effort for one agent: a model it lists, "auto", or "default" for its own settings.
|
|
235
|
+
if (command === "board.swarm.model") {
|
|
236
|
+
const named = resolveMember(request.target);
|
|
237
|
+
if (!named.ok || !PICKABLE.has(named.id)) return { ok: false, error: `the relay sets the model of ${[...PICKABLE].join(", ")}, not "${request.target}"` };
|
|
238
|
+
const set = setChoice(store, named.id, request.model, request.effort);
|
|
239
|
+
return set.ok ? { ok: true, agent: named.id, choice: set.choice } : set;
|
|
240
|
+
}
|
|
241
|
+
// The person's words on how Auto picks models for swarm work; no text goes back to the default words.
|
|
242
|
+
if (command === "board.swarm.routing") return { ok: true, routing: setRouting(store, request.text), defaultRouting: DEFAULT_ROUTING };
|
|
194
243
|
// The coordinator's record: what each round saw, chose and cost. The evidence for its limits.
|
|
195
244
|
if (command === "board.swarm.rounds") return { ok: true, rounds: store.rounds(request.id ? String(request.id) : null, request.limit) };
|
|
196
245
|
if (command === "board.swarm.coordinator") {
|
|
@@ -210,6 +259,8 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
210
259
|
const to = recipient(request);
|
|
211
260
|
if (to === false) return { ok: false, error: `${request.to} can't be written to in a thread` };
|
|
212
261
|
const team = new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])]);
|
|
262
|
+
const picked = picksIn(store, text, roomAgents);
|
|
263
|
+
if (picked.error) return { ok: false, error: picked.error };
|
|
213
264
|
if (from) {
|
|
214
265
|
const agentName = String(from.agent || ""), sessionId = String(from.sessionId || "");
|
|
215
266
|
if (!PROCESS_NAMES[agentName]) return { ok: false, error: "only a coding agent's session continues in a thread" };
|
|
@@ -221,17 +272,19 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
221
272
|
team.add(agentName);
|
|
222
273
|
folder ??= isDir(session.cwd) ? session.cwd : null;
|
|
223
274
|
}
|
|
224
|
-
// Where the work happens: the
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
275
|
+
// Where the work happens: the folder the person chose, else one picked from what they wrote, else where its agent
|
|
276
|
+
// last worked (folders.mjs); never the home folder or a hidden one.
|
|
277
|
+
const begin = (where) => {
|
|
278
|
+
const objective = text.split("\n").map((line) => line.trim()).find(Boolean).replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "").slice(0, 120) || text.slice(0, 120);
|
|
279
|
+
const thread = store.createGoal({ objective, originAgent: "user", provenance: provenanceFor("user"), team: [...new Set([...team, "harness"])], facilitator: "harness", workspace: where ? path.resolve(where) : null, maxPosts: defaults.maxPosts, maxWakes: defaults.maxWakes });
|
|
280
|
+
if (from) { store.setGoalSession(thread.id, String(from.agent), nativeSessionId(String(from.agent), String(from.sessionId))); worktrees.inPlace(thread.id); }
|
|
281
|
+
const opened = store.getTask(thread.id);
|
|
282
|
+
const first = store.roomPost({ goalId: thread.id, author: "user", kind: "post", text, mentions: addressed(opened, text) });
|
|
283
|
+
wakeFor(thread.id, first, "user");
|
|
284
|
+
notifyBoard(store.getTask(thread.id));
|
|
285
|
+
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
286
|
+
};
|
|
287
|
+
return folder || !needsFolder([...team]) ? begin(folder) : { pending: folders.suggest(text, [...team]).then((suggested) => begin(suggested.folder)) };
|
|
235
288
|
}
|
|
236
289
|
const thread = store.getTask(String(request.id || ""));
|
|
237
290
|
if (!thread || thread.kind !== "goal") return { ok: false, error: "thread not found" };
|
|
@@ -273,10 +326,21 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
273
326
|
notifyBoard(store.getTask(thread.id));
|
|
274
327
|
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
275
328
|
}
|
|
329
|
+
// The person moves the thread to another folder between turns. Each agent starts a new session there, as a session
|
|
330
|
+
// and a T3 thread belong to the folder they ran in.
|
|
276
331
|
if (command === "board.swarm.folder") {
|
|
277
332
|
const folder = String(request.folder || "");
|
|
278
|
-
if (!isDir(folder)) return { ok: false, error: `${folder} is not
|
|
279
|
-
|
|
333
|
+
if (!isDir(folder)) return { ok: false, error: `${folder} is not a folder on this computer` };
|
|
334
|
+
const where = path.resolve(folder);
|
|
335
|
+
if (where === thread.goal.workspace) return { ok: true, thread: summary(thread) };
|
|
336
|
+
if (store.threadRuns(thread.id).some((run) => ["open", "claimed", "running"].includes(run.status))) return { ok: false, error: "an agent is still working in this thread; stop it or wait for it first" };
|
|
337
|
+
const moved = worktrees.moved(thread.id);
|
|
338
|
+
if (!moved.ok) return moved;
|
|
339
|
+
store.setGoalFolder(thread.id, where);
|
|
340
|
+
for (const name of Object.keys(thread.goal.sessions || {})) store.setGoalSession(thread.id, name, null);
|
|
341
|
+
for (const name of thread.goal.team) if (store.meta(`t3-thread:${thread.id}:${name}`)) store.meta(`t3-thread:${thread.id}:${name}`, "");
|
|
342
|
+
store.roomPost({ goalId: thread.id, author: "user", kind: "note", text: `Now working in ${where}.` });
|
|
343
|
+
notifyBoard(store.getTask(thread.id));
|
|
280
344
|
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
281
345
|
}
|
|
282
346
|
return { ok: false, error: "unknown command" };
|
package/lib/t3.mjs
CHANGED
|
@@ -201,7 +201,7 @@ function stepOf(item) {
|
|
|
201
201
|
/**
|
|
202
202
|
* A coding agent's turn in a Harness thread, carried out by T3 Code, so the person finds it in T3 like any thread there.
|
|
203
203
|
* The agent's first turn launches its own T3 thread (`launch`: T3's project, workspace and the model the person last
|
|
204
|
-
* chose for the agent there
|
|
204
|
+
* chose for the agent there, unless the relay chose one, `model`; T3 applies the mode the person approved Harness with); later turns go into that thread,
|
|
205
205
|
* queued behind whatever it is doing. It reports as runner.mjs does: accepted, its session (the T3 thread, which is
|
|
206
206
|
* how Open finds it), each step, then done with the agent's last words or failed; Stop interrupts it in T3.
|
|
207
207
|
*/
|
|
@@ -245,9 +245,12 @@ export function runInT3(run, emit, observe) {
|
|
|
245
245
|
emit("accepted");
|
|
246
246
|
void (async () => {
|
|
247
247
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
// A model the relay chose for this turn (models.mjs) is the thread's selection from this turn on.
|
|
249
|
+
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;
|
|
252
|
+
} else {
|
|
253
|
+
const launched = await t3Tool("t3_thread_launch", { title: run.title, message: run.brief, ...run.launch, ...(run.model ? { modelSelection: run.model } : {}) });
|
|
251
254
|
threadId = launched.threadId;
|
|
252
255
|
runId = launched.runId ?? null;
|
|
253
256
|
run.onThread?.(threadId);
|