tryharness 0.7.23 → 0.7.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/assets/opencode-plugin.js +48 -11
  2. package/assets/pi-extension.ts +105 -26
  3. package/cli.mjs +1238 -322
  4. package/lib/activity.mjs +197 -40
  5. package/lib/adapters.mjs +337 -54
  6. package/lib/agentnode.mjs +175 -35
  7. package/lib/atlas.mjs +383 -83
  8. package/lib/cards.mjs +174 -40
  9. package/lib/claudemem.mjs +74 -18
  10. package/lib/commands.mjs +13 -5
  11. package/lib/context.mjs +409 -71
  12. package/lib/control-client.mjs +5 -2
  13. package/lib/coordinator.mjs +274 -57
  14. package/lib/daemon.mjs +5248 -1024
  15. package/lib/desktop.mjs +149 -35
  16. package/lib/digest.mjs +54 -13
  17. package/lib/environment.mjs +103 -21
  18. package/lib/files.mjs +246 -13
  19. package/lib/firewall.mjs +169 -35
  20. package/lib/folders.mjs +179 -36
  21. package/lib/frames.mjs +214 -40
  22. package/lib/inference.mjs +463 -126
  23. package/lib/learn.mjs +78 -16
  24. package/lib/live.mjs +286 -61
  25. package/lib/localmodels.mjs +424 -83
  26. package/lib/mcp-stdio.mjs +868 -151
  27. package/lib/media.mjs +138 -28
  28. package/lib/members.json +1 -1
  29. package/lib/members.mjs +35 -9
  30. package/lib/models.mjs +342 -80
  31. package/lib/network.mjs +201 -42
  32. package/lib/notify.mjs +49 -12
  33. package/lib/nudge.mjs +184 -33
  34. package/lib/ocr.mjs +223 -60
  35. package/lib/onboard.mjs +321 -70
  36. package/lib/parsers.mjs +132 -41
  37. package/lib/paths.mjs +33 -10
  38. package/lib/personal.mjs +51 -13
  39. package/lib/phones.mjs +382 -60
  40. package/lib/places.mjs +555 -107
  41. package/lib/provenance.mjs +19 -4
  42. package/lib/receipts.mjs +51 -11
  43. package/lib/redact.mjs +512 -116
  44. package/lib/runner.mjs +345 -55
  45. package/lib/runtime-install.mjs +55 -14
  46. package/lib/screens.mjs +154 -38
  47. package/lib/sealed.mjs +157 -28
  48. package/lib/secrets.mjs +18 -5
  49. package/lib/service.mjs +170 -34
  50. package/lib/snippets.mjs +94 -18
  51. package/lib/speech.mjs +688 -175
  52. package/lib/store.mjs +1654 -307
  53. package/lib/swarm.mjs +596 -117
  54. package/lib/t3.mjs +400 -79
  55. package/lib/trails.mjs +285 -61
  56. package/lib/ui.mjs +95 -23
  57. package/lib/vectors.mjs +5 -2
  58. package/lib/wakes.mjs +284 -68
  59. package/lib/watches.mjs +247 -48
  60. package/lib/worktrees.mjs +186 -38
  61. package/package.json +3 -3
package/lib/swarm.mjs CHANGED
@@ -4,15 +4,35 @@ import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { readRunSteps } from "./runner.mjs";
6
6
  import { nameOf, resolveMember } from "./members.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
+ import {
8
+ modelOf,
9
+ setNote,
10
+ swarmBlock,
11
+ swarmCards,
12
+ swarmDigest,
13
+ } from "./cards.mjs";
14
+ import {
15
+ DEFAULT_ROUTING,
16
+ PICKABLE,
17
+ choiceOf,
18
+ listingOf,
19
+ picksIn,
20
+ routingOf,
21
+ setChoice,
22
+ setRouting,
23
+ } from "./models.mjs";
9
24
 
10
25
  // The person's swarm as threads. A thread is a goal under the hood: its room is the transcript, its team the agents in
11
26
  // it, its folder where coding agents work, and it runs on its own authority. A message the person sends wakes the
12
27
  // agents it names (or the only agent in the thread), each resuming its own session; their runs' steps are read back
13
28
  // from the run logs. The person's own terminal sessions are listed too and continue in a thread once idle.
14
29
 
15
- export const PROCESS_NAMES = { "claude-code": "claude", codex: "codex", pi: "pi", opencode: "opencode" };
30
+ export const PROCESS_NAMES = {
31
+ "claude-code": "claude",
32
+ codex: "codex",
33
+ pi: "pi",
34
+ opencode: "opencode",
35
+ };
16
36
  const SAY_LIMIT = 4_000;
17
37
  const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
18
38
 
@@ -20,7 +40,9 @@ const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
20
40
  export function nativeSessionId(agent, sessionId) {
21
41
  const bare = String(sessionId).replace(/\.jsonl$/, "");
22
42
  // 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;
43
+ return agent === "codex" || agent === "pi"
44
+ ? (bare.match(UUID)?.[0] ?? bare)
45
+ : bare;
24
46
  }
25
47
 
26
48
  // The folder each running coding agent works in, by agent, leaving out the runs the daemon started itself.
@@ -33,12 +55,18 @@ export function agentProcessCwds(exclude = new Set()) {
33
55
  for (const line of ps.stdout.split("\n")) {
34
56
  const match = line.trim().match(/^(\d+)\s+(.+)$/);
35
57
  if (!match || exclude.has(Number(match[1]))) continue;
36
- const agent = Object.keys(PROCESS_NAMES).find((name) => path.basename(match[2]) === PROCESS_NAMES[name]);
58
+ const agent = Object.keys(PROCESS_NAMES).find(
59
+ (name) => path.basename(match[2]) === PROCESS_NAMES[name]
60
+ );
37
61
  if (agent) byPid.set(match[1], agent);
38
62
  }
39
63
  const cwds = new Map();
40
64
  if (!byPid.size) return cwds;
41
- const lsof = spawnSync("lsof", ["-a", "-d", "cwd", "-Fn", "-p", [...byPid.keys()].join(",")], { encoding: "utf8" });
65
+ const lsof = spawnSync(
66
+ "lsof",
67
+ ["-a", "-d", "cwd", "-Fn", "-p", [...byPid.keys()].join(",")],
68
+ { encoding: "utf8" }
69
+ );
42
70
  if (lsof.status !== 0 && !lsof.stdout) return null;
43
71
  let pid = null;
44
72
  for (const line of lsof.stdout.split("\n")) {
@@ -52,10 +80,39 @@ export function agentProcessCwds(exclude = new Set()) {
52
80
  return cwds;
53
81
  }
54
82
 
55
- export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoard, goalCommand, stopThreadRuns, runLog, runningPids, provenanceFor, defaults, coordinator, worktrees, roster, authorizeRun, pickable, listModels, folders, runsIn, threadFiles = () => ({ files: [], rendering: [] }) }) {
56
- const awaitingStart = (run) => run.status === "open" && Boolean(run.run_workspace) && !run.run_authorized_at;
57
- const isDir = (folder) => typeof folder === "string" && path.isAbsolute(folder) && fs.statSync(folder, { throwIfNoEntry: false })?.isDirectory();
58
- const agentsOf = (thread) => thread.goal.team.filter((agent) => agent !== thread.goal.facilitator);
83
+ export function createSwarm({
84
+ store,
85
+ roomAgents,
86
+ mentionsIn,
87
+ wakeFor,
88
+ notifyBoard,
89
+ goalCommand,
90
+ stopThreadRuns,
91
+ runLog,
92
+ runningPids,
93
+ provenanceFor,
94
+ defaults,
95
+ coordinator,
96
+ worktrees,
97
+ roster,
98
+ authorizeRun,
99
+ pickable,
100
+ listModels,
101
+ folders,
102
+ runsIn,
103
+ threadFiles = () => ({ files: [], rendering: [] }),
104
+ whyWaiting = () => null,
105
+ }) {
106
+ const awaitingStart = (run) =>
107
+ run.status === "open" &&
108
+ Boolean(run.run_workspace) &&
109
+ !run.run_authorized_at;
110
+ const isDir = (folder) =>
111
+ typeof folder === "string" &&
112
+ path.isAbsolute(folder) &&
113
+ fs.statSync(folder, { throwIfNoEntry: false })?.isDirectory();
114
+ const agentsOf = (thread) =>
115
+ thread.goal.team.filter((agent) => agent !== thread.goal.facilitator);
59
116
 
60
117
  // Who a message is for: the agents it names, else the only agent in the thread, else whoever coordinates.
61
118
  const addressed = (thread, text) => {
@@ -67,15 +124,33 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
67
124
 
68
125
  // What the thread waits on the person for, if anything: a pause, an agent's question, a run's question.
69
126
  const waitingOn = (thread, posts, runs) => {
70
- if (thread.status === "paused") return { kind: "paused", text: thread.goal.paused_reason || "Paused." };
71
- const asking = runs.find((run) => run.status === "needs_input" && run.holder === "user");
72
- if (asking) return { kind: "question", agent: asking.claimed_by || asking.target_agent, text: asking.result || "" };
127
+ if (thread.status === "paused")
128
+ return { kind: "paused", text: thread.goal.paused_reason || "Paused." };
129
+ const asking = runs.find(
130
+ (run) => run.status === "needs_input" && run.holder === "user"
131
+ );
132
+ if (asking)
133
+ return {
134
+ kind: "question",
135
+ agent: asking.claimed_by || asking.target_agent,
136
+ text: asking.result || "",
137
+ };
73
138
  // Work another agent asked for that only the person's Start lets run (docs/member-runs.md).
74
139
  const held = runs.find(awaitingStart);
75
- if (held) return { kind: "start", agent: held.target_agent, runId: held.id, text: `${nameOf(held.target_agent)} waits for your Start` };
140
+ if (held)
141
+ return {
142
+ kind: "start",
143
+ agent: held.target_agent,
144
+ runId: held.id,
145
+ text: `${nameOf(held.target_agent)} waits for your Start`,
146
+ };
76
147
  const lastMine = posts.findLastIndex((post) => post.author === "user");
77
- const question = posts.slice(lastMine + 1).findLast((post) => post.kind === "question");
78
- return question ? { kind: "question", agent: question.author, text: question.text } : null;
148
+ const question = posts
149
+ .slice(lastMine + 1)
150
+ .findLast((post) => post.kind === "question");
151
+ return question
152
+ ? { kind: "question", agent: question.author, text: question.text }
153
+ : null;
79
154
  };
80
155
 
81
156
  const summary = (thread) => {
@@ -85,17 +160,64 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
85
160
  const live = runs.find((run) => run.status === "running");
86
161
  const step = live ? readRunSteps(runLog(live.id), 1)[0] : null;
87
162
  return {
88
- id: thread.id, title: thread.objective, status: thread.status, agents: agentsOf(thread), coordinator: thread.goal.facilitator,
163
+ id: thread.id,
164
+ title: thread.objective,
165
+ status: thread.status,
166
+ agents: agentsOf(thread),
167
+ coordinator: thread.goal.facilitator,
89
168
  // The task this room was opened for, when it is a task's own room.
90
169
  root: store.meta(`task-room:${thread.id}`) || null,
91
- folder: thread.goal.workspace, working: [...new Set(runs.filter((run) => ["claimed", "running"].includes(run.status)).map((run) => run.target_agent))],
92
- queued: [...new Set(runs.filter((run) => run.status === "open").map((run) => run.target_agent))],
93
- step: step ? { agent: live.claimed_by || live.target_agent, kind: step.kind, text: step.text.split("\n")[0].slice(0, 200) } : null,
94
- waiting: waitingOn(thread, posts, runs), last: last ? { author: last.author, kind: last.kind, text: last.text.slice(0, 280), at: last.created_at, mentions: last.mentions } : null,
95
- posts: thread.goal.posts, spend: thread.goal.spend, createdAt: thread.created_at, updatedAt: last?.created_at ?? thread.updated_at,
170
+ folder: thread.goal.workspace,
171
+ working: [
172
+ ...new Set(
173
+ runs
174
+ .filter((run) => ["claimed", "running"].includes(run.status))
175
+ .map((run) => run.target_agent)
176
+ ),
177
+ ],
178
+ queued: [
179
+ ...new Set(
180
+ runs
181
+ .filter((run) => run.status === "open")
182
+ .map((run) => run.target_agent)
183
+ ),
184
+ ],
185
+ // Why each agent given work here has not started, in a sentence: every surface says it the same way.
186
+ waits: runs
187
+ .map((run) => ({
188
+ agent: run.target_agent,
189
+ text: whyWaiting(thread.id, run),
190
+ }))
191
+ .filter((wait) => wait.text),
192
+ step: step
193
+ ? {
194
+ agent: live.claimed_by || live.target_agent,
195
+ kind: step.kind,
196
+ text: step.text.split("\n")[0].slice(0, 200),
197
+ }
198
+ : null,
199
+ waiting: waitingOn(thread, posts, runs),
200
+ last: last
201
+ ? {
202
+ author: last.author,
203
+ kind: last.kind,
204
+ text: last.text.slice(0, 280),
205
+ at: last.created_at,
206
+ mentions: last.mentions,
207
+ }
208
+ : null,
209
+ posts: thread.goal.posts,
210
+ spend: thread.goal.spend,
211
+ createdAt: thread.created_at,
212
+ updatedAt: last?.created_at ?? thread.updated_at,
96
213
  // Parts the swarm moved past: never silent, one tap sends one back.
97
- openItems: posts.filter((post) => post.kind === "open_item").map((post) => ({ seq: post.seq, text: post.text })),
98
- coordination: { mode: coordinator.mode(), rounds: coordinator.state(thread.id).rounds },
214
+ openItems: posts
215
+ .filter((post) => post.kind === "open_item")
216
+ .map((post) => ({ seq: post.seq, text: post.text })),
217
+ coordination: {
218
+ mode: coordinator.mode(),
219
+ rounds: coordinator.state(thread.id).rounds,
220
+ },
99
221
  changes: worktrees.summary(thread.id),
100
222
  };
101
223
  };
@@ -104,35 +226,94 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
104
226
  const sessions = () => {
105
227
  const live = agentProcessCwds(new Set(runningPids()));
106
228
  const newestIn = new Set();
107
- return store.recentSessions(30).filter((session) => PROCESS_NAMES[session.agent] && !store.threadForSession(session.agent, nativeSessionId(session.agent, session.session_id))).map((session) => {
108
- const key = `${session.agent}:${session.cwd}`;
109
- const newest = !newestIn.has(key);
110
- newestIn.add(key);
111
- const open = live === null || (newest && Boolean(live.get(session.agent)?.has(session.cwd)));
112
- return { agent: session.agent, sessionId: session.session_id, title: session.title, folder: session.cwd, startedAt: session.started_at, endedAt: session.ended_at, messages: session.message_count, open };
113
- });
229
+ return store
230
+ .recentSessions(30)
231
+ .filter(
232
+ (session) =>
233
+ PROCESS_NAMES[session.agent] &&
234
+ !store.threadForSession(
235
+ session.agent,
236
+ nativeSessionId(session.agent, session.session_id)
237
+ )
238
+ )
239
+ .map((session) => {
240
+ const key = `${session.agent}:${session.cwd}`;
241
+ const newest = !newestIn.has(key);
242
+ newestIn.add(key);
243
+ const open =
244
+ live === null ||
245
+ (newest && Boolean(live.get(session.agent)?.has(session.cwd)));
246
+ return {
247
+ agent: session.agent,
248
+ sessionId: session.session_id,
249
+ title: session.title,
250
+ folder: session.cwd,
251
+ startedAt: session.started_at,
252
+ endedAt: session.ended_at,
253
+ messages: session.message_count,
254
+ open,
255
+ };
256
+ });
114
257
  };
115
258
 
116
- const sessionOpen = (agent, sessionId) => sessions().find((session) => session.agent === agent && session.sessionId === sessionId)?.open ?? false;
259
+ const sessionOpen = (agent, sessionId) =>
260
+ sessions().find(
261
+ (session) => session.agent === agent && session.sessionId === sessionId
262
+ )?.open ?? false;
117
263
 
118
264
  // The agent a request names in `to`: null for none, false for one no thread can have.
119
- const recipient = (request) => (request.to == null || request.to === "" ? null : roomAgents.includes(String(request.to)) ? String(request.to) : false);
265
+ const recipient = (request) =>
266
+ request.to == null || request.to === ""
267
+ ? null
268
+ : roomAgents.includes(String(request.to))
269
+ ? String(request.to)
270
+ : false;
120
271
 
121
272
  // 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]);
273
+ const needsFolder = (team) =>
274
+ !team.length || team.some((name) => PROCESS_NAMES[name]);
123
275
  // 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
276
  // `lastWorked`: where its agent last worked, when Harness picked another folder from the message, for the person to take.
125
- const placeOf = (folder, team, by, lastWorked = null) => ({ ok: true, folder, name: folder ? path.basename(folder) : null, by,
126
- lastWorked: lastWorked ? { folder: lastWorked, name: path.basename(lastWorked) } : null,
127
- runs: folder ? team.filter((name) => PROCESS_NAMES[name]).map((name) => ({ agent: name, ...runsIn(name, folder) })) : [] });
277
+ const placeOf = (folder, team, by, lastWorked = null) => ({
278
+ ok: true,
279
+ folder,
280
+ name: folder ? path.basename(folder) : null,
281
+ by,
282
+ lastWorked: lastWorked
283
+ ? { folder: lastWorked, name: path.basename(lastWorked) }
284
+ : null,
285
+ runs: folder
286
+ ? team
287
+ .filter((name) => PROCESS_NAMES[name])
288
+ .map((name) => ({ agent: name, ...runsIn(name, folder) }))
289
+ : [],
290
+ });
128
291
 
129
292
  const read = (thread, hostedCaller = false) => {
130
293
  const runs = store.threadRuns(thread.id).map((run) => ({
131
- 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,
132
- sessionId: run.session_id, folder: run.run_workspace, usage: run.usage, model: run.run_model,
133
- steps: readRunSteps(runLog(run.id), 300).map((step) => ({ ...step, text: step.text.slice(0, SAY_LIMIT) })),
294
+ id: run.id,
295
+ agent: run.claimed_by || run.target_agent,
296
+ kind: run.kind,
297
+ status: run.status,
298
+ awaitingStart: awaitingStart(run),
299
+ startedAt: run.created_at,
300
+ endedAt: run.reported_at,
301
+ sessionId: run.session_id,
302
+ folder: run.run_workspace,
303
+ usage: run.usage,
304
+ model: run.run_model,
305
+ steps: readRunSteps(runLog(run.id), 300).map((step) => ({
306
+ ...step,
307
+ text: step.text.slice(0, SAY_LIMIT),
308
+ })),
134
309
  }));
135
- return { ok: true, thread: summary(thread), posts: store.roomPosts(thread.id, 0, 500), runs, ...threadFiles(thread, hostedCaller) };
310
+ return {
311
+ ok: true,
312
+ thread: summary(thread),
313
+ posts: store.roomPosts(thread.id, 0, 500),
314
+ runs,
315
+ ...threadFiles(thread, hostedCaller),
316
+ };
136
317
  };
137
318
 
138
319
  // `to`: the one agent the person wrote to (the desktop's agent list), so their words go as written, with no @name.
@@ -140,191 +321,467 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
140
321
  const picked = picksIn(store, text, roomAgents);
141
322
  if (picked.error) return { ok: false, error: picked.error };
142
323
  // A new name in the person's message joins the thread.
143
- const joining = [...new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])])].filter((agent) => !thread.goal.team.includes(agent));
144
- if (joining.length) store.setGoalTeam(thread.id, [...thread.goal.team, ...joining]);
324
+ const joining = [
325
+ ...new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])]),
326
+ ].filter((agent) => !thread.goal.team.includes(agent));
327
+ if (joining.length)
328
+ store.setGoalTeam(thread.id, [...thread.goal.team, ...joining]);
145
329
  if (thread.status !== "open") {
146
330
  store.setGoalState(thread.id, { status: "open" });
147
- store.roomPost({ goalId: thread.id, author: "user", kind: "note", text: "Picked up again by you." });
331
+ store.roomPost({
332
+ goalId: thread.id,
333
+ author: "user",
334
+ kind: "note",
335
+ text: "Picked up again by you.",
336
+ });
148
337
  }
149
338
  const current = store.getTask(thread.id);
150
- return goalCommand({ command: "board.room.post", id: thread.id, text, mentions: to ? [to] : addressed(current, text) }, "user");
339
+ return goalCommand(
340
+ {
341
+ command: "board.room.post",
342
+ id: thread.id,
343
+ text,
344
+ mentions: to ? [to] : addressed(current, text),
345
+ },
346
+ "user"
347
+ );
151
348
  };
152
349
 
153
350
  return function swarmCommand(request, agent) {
154
351
  const command = String(request.command);
155
352
  if (command === "board.swarm.list") {
156
- if (agent !== "user" && agent !== "harness") return { ok: false, error: "only the person and Harness read the swarm" };
157
- return { ok: true, threads: store.goals({ all: request.all === true }).map(summary), sessions: request.sessions === false ? [] : sessions(), agents: roster() };
353
+ if (agent !== "user" && agent !== "harness")
354
+ return {
355
+ ok: false,
356
+ error: "only the person and Harness read the swarm",
357
+ };
358
+ return {
359
+ ok: true,
360
+ threads: store.goals({ all: request.all === true }).map(summary),
361
+ sessions: request.sessions === false ? [] : sessions(),
362
+ agents: roster(),
363
+ };
158
364
  }
159
365
  // The swarm's shared memory, for any of the person's agents: what any agent wrote in any thread, and the coding
160
366
  // sessions on this machine. A caller limited to its own threads (a hosted agent while the machine is sealed) gets only those.
161
367
  if (command === "board.swarm.search") {
162
- const query = String(request.query || "").trim().slice(0, 500);
368
+ const query = String(request.query || "")
369
+ .trim()
370
+ .slice(0, 500);
163
371
  if (!query) return { ok: false, error: "search needs a query" };
164
- const mine = request.teamOnly ? store.goals({ all: true }).filter((thread) => thread.goal.team.includes(agent)).map((thread) => thread.id) : null;
165
- return { ok: true, threads: store.searchThreads(query, { goalIds: mine, k: 8 }), sessions: request.teamOnly ? [] : store.search({ query, limit: 5 }) };
372
+ const mine = request.teamOnly
373
+ ? store
374
+ .goals({ all: true })
375
+ .filter((thread) => thread.goal.team.includes(agent))
376
+ .map((thread) => thread.id)
377
+ : null;
378
+ return {
379
+ ok: true,
380
+ threads: store.searchThreads(query, { goalIds: mine, k: 8 }),
381
+ sessions: request.teamOnly ? [] : store.search({ query, limit: 5 }),
382
+ };
166
383
  }
167
384
  // Who can be asked: every other agent's card (cards.mjs), and the same cards as the block an agent is told when its
168
385
  // session starts. Open to every caller, so an agent picks a name instead of guessing one.
169
386
  if (command === "board.swarm.agents") {
170
- const agents = swarmCards(store, roster().filter((entry) => entry.id !== agent));
171
- return { ok: true, agents, text: swarmBlock(agents), digest: swarmDigest(agents) };
387
+ const agents = swarmCards(
388
+ store,
389
+ roster().filter((entry) => entry.id !== agent)
390
+ );
391
+ return {
392
+ ok: true,
393
+ agents,
394
+ text: swarmBlock(agents),
395
+ digest: swarmDigest(agents),
396
+ };
172
397
  }
173
398
  // Each coding agent's models as it lists them, the person's choice for it and their routing words (models.mjs). Open
174
399
  // to every caller, so an agent names a model that exists: post_task's model, or `@codex:model/effort` in a post.
175
400
  if (command === "board.swarm.models") {
176
401
  const named = request.target ? resolveMember(request.target) : null;
177
- if (named && !named.ok) return { ok: false, error: `no agent called "${request.target}"` };
402
+ if (named && !named.ok)
403
+ return { ok: false, error: `no agent called "${request.target}"` };
178
404
  const ids = pickable().filter((id) => !named || id === named.id);
179
- 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)}` };
180
- const listing = () => ({ ok: true, routing: routingOf(store), defaultRouting: DEFAULT_ROUTING, agents: ids.map((id) => {
181
- const listed = listingOf(store, id);
182
- return { id, name: nameOf(id), choice: choiceOf(store, id), ownModel: modelOf(id), listedAt: listed?.at ?? null, ...(listed?.error ? { error: listed.error } : {}), models: listed?.models ?? [] };
183
- }) });
184
- return request.refresh === true && agent === "user" ? { pending: listModels().then(listing) } : listing();
405
+ if (named && !ids.length)
406
+ return {
407
+ ok: false,
408
+ error: `the relay sets the model of ${[...PICKABLE].join(", ")} when they are installed here, not ${nameOf(named.id)}`,
409
+ };
410
+ const listing = () => ({
411
+ ok: true,
412
+ routing: routingOf(store),
413
+ defaultRouting: DEFAULT_ROUTING,
414
+ agents: ids.map((id) => {
415
+ const listed = listingOf(store, id);
416
+ return {
417
+ id,
418
+ name: nameOf(id),
419
+ choice: choiceOf(store, id),
420
+ ownModel: modelOf(id),
421
+ listedAt: listed?.at ?? null,
422
+ ...(listed?.error ? { error: listed.error } : {}),
423
+ models: listed?.models ?? [],
424
+ };
425
+ }),
426
+ });
427
+ return request.refresh === true && agent === "user"
428
+ ? { pending: listModels().then(listing) }
429
+ : listing();
185
430
  }
186
431
  // Any of the person's agents may start a thread and stop one it started. The thread carries the agent's trust, so
187
432
  // nothing in it runs without the person's Start, and one started through Harness gets no coordinator rounds (they
188
433
  // are paid) until the person sets a spend ceiling.
189
434
  if (agent !== "user" && command === "board.swarm.start") {
190
- const text = String(request.text || "").trim().slice(0, 20_000);
435
+ const text = String(request.text || "")
436
+ .trim()
437
+ .slice(0, 20_000);
191
438
  if (!text) return { ok: false, error: "a thread starts with a message" };
192
439
  const folder = request.folder ? String(request.folder) : null;
193
- if (folder && !isDir(folder)) return { ok: false, error: `${folder} is not an absolute path to a folder` };
440
+ if (folder && !isDir(folder))
441
+ return {
442
+ ok: false,
443
+ error: `${folder} is not an absolute path to a folder`,
444
+ };
194
445
  const team = new Set(mentionsIn(text, roomAgents));
195
446
  const picked = picksIn(store, text, roomAgents);
196
447
  if (picked.error) return { ok: false, error: picked.error };
197
- if (!team.size) return { ok: false, error: `name who the thread is for with @, one of: ${roomAgents.filter((name) => name !== agent && name !== "harness").join(", ")}` };
198
- 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);
199
- 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 });
448
+ if (!team.size)
449
+ return {
450
+ ok: false,
451
+ error: `name who the thread is for with @, one of: ${roomAgents.filter((name) => name !== agent && name !== "harness").join(", ")}`,
452
+ };
453
+ const objective =
454
+ text
455
+ .split("\n")
456
+ .map((line) => line.trim())
457
+ .find(Boolean)
458
+ .replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "")
459
+ .slice(0, 120) || text.slice(0, 120);
460
+ const thread = store.createGoal({
461
+ objective,
462
+ originAgent: agent,
463
+ provenance: provenanceFor(agent),
464
+ team: [...team, agent, "harness"],
465
+ facilitator: "harness",
466
+ workspace: folder ? path.resolve(folder) : null,
467
+ maxPosts: defaults.maxPosts,
468
+ maxWakes: defaults.maxWakes,
469
+ });
200
470
  if (request.hostedCaller) store.meta(`hosted-thread:${thread.id}`, "1");
201
- const first = store.roomPost({ goalId: thread.id, author: agent, kind: "post", text, mentions: addressed(store.getTask(thread.id), text) });
471
+ const first = store.roomPost({
472
+ goalId: thread.id,
473
+ author: agent,
474
+ kind: "post",
475
+ text,
476
+ mentions: addressed(store.getTask(thread.id), text),
477
+ });
202
478
  wakeFor(thread.id, first, agent);
203
479
  notifyBoard(store.getTask(thread.id));
204
- return { ok: true, thread: summary(store.getTask(thread.id)), note: "Agents here run only after the person's Start; read the thread with read_room and add to it with post_to_room." };
480
+ return {
481
+ ok: true,
482
+ thread: summary(store.getTask(thread.id)),
483
+ note: "Agents here run only after the person's Start; read the thread with read_room and add to it with post_to_room.",
484
+ };
205
485
  }
206
486
  if (agent !== "user" && command === "board.swarm.stop") {
207
487
  const thread = store.getTask(String(request.id || ""));
208
- if (!thread || thread.kind !== "goal" || thread.origin_agent !== agent) return { ok: false, error: "thread not found" };
209
- if (thread.status !== "open") return { ok: false, error: `the thread is ${thread.status}` };
488
+ if (!thread || thread.kind !== "goal" || thread.origin_agent !== agent)
489
+ return { ok: false, error: "thread not found" };
490
+ if (thread.status !== "open")
491
+ return { ok: false, error: `the thread is ${thread.status}` };
210
492
  stopThreadRuns(thread.id);
211
493
  return { ok: true, thread: summary(store.getTask(thread.id)) };
212
494
  }
213
- 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" };
495
+ if (agent !== "user")
496
+ return {
497
+ ok: false,
498
+ error:
499
+ "only the person writes in, stops or resumes a thread they did not start; post in it with post_to_room",
500
+ };
214
501
  // Where a thread works, for the composer (folders.mjs): the folders the person works in, any folder on the computer
215
502
  // from a typed path, and where a message would work: the folder chosen (`folder`) or the one Harness suggests, and
216
503
  // where each coding agent named in it (`text`, `to`, `agents`) runs its turns there.
217
- if (command === "board.swarm.places") return { ok: true, places: folders.known(), home: os.homedir() };
504
+ if (command === "board.swarm.places")
505
+ return { ok: true, places: folders.known(), home: os.homedir() };
218
506
  if (command === "board.swarm.browse") return folders.browse(request.path);
219
507
  if (command === "board.swarm.place") {
220
508
  const text = String(request.text || "").slice(0, 20_000);
221
509
  const to = recipient(request);
222
- const team = [...new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : []), ...(Array.isArray(request.agents) ? request.agents.map(String).filter((name) => roomAgents.includes(name)) : [])])];
510
+ const team = [
511
+ ...new Set([
512
+ ...mentionsIn(text, roomAgents),
513
+ ...(to ? [to] : []),
514
+ ...(Array.isArray(request.agents)
515
+ ? request.agents
516
+ .map(String)
517
+ .filter((name) => roomAgents.includes(name))
518
+ : []),
519
+ ]),
520
+ ];
223
521
  if (request.folder) {
224
522
  const folder = String(request.folder);
225
- return isDir(folder) ? placeOf(path.resolve(folder), team, "you") : { ok: false, error: `${folder} is not a folder on this computer` };
523
+ return isDir(folder)
524
+ ? placeOf(path.resolve(folder), team, "you")
525
+ : { ok: false, error: `${folder} is not a folder on this computer` };
226
526
  }
227
527
  if (!needsFolder(team)) return placeOf(null, team, null);
228
- return { pending: folders.suggest(text, team).then(({ folder, by, lastWorked }) => placeOf(folder, team, by, lastWorked)) };
528
+ return {
529
+ pending: folders
530
+ .suggest(text, team)
531
+ .then(({ folder, by, lastWorked }) =>
532
+ placeOf(folder, team, by, lastWorked)
533
+ ),
534
+ };
229
535
  }
230
536
  // The person's own note on one agent's card ("use it for reviews"), told to every other agent; no text clears it.
231
537
  if (command === "board.swarm.note") {
232
538
  const named = resolveMember(request.target);
233
- 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}"` };
234
- return { ok: true, agent: named.id, note: setNote(store, named.id, request.text) };
539
+ if (!named.ok)
540
+ return {
541
+ ok: false,
542
+ error: named.candidates.length
543
+ ? `"${request.target}" could be ${named.candidates.join(" or ")}; name one`
544
+ : `no agent called "${request.target}"`,
545
+ };
546
+ return {
547
+ ok: true,
548
+ agent: named.id,
549
+ note: setNote(store, named.id, request.text),
550
+ };
235
551
  }
236
552
  // The person's choice of model and effort for one agent: a model it lists, "auto", or "default" for its own settings.
237
553
  if (command === "board.swarm.model") {
238
554
  const named = resolveMember(request.target);
239
- if (!named.ok || !PICKABLE.has(named.id)) return { ok: false, error: `the relay sets the model of ${[...PICKABLE].join(", ")}, not "${request.target}"` };
555
+ if (!named.ok || !PICKABLE.has(named.id))
556
+ return {
557
+ ok: false,
558
+ error: `the relay sets the model of ${[...PICKABLE].join(", ")}, not "${request.target}"`,
559
+ };
240
560
  const set = setChoice(store, named.id, request.model, request.effort);
241
561
  return set.ok ? { ok: true, agent: named.id, choice: set.choice } : set;
242
562
  }
243
563
  // The person's words on how Auto picks models for swarm work; no text goes back to the default words.
244
- if (command === "board.swarm.routing") return { ok: true, routing: setRouting(store, request.text), defaultRouting: DEFAULT_ROUTING };
564
+ if (command === "board.swarm.routing")
565
+ return {
566
+ ok: true,
567
+ routing: setRouting(store, request.text),
568
+ defaultRouting: DEFAULT_ROUTING,
569
+ };
245
570
  // The coordinator's record: what each round saw, chose and cost. The evidence for its limits.
246
- if (command === "board.swarm.rounds") return { ok: true, rounds: store.rounds(request.id ? String(request.id) : null, request.limit) };
571
+ if (command === "board.swarm.rounds")
572
+ return {
573
+ ok: true,
574
+ rounds: store.rounds(
575
+ request.id ? String(request.id) : null,
576
+ request.limit
577
+ ),
578
+ };
247
579
  if (command === "board.swarm.coordinator") {
248
- if (request.mode === "harness" || request.mode === "local") coordinator.setMode(request.mode);
580
+ if (request.mode === "harness" || request.mode === "local")
581
+ coordinator.setMode(request.mode);
249
582
  return { ok: true, mode: coordinator.mode() };
250
583
  }
251
584
  if (command === "board.swarm.stop_all") {
252
- const stopped = store.goals().filter((thread) => thread.status === "open").map((thread) => { stopThreadRuns(thread.id); return thread.id; });
585
+ const stopped = store
586
+ .goals()
587
+ .filter((thread) => thread.status === "open")
588
+ .map((thread) => {
589
+ stopThreadRuns(thread.id);
590
+ return thread.id;
591
+ });
253
592
  return { ok: true, stopped };
254
593
  }
255
594
  if (command === "board.swarm.start") {
256
- const text = String(request.text || "").trim().slice(0, 20_000);
595
+ const text = String(request.text || "")
596
+ .trim()
597
+ .slice(0, 20_000);
257
598
  if (!text) return { ok: false, error: "a thread starts with a message" };
258
- const from = request.continueSession && typeof request.continueSession === "object" ? request.continueSession : null;
599
+ const from =
600
+ request.continueSession && typeof request.continueSession === "object"
601
+ ? request.continueSession
602
+ : null;
259
603
  let folder = request.folder ? String(request.folder) : null;
260
- if (folder && !isDir(folder)) return { ok: false, error: `${folder} is not an absolute path to a folder` };
604
+ if (folder && !isDir(folder))
605
+ return {
606
+ ok: false,
607
+ error: `${folder} is not an absolute path to a folder`,
608
+ };
261
609
  const to = recipient(request);
262
- if (to === false) return { ok: false, error: `${request.to} can't be written to in a thread` };
263
- const team = new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])]);
610
+ if (to === false)
611
+ return {
612
+ ok: false,
613
+ error: `${request.to} can't be written to in a thread`,
614
+ };
615
+ const team = new Set([
616
+ ...mentionsIn(text, roomAgents),
617
+ ...(to ? [to] : []),
618
+ ]);
264
619
  const picked = picksIn(store, text, roomAgents);
265
620
  if (picked.error) return { ok: false, error: picked.error };
266
621
  if (from) {
267
- const agentName = String(from.agent || ""), sessionId = String(from.sessionId || "");
268
- if (!PROCESS_NAMES[agentName]) return { ok: false, error: "only a coding agent's session continues in a thread" };
622
+ const agentName = String(from.agent || ""),
623
+ sessionId = String(from.sessionId || "");
624
+ if (!PROCESS_NAMES[agentName])
625
+ return {
626
+ ok: false,
627
+ error: "only a coding agent's session continues in a thread",
628
+ };
269
629
  const session = store.read({ agent: agentName, sessionId });
270
630
  if (!session) return { ok: false, error: "session not found" };
271
- const already = store.threadForSession(agentName, nativeSessionId(agentName, sessionId));
631
+ const already = store.threadForSession(
632
+ agentName,
633
+ nativeSessionId(agentName, sessionId)
634
+ );
272
635
  if (already) return post(store.getTask(already), text);
273
- if (sessionOpen(agentName, sessionId)) return { ok: false, error: "that session is still open in a terminal; it continues here once it is closed there" };
636
+ if (sessionOpen(agentName, sessionId))
637
+ return {
638
+ ok: false,
639
+ error:
640
+ "that session is still open in a terminal; it continues here once it is closed there",
641
+ };
274
642
  team.add(agentName);
275
643
  folder ??= isDir(session.cwd) ? session.cwd : null;
276
644
  }
277
645
  // Where the work happens: the folder the person chose, else one picked from what they wrote, else where its agent
278
646
  // last worked (folders.mjs); never the home folder or a hidden one.
279
647
  const begin = (where) => {
280
- 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);
281
- 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 });
282
- if (from) { store.setGoalSession(thread.id, String(from.agent), nativeSessionId(String(from.agent), String(from.sessionId))); worktrees.inPlace(thread.id); }
648
+ const objective =
649
+ text
650
+ .split("\n")
651
+ .map((line) => line.trim())
652
+ .find(Boolean)
653
+ .replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "")
654
+ .slice(0, 120) || text.slice(0, 120);
655
+ const thread = store.createGoal({
656
+ objective,
657
+ originAgent: "user",
658
+ provenance: provenanceFor("user"),
659
+ team: [...new Set([...team, "harness"])],
660
+ facilitator: "harness",
661
+ workspace: where ? path.resolve(where) : null,
662
+ maxPosts: defaults.maxPosts,
663
+ maxWakes: defaults.maxWakes,
664
+ });
665
+ if (from) {
666
+ store.setGoalSession(
667
+ thread.id,
668
+ String(from.agent),
669
+ nativeSessionId(String(from.agent), String(from.sessionId))
670
+ );
671
+ worktrees.inPlace(thread.id);
672
+ }
283
673
  const opened = store.getTask(thread.id);
284
- const first = store.roomPost({ goalId: thread.id, author: "user", kind: "post", text, mentions: addressed(opened, text) });
674
+ const first = store.roomPost({
675
+ goalId: thread.id,
676
+ author: "user",
677
+ kind: "post",
678
+ text,
679
+ mentions: addressed(opened, text),
680
+ });
285
681
  wakeFor(thread.id, first, "user");
286
682
  notifyBoard(store.getTask(thread.id));
287
683
  return { ok: true, thread: summary(store.getTask(thread.id)) };
288
684
  };
289
- return folder || !needsFolder([...team]) ? begin(folder) : { pending: folders.suggest(text, [...team]).then((suggested) => begin(suggested.folder)) };
685
+ return folder || !needsFolder([...team])
686
+ ? begin(folder)
687
+ : {
688
+ pending: folders
689
+ .suggest(text, [...team])
690
+ .then((suggested) => begin(suggested.folder)),
691
+ };
290
692
  }
291
693
  const thread = store.getTask(String(request.id || ""));
292
- if (!thread || thread.kind !== "goal") return { ok: false, error: "thread not found" };
293
- if (command === "board.swarm.read") return read(thread, request.hostedCaller === true);
294
- if (command === "board.swarm.diff") return worktrees.diff(thread.id, request.runId ? String(request.runId) : null);
694
+ if (!thread || thread.kind !== "goal")
695
+ return { ok: false, error: "thread not found" };
696
+ if (command === "board.swarm.read")
697
+ return read(thread, request.hostedCaller === true);
698
+ if (command === "board.swarm.diff")
699
+ return worktrees.diff(
700
+ thread.id,
701
+ request.runId ? String(request.runId) : null
702
+ );
295
703
  if (command === "board.swarm.merge" || command === "board.swarm.discard") {
296
704
  // Both change the person's repository, so both wait for the person, and never while an agent is changing files.
297
- 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" };
298
- const done = command === "board.swarm.merge" ? worktrees.merge(thread.id, thread.objective) : worktrees.discard(thread.id);
299
- if (done.ok) store.roomPost({ goalId: thread.id, author: "user", kind: "note", text: command === "board.swarm.merge" ? `Merged into ${done.into}.` : "Discarded this thread's changes." });
300
- else if (done.conflict) store.roomPost({ goalId: thread.id, author: thread.goal.facilitator, kind: "question", text: `${done.error[0].toUpperCase()}${done.error.slice(1)}. Resolve it in your checkout, or ask an agent here to bring the thread's branch up to date first.` });
705
+ if (
706
+ store
707
+ .threadRuns(thread.id)
708
+ .some((run) => ["open", "claimed", "running"].includes(run.status))
709
+ )
710
+ return {
711
+ ok: false,
712
+ error:
713
+ "an agent is still working in this thread; stop it or wait for it first",
714
+ };
715
+ const done =
716
+ command === "board.swarm.merge"
717
+ ? worktrees.merge(thread.id, thread.objective)
718
+ : worktrees.discard(thread.id);
719
+ if (done.ok)
720
+ store.roomPost({
721
+ goalId: thread.id,
722
+ author: "user",
723
+ kind: "note",
724
+ text:
725
+ command === "board.swarm.merge"
726
+ ? `Merged into ${done.into}.`
727
+ : "Discarded this thread's changes.",
728
+ });
729
+ else if (done.conflict)
730
+ store.roomPost({
731
+ goalId: thread.id,
732
+ author: thread.goal.facilitator,
733
+ kind: "question",
734
+ text: `${done.error[0].toUpperCase()}${done.error.slice(1)}. Resolve it in your checkout, or ask an agent here to bring the thread's branch up to date first.`,
735
+ });
301
736
  notifyBoard(store.getTask(thread.id));
302
- return done.ok ? { ok: true, thread: summary(store.getTask(thread.id)) } : done;
737
+ return done.ok
738
+ ? { ok: true, thread: summary(store.getTask(thread.id)) }
739
+ : done;
303
740
  }
304
741
  // The person's Start for one run in the thread that waits for it.
305
742
  if (command === "board.swarm.start_run") {
306
- const run = store.threadRuns(thread.id).find((candidate) => candidate.id === String(request.runId || ""));
307
- if (!run || !awaitingStart(run)) return { ok: false, error: "that run is not waiting for a Start" };
743
+ const run = store
744
+ .threadRuns(thread.id)
745
+ .find((candidate) => candidate.id === String(request.runId || ""));
746
+ if (!run || !awaitingStart(run))
747
+ return { ok: false, error: "that run is not waiting for a Start" };
308
748
  const started = authorizeRun(run.id);
309
- return started?.ok === false ? started : { ok: true, thread: summary(store.getTask(thread.id)) };
749
+ return started?.ok === false
750
+ ? started
751
+ : { ok: true, thread: summary(store.getTask(thread.id)) };
310
752
  }
311
753
  if (command === "board.swarm.post") {
312
- const text = String(request.text || "").trim().slice(0, 20_000);
754
+ const text = String(request.text || "")
755
+ .trim()
756
+ .slice(0, 20_000);
313
757
  if (!text) return { ok: false, error: "a message needs text" };
314
758
  const to = recipient(request);
315
- if (to === false) return { ok: false, error: `${request.to} can't be written to in a thread` };
759
+ if (to === false)
760
+ return {
761
+ ok: false,
762
+ error: `${request.to} can't be written to in a thread`,
763
+ };
316
764
  const posted = post(thread, text, to);
317
- return posted.ok ? { ...posted, thread: summary(store.getTask(thread.id)) } : posted;
765
+ return posted.ok
766
+ ? { ...posted, thread: summary(store.getTask(thread.id)) }
767
+ : posted;
318
768
  }
319
769
  if (command === "board.swarm.stop") {
320
- if (thread.status !== "open") return { ok: false, error: `the thread is ${thread.status}` };
770
+ if (thread.status !== "open")
771
+ return { ok: false, error: `the thread is ${thread.status}` };
321
772
  stopThreadRuns(thread.id);
322
773
  return { ok: true, thread: summary(store.getTask(thread.id)) };
323
774
  }
324
775
  if (command === "board.swarm.resume") {
325
- if (thread.status !== "paused") return { ok: false, error: `the thread is ${thread.status}` };
776
+ if (thread.status !== "paused")
777
+ return { ok: false, error: `the thread is ${thread.status}` };
326
778
  store.setGoalState(thread.id, { status: "open" });
327
- store.roomPost({ goalId: thread.id, author: "user", kind: "note", text: "Resumed by you." });
779
+ store.roomPost({
780
+ goalId: thread.id,
781
+ author: "user",
782
+ kind: "note",
783
+ text: "Resumed by you.",
784
+ });
328
785
  notifyBoard(store.getTask(thread.id));
329
786
  return { ok: true, thread: summary(store.getTask(thread.id)) };
330
787
  }
@@ -332,16 +789,38 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
332
789
  // and a T3 thread belong to the folder they ran in.
333
790
  if (command === "board.swarm.folder") {
334
791
  const folder = String(request.folder || "");
335
- if (!isDir(folder)) return { ok: false, error: `${folder} is not a folder on this computer` };
792
+ if (!isDir(folder))
793
+ return {
794
+ ok: false,
795
+ error: `${folder} is not a folder on this computer`,
796
+ };
336
797
  const where = path.resolve(folder);
337
- if (where === thread.goal.workspace) return { ok: true, thread: summary(thread) };
338
- 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" };
798
+ if (where === thread.goal.workspace)
799
+ return { ok: true, thread: summary(thread) };
800
+ if (
801
+ store
802
+ .threadRuns(thread.id)
803
+ .some((run) => ["open", "claimed", "running"].includes(run.status))
804
+ )
805
+ return {
806
+ ok: false,
807
+ error:
808
+ "an agent is still working in this thread; stop it or wait for it first",
809
+ };
339
810
  const moved = worktrees.moved(thread.id);
340
811
  if (!moved.ok) return moved;
341
812
  store.setGoalFolder(thread.id, where);
342
- for (const name of Object.keys(thread.goal.sessions || {})) store.setGoalSession(thread.id, name, null);
343
- for (const name of thread.goal.team) if (store.meta(`t3-thread:${thread.id}:${name}`)) store.meta(`t3-thread:${thread.id}:${name}`, "");
344
- store.roomPost({ goalId: thread.id, author: "user", kind: "note", text: `Now working in ${where}.` });
813
+ for (const name of Object.keys(thread.goal.sessions || {}))
814
+ store.setGoalSession(thread.id, name, null);
815
+ for (const name of thread.goal.team)
816
+ if (store.meta(`t3-thread:${thread.id}:${name}`))
817
+ store.meta(`t3-thread:${thread.id}:${name}`, "");
818
+ store.roomPost({
819
+ goalId: thread.id,
820
+ author: "user",
821
+ kind: "note",
822
+ text: `Now working in ${where}.`,
823
+ });
345
824
  notifyBoard(store.getTask(thread.id));
346
825
  return { ok: true, thread: summary(store.getTask(thread.id)) };
347
826
  }