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/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
- // build the same text in-process. Nothing waiting, or no daemon, prints nothing.
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
- export async function notePrompt(agent, input = null) {
98
- try { await daemonRequest({ command: "board.prompted", agent, pids: agentPids(), ...(input?.session_id ? placeOf(input) : {}) }, 1_500); } catch {}
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: agentPids(), ...placeOf(input) }, 1_500); } catch {}
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
- const BROWSER_PERSON_COMMANDS = new Set([...PHONE_COMMANDS, "board.goal.resume", "board.standing", "board.standing.check", "board.standing.off"]);
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 wanted = new Set([realDir(folder), top.status === 0 ? realDir(top.stdout.trim()) : null].filter(Boolean));
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("done", (resultText || "The run finished; check the repository for its changes.").slice(0, 20_000), { usage: usage.result() });
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
- return agent === "codex" ? bare.match(UUID)?.[0] ?? bare : bare;
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
- const lastFolder = (agent) => store.recentSessions(100).find((session) => session.agent === agent && isDir(session.cwd))?.cwd ?? null;
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-]*)\s*/g, "").slice(0, 120) || text.slice(0, 120);
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 named agent's last folder, else the latest coding session's on this computer.
225
- folder ??= [...team].map((name) => PROCESS_NAMES[name] ? lastFolder(name) : null).find(Boolean)
226
- ?? store.recentSessions(100).find((session) => PROCESS_NAMES[session.agent] && isDir(session.cwd))?.cwd ?? null;
227
- const objective = text.split("\n").map((line) => line.trim()).find(Boolean).replace(/@([a-z][a-z0-9-]*)\s*/g, "").slice(0, 120) || text.slice(0, 120);
228
- const thread = store.createGoal({ objective, originAgent: "user", provenance: provenanceFor("user"), team: [...team, "harness"], facilitator: "harness", workspace: folder ? path.resolve(folder) : null, maxPosts: defaults.maxPosts, maxWakes: defaults.maxWakes });
229
- if (from) { store.setGoalSession(thread.id, String(from.agent), nativeSessionId(String(from.agent), String(from.sessionId))); worktrees.inPlace(thread.id); }
230
- const opened = store.getTask(thread.id);
231
- const first = store.roomPost({ goalId: thread.id, author: "user", kind: "post", text, mentions: addressed(opened, text) });
232
- wakeFor(thread.id, first, "user");
233
- notifyBoard(store.getTask(thread.id));
234
- return { ok: true, thread: summary(store.getTask(thread.id)) };
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 an absolute path to a folder` };
279
- store.setGoalFolder(thread.id, path.resolve(folder));
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; T3 applies the mode the person approved Harness with); later turns go into that thread,
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
- if (threadId) runId = (await t3Tool("t3_thread_send", { threadId, message: run.brief, mode: "queue", clientRequestId: run.runId })).runId ?? null;
249
- else {
250
- const launched = await t3Tool("t3_thread_launch", { title: run.title, message: run.brief, ...run.launch });
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);