tryharness 0.7.18 → 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 +226 -57
- 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 +45 -0
- package/lib/runner.mjs +35 -6
- package/lib/store.mjs +32 -3
- package/lib/swarm.mjs +84 -23
- package/lib/t3.mjs +7 -4
- package/lib/worktrees.mjs +21 -5
- 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,8 @@ 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";
|
|
6
8
|
import { mainCheckout } from "./worktrees.mjs";
|
|
7
9
|
|
|
8
10
|
// Where a coding session runs, and how it opens there again. A session's place comes from its own environment: the
|
|
@@ -168,6 +170,43 @@ export function resumeLine(agent, sessionId, folder = null) {
|
|
|
168
170
|
return `${folder && path.isAbsolute(folder) ? `cd ${quote(folder)} && ` : ""}${resume} ${sessionId}`;
|
|
169
171
|
}
|
|
170
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
|
+
|
|
171
210
|
/** A session in its agent's own panel in an editor (Claude Code's, OpenAI's Codex extension), when it is installed there. */
|
|
172
211
|
export function panelLink(agent, editorId, sessionId) {
|
|
173
212
|
const panel = AGENTS[agent]?.panel;
|
|
@@ -235,6 +274,12 @@ export function t3Project(folder, home = t3Home()) {
|
|
|
235
274
|
return found ? { id: found[0], root: realDir(found[1]) } : null;
|
|
236
275
|
}
|
|
237
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
|
+
|
|
238
283
|
/** The model (and its options) the person last ran an agent with in T3 Code, as T3 records each thread's choice. */
|
|
239
284
|
export function t3Model(agent, home = t3Home()) {
|
|
240
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,24 +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
|
-
import { mainCheckout } from "./worktrees.mjs";
|
|
6
6
|
import { nameOf, resolveMember } from "./members.mjs";
|
|
7
|
-
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";
|
|
8
9
|
|
|
9
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
|
|
10
11
|
// it, its folder where coding agents work, and it runs on its own authority. A message the person sends wakes the
|
|
11
12
|
// agents it names (or the only agent in the thread), each resuming its own session; their runs' steps are read back
|
|
12
13
|
// from the run logs. The person's own terminal sessions are listed too and continue in a thread once idle.
|
|
13
14
|
|
|
14
|
-
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" };
|
|
15
16
|
const SAY_LIMIT = 4_000;
|
|
16
17
|
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
17
18
|
|
|
18
19
|
// The id an agent resumes a session by: the index keys sessions by their file name.
|
|
19
20
|
export function nativeSessionId(agent, sessionId) {
|
|
20
21
|
const bare = String(sessionId).replace(/\.jsonl$/, "");
|
|
21
|
-
|
|
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;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
// The folder each running coding agent works in, by agent, leaving out the runs the daemon started itself.
|
|
@@ -50,7 +52,7 @@ export function agentProcessCwds(exclude = new Set()) {
|
|
|
50
52
|
return cwds;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
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 }) {
|
|
54
56
|
const awaitingStart = (run) => run.status === "open" && Boolean(run.run_workspace) && !run.run_authorized_at;
|
|
55
57
|
const isDir = (folder) => typeof folder === "string" && path.isAbsolute(folder) && fs.statSync(folder, { throwIfNoEntry: false })?.isDirectory();
|
|
56
58
|
const agentsOf = (thread) => thread.goal.team.filter((agent) => agent !== thread.goal.facilitator);
|
|
@@ -116,12 +118,16 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
116
118
|
// The agent a request names in `to`: null for none, false for one no thread can have.
|
|
117
119
|
const recipient = (request) => (request.to == null || request.to === "" ? null : roomAgents.includes(String(request.to)) ? String(request.to) : false);
|
|
118
120
|
|
|
119
|
-
|
|
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) })) : [] });
|
|
120
126
|
|
|
121
127
|
const read = (thread) => {
|
|
122
128
|
const runs = store.threadRuns(thread.id).map((run) => ({
|
|
123
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,
|
|
124
|
-
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,
|
|
125
131
|
steps: readRunSteps(runLog(run.id), 300).map((step) => ({ ...step, text: step.text.slice(0, SAY_LIMIT) })),
|
|
126
132
|
}));
|
|
127
133
|
return { ok: true, thread: summary(thread), posts: store.roomPosts(thread.id, 0, 500), runs };
|
|
@@ -129,6 +135,8 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
129
135
|
|
|
130
136
|
// `to`: the one agent the person wrote to (the desktop's agent list), so their words go as written, with no @name.
|
|
131
137
|
const post = (thread, text, to = null) => {
|
|
138
|
+
const picked = picksIn(store, text, roomAgents);
|
|
139
|
+
if (picked.error) return { ok: false, error: picked.error };
|
|
132
140
|
// A new name in the person's message joins the thread.
|
|
133
141
|
const joining = [...new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])])].filter((agent) => !thread.goal.team.includes(agent));
|
|
134
142
|
if (joining.length) store.setGoalTeam(thread.id, [...thread.goal.team, ...joining]);
|
|
@@ -160,6 +168,19 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
160
168
|
const agents = swarmCards(store, roster().filter((entry) => entry.id !== agent));
|
|
161
169
|
return { ok: true, agents, text: swarmBlock(agents), digest: swarmDigest(agents) };
|
|
162
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
|
+
}
|
|
163
184
|
// Any of the person's agents may start a thread and stop one it started. The thread carries the agent's trust, so
|
|
164
185
|
// nothing in it runs without the person's Start, and one started through Harness gets no coordinator rounds (they
|
|
165
186
|
// are paid) until the person sets a spend ceiling.
|
|
@@ -169,8 +190,10 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
169
190
|
const folder = request.folder ? String(request.folder) : null;
|
|
170
191
|
if (folder && !isDir(folder)) return { ok: false, error: `${folder} is not an absolute path to a folder` };
|
|
171
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 };
|
|
172
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(", ")}` };
|
|
173
|
-
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);
|
|
174
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 });
|
|
175
198
|
if (request.hostedCaller) store.meta(`hosted-thread:${thread.id}`, "1");
|
|
176
199
|
const first = store.roomPost({ goalId: thread.id, author: agent, kind: "post", text, mentions: addressed(store.getTask(thread.id), text) });
|
|
@@ -186,12 +209,37 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
186
209
|
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
187
210
|
}
|
|
188
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
|
+
}
|
|
189
228
|
// The person's own note on one agent's card ("use it for reviews"), told to every other agent; no text clears it.
|
|
190
229
|
if (command === "board.swarm.note") {
|
|
191
230
|
const named = resolveMember(request.target);
|
|
192
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}"` };
|
|
193
232
|
return { ok: true, agent: named.id, note: setNote(store, named.id, request.text) };
|
|
194
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 };
|
|
195
243
|
// The coordinator's record: what each round saw, chose and cost. The evidence for its limits.
|
|
196
244
|
if (command === "board.swarm.rounds") return { ok: true, rounds: store.rounds(request.id ? String(request.id) : null, request.limit) };
|
|
197
245
|
if (command === "board.swarm.coordinator") {
|
|
@@ -211,6 +259,8 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
211
259
|
const to = recipient(request);
|
|
212
260
|
if (to === false) return { ok: false, error: `${request.to} can't be written to in a thread` };
|
|
213
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 };
|
|
214
264
|
if (from) {
|
|
215
265
|
const agentName = String(from.agent || ""), sessionId = String(from.sessionId || "");
|
|
216
266
|
if (!PROCESS_NAMES[agentName]) return { ok: false, error: "only a coding agent's session continues in a thread" };
|
|
@@ -222,19 +272,19 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
222
272
|
team.add(agentName);
|
|
223
273
|
folder ??= isDir(session.cwd) ? session.cwd : null;
|
|
224
274
|
}
|
|
225
|
-
// Where the work happens: the
|
|
226
|
-
//
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return
|
|
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)) };
|
|
238
288
|
}
|
|
239
289
|
const thread = store.getTask(String(request.id || ""));
|
|
240
290
|
if (!thread || thread.kind !== "goal") return { ok: false, error: "thread not found" };
|
|
@@ -276,10 +326,21 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
|
|
|
276
326
|
notifyBoard(store.getTask(thread.id));
|
|
277
327
|
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
278
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.
|
|
279
331
|
if (command === "board.swarm.folder") {
|
|
280
332
|
const folder = String(request.folder || "");
|
|
281
|
-
if (!isDir(folder)) return { ok: false, error: `${folder} is not
|
|
282
|
-
|
|
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));
|
|
283
344
|
return { ok: true, thread: summary(store.getTask(thread.id)) };
|
|
284
345
|
}
|
|
285
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);
|
package/lib/worktrees.mjs
CHANGED
|
@@ -142,17 +142,33 @@ export function createWorktrees({ store, root }) {
|
|
|
142
142
|
return { ok: true };
|
|
143
143
|
};
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The thread's folder changed: its copy of the old repository goes when nothing in it is the person's yet, and the
|
|
147
|
+
* next turn makes whatever the new folder needs. Changes not yet merged or discarded keep it where it is.
|
|
148
|
+
*/
|
|
149
|
+
const moved = (threadId) => {
|
|
150
|
+
const tree = get(threadId);
|
|
151
|
+
if (tree?.state === "open" && fs.existsSync(tree.path)) {
|
|
152
|
+
if (stat(tree.path, `${tree.base}..HEAD`).length) return { ok: false, error: `this thread changed files on ${tree.branch}; merge or discard them first` };
|
|
153
|
+
retire(tree);
|
|
154
|
+
git(tree.repo, ["branch", "-D", tree.branch]);
|
|
155
|
+
}
|
|
156
|
+
if (tree) save(threadId, null);
|
|
157
|
+
return { ok: true };
|
|
158
|
+
};
|
|
159
|
+
|
|
145
160
|
/** A thread that carries on a session from a terminal works where that session did: agents find sessions by folder. */
|
|
146
161
|
const inPlace = (threadId) => save(threadId, { state: "in_place" });
|
|
147
162
|
|
|
148
163
|
/**
|
|
149
164
|
* Where T3 Code runs a thread's agents (t3.mjs runInT3): the checkout T3 made for the thread's first agent, shared by
|
|
150
|
-
* the rest as its own worktree is, or the thread's own copy when it worked here before;
|
|
151
|
-
*
|
|
152
|
-
* no repository.
|
|
165
|
+
* the rest as its own worktree is, or the thread's own copy when it worked here before; a task's room that works in
|
|
166
|
+
* place, T3's project checkout itself; else a new one on its own branch from what the person has checked out (as
|
|
167
|
+
* folderFor's), or T3's project folder when the thread's folder is in no repository.
|
|
153
168
|
*/
|
|
154
169
|
const t3Workspace = (thread) => {
|
|
155
170
|
const current = get(thread.id);
|
|
171
|
+
if (current?.state === "in_place" || current?.inPlace) return { type: "root" };
|
|
156
172
|
if ((current?.state === "t3" || current?.state === "open") && current.path) return { type: "existing_worktree", worktreePath: current.path, ...(current.branch ? { branch: current.branch } : {}) };
|
|
157
173
|
const repo = repositoryOf(thread.goal.workspace);
|
|
158
174
|
if (!repo) return { type: "root" };
|
|
@@ -163,8 +179,8 @@ export function createWorktrees({ store, root }) {
|
|
|
163
179
|
/** The thread works in T3 Code from now on; `where` and `branch` once T3 has made its checkout. */
|
|
164
180
|
const inT3 = (threadId, where = null, branch = null) => {
|
|
165
181
|
const current = get(threadId);
|
|
166
|
-
if (current?.state !== "t3" || (where && where !== current.path)) save(threadId, { ...current, state: "t3", path: where ?? current?.path ?? null, branch: branch ?? current?.branch ?? null });
|
|
182
|
+
if (current?.state !== "t3" || (where && where !== current.path)) save(threadId, { ...current, ...(current?.state === "in_place" ? { inPlace: true } : {}), state: "t3", path: where ?? current?.path ?? null, branch: branch ?? current?.branch ?? null });
|
|
167
183
|
};
|
|
168
184
|
|
|
169
|
-
return { folderFor, commitTurn, diff, summary, merge, discard, inPlace, t3Workspace, inT3, get };
|
|
185
|
+
return { folderFor, commitTurn, diff, summary, merge, discard, moved, inPlace, t3Workspace, inT3, get };
|
|
170
186
|
}
|