tryharness 0.7.22 → 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 (64) hide show
  1. package/README.md +48 -8
  2. package/assets/opencode-plugin.js +48 -10
  3. package/assets/pi-extension.ts +106 -26
  4. package/cli.mjs +1281 -289
  5. package/lib/activity.mjs +197 -40
  6. package/lib/adapters.mjs +337 -54
  7. package/lib/agentnode.mjs +175 -35
  8. package/lib/atlas.mjs +604 -0
  9. package/lib/bip39-english.txt +2048 -0
  10. package/lib/cards.mjs +177 -39
  11. package/lib/claudemem.mjs +74 -18
  12. package/lib/commands.mjs +13 -5
  13. package/lib/context.mjs +628 -0
  14. package/lib/control-client.mjs +5 -2
  15. package/lib/coordinator.mjs +281 -56
  16. package/lib/daemon.mjs +5590 -838
  17. package/lib/desktop.mjs +149 -35
  18. package/lib/digest.mjs +81 -0
  19. package/lib/environment.mjs +180 -0
  20. package/lib/files.mjs +246 -13
  21. package/lib/firewall.mjs +288 -0
  22. package/lib/folders.mjs +223 -57
  23. package/lib/frames.mjs +333 -0
  24. package/lib/inference.mjs +663 -0
  25. package/lib/learn.mjs +182 -0
  26. package/lib/live.mjs +286 -61
  27. package/lib/localmodels.mjs +571 -0
  28. package/lib/mcp-stdio.mjs +912 -103
  29. package/lib/media.mjs +228 -0
  30. package/lib/members.json +1 -1
  31. package/lib/members.mjs +35 -9
  32. package/lib/models.mjs +342 -80
  33. package/lib/network.mjs +201 -42
  34. package/lib/notify.mjs +49 -12
  35. package/lib/nudge.mjs +190 -32
  36. package/lib/ocr.mjs +380 -0
  37. package/lib/onboard.mjs +321 -70
  38. package/lib/parsers.mjs +132 -41
  39. package/lib/paths.mjs +33 -10
  40. package/lib/personal.mjs +77 -0
  41. package/lib/phones.mjs +398 -51
  42. package/lib/places.mjs +566 -102
  43. package/lib/provenance.mjs +19 -4
  44. package/lib/receipts.mjs +82 -0
  45. package/lib/redact.mjs +730 -0
  46. package/lib/runner.mjs +347 -55
  47. package/lib/runtime-install.mjs +59 -17
  48. package/lib/screens.mjs +212 -0
  49. package/lib/sealed.mjs +160 -28
  50. package/lib/secrets.mjs +18 -5
  51. package/lib/service.mjs +173 -35
  52. package/lib/snippets.mjs +140 -0
  53. package/lib/speech.mjs +993 -0
  54. package/lib/store.mjs +1678 -301
  55. package/lib/swarm.mjs +598 -117
  56. package/lib/t3.mjs +400 -79
  57. package/lib/trails.mjs +401 -0
  58. package/lib/ui.mjs +95 -23
  59. package/lib/vectors.mjs +5 -2
  60. package/lib/wakes.mjs +284 -68
  61. package/lib/watches.mjs +362 -0
  62. package/lib/worktrees.mjs +186 -38
  63. package/package.json +6 -4
  64. package/lib/embedder.mjs +0 -156
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 }) {
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,33 +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
- 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) })) : [] });
276
+ // `lastWorked`: where its agent last worked, when Harness picked another folder from the message, for the person to take.
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
+ });
126
291
 
127
- const read = (thread) => {
292
+ const read = (thread, hostedCaller = false) => {
128
293
  const runs = store.threadRuns(thread.id).map((run) => ({
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,
130
- sessionId: run.session_id, folder: run.run_workspace, usage: run.usage, model: run.run_model,
131
- 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
+ })),
132
309
  }));
133
- return { ok: true, thread: summary(thread), posts: store.roomPosts(thread.id, 0, 500), runs };
310
+ return {
311
+ ok: true,
312
+ thread: summary(thread),
313
+ posts: store.roomPosts(thread.id, 0, 500),
314
+ runs,
315
+ ...threadFiles(thread, hostedCaller),
316
+ };
134
317
  };
135
318
 
136
319
  // `to`: the one agent the person wrote to (the desktop's agent list), so their words go as written, with no @name.
@@ -138,191 +321,467 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
138
321
  const picked = picksIn(store, text, roomAgents);
139
322
  if (picked.error) return { ok: false, error: picked.error };
140
323
  // A new name in the person's message joins the thread.
141
- const joining = [...new Set([...mentionsIn(text, roomAgents), ...(to ? [to] : [])])].filter((agent) => !thread.goal.team.includes(agent));
142
- 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]);
143
329
  if (thread.status !== "open") {
144
330
  store.setGoalState(thread.id, { status: "open" });
145
- 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
+ });
146
337
  }
147
338
  const current = store.getTask(thread.id);
148
- 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
+ );
149
348
  };
150
349
 
151
350
  return function swarmCommand(request, agent) {
152
351
  const command = String(request.command);
153
352
  if (command === "board.swarm.list") {
154
- if (agent !== "user" && agent !== "harness") return { ok: false, error: "only the person and Harness read the swarm" };
155
- 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
+ };
156
364
  }
157
365
  // The swarm's shared memory, for any of the person's agents: what any agent wrote in any thread, and the coding
158
366
  // sessions on this machine. A caller limited to its own threads (a hosted agent while the machine is sealed) gets only those.
159
367
  if (command === "board.swarm.search") {
160
- const query = String(request.query || "").trim().slice(0, 500);
368
+ const query = String(request.query || "")
369
+ .trim()
370
+ .slice(0, 500);
161
371
  if (!query) return { ok: false, error: "search needs a query" };
162
- const mine = request.teamOnly ? store.goals({ all: true }).filter((thread) => thread.goal.team.includes(agent)).map((thread) => thread.id) : null;
163
- 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
+ };
164
383
  }
165
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
166
385
  // session starts. Open to every caller, so an agent picks a name instead of guessing one.
167
386
  if (command === "board.swarm.agents") {
168
- const agents = swarmCards(store, roster().filter((entry) => entry.id !== agent));
169
- 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
+ };
170
397
  }
171
398
  // Each coding agent's models as it lists them, the person's choice for it and their routing words (models.mjs). Open
172
399
  // to every caller, so an agent names a model that exists: post_task's model, or `@codex:model/effort` in a post.
173
400
  if (command === "board.swarm.models") {
174
401
  const named = request.target ? resolveMember(request.target) : null;
175
- 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}"` };
176
404
  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();
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();
183
430
  }
184
431
  // Any of the person's agents may start a thread and stop one it started. The thread carries the agent's trust, so
185
432
  // nothing in it runs without the person's Start, and one started through Harness gets no coordinator rounds (they
186
433
  // are paid) until the person sets a spend ceiling.
187
434
  if (agent !== "user" && command === "board.swarm.start") {
188
- const text = String(request.text || "").trim().slice(0, 20_000);
435
+ const text = String(request.text || "")
436
+ .trim()
437
+ .slice(0, 20_000);
189
438
  if (!text) return { ok: false, error: "a thread starts with a message" };
190
439
  const folder = request.folder ? String(request.folder) : null;
191
- 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
+ };
192
445
  const team = new Set(mentionsIn(text, roomAgents));
193
446
  const picked = picksIn(store, text, roomAgents);
194
447
  if (picked.error) return { ok: false, error: picked.error };
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(", ")}` };
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);
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 });
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
+ });
198
470
  if (request.hostedCaller) store.meta(`hosted-thread:${thread.id}`, "1");
199
- 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
+ });
200
478
  wakeFor(thread.id, first, agent);
201
479
  notifyBoard(store.getTask(thread.id));
202
- 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
+ };
203
485
  }
204
486
  if (agent !== "user" && command === "board.swarm.stop") {
205
487
  const thread = store.getTask(String(request.id || ""));
206
- if (!thread || thread.kind !== "goal" || thread.origin_agent !== agent) return { ok: false, error: "thread not found" };
207
- 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}` };
208
492
  stopThreadRuns(thread.id);
209
493
  return { ok: true, thread: summary(store.getTask(thread.id)) };
210
494
  }
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" };
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
+ };
212
501
  // Where a thread works, for the composer (folders.mjs): the folders the person works in, any folder on the computer
213
502
  // from a typed path, and where a message would work: the folder chosen (`folder`) or the one Harness suggests, and
214
503
  // 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() };
504
+ if (command === "board.swarm.places")
505
+ return { ok: true, places: folders.known(), home: os.homedir() };
216
506
  if (command === "board.swarm.browse") return folders.browse(request.path);
217
507
  if (command === "board.swarm.place") {
218
508
  const text = String(request.text || "").slice(0, 20_000);
219
509
  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)) : [])])];
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
+ ];
221
521
  if (request.folder) {
222
522
  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` };
523
+ return isDir(folder)
524
+ ? placeOf(path.resolve(folder), team, "you")
525
+ : { ok: false, error: `${folder} is not a folder on this computer` };
224
526
  }
225
527
  if (!needsFolder(team)) return placeOf(null, team, null);
226
- return { pending: folders.suggest(text, team).then(({ folder, by }) => placeOf(folder, team, by)) };
528
+ return {
529
+ pending: folders
530
+ .suggest(text, team)
531
+ .then(({ folder, by, lastWorked }) =>
532
+ placeOf(folder, team, by, lastWorked)
533
+ ),
534
+ };
227
535
  }
228
536
  // The person's own note on one agent's card ("use it for reviews"), told to every other agent; no text clears it.
229
537
  if (command === "board.swarm.note") {
230
538
  const named = resolveMember(request.target);
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}"` };
232
- 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
+ };
233
551
  }
234
552
  // The person's choice of model and effort for one agent: a model it lists, "auto", or "default" for its own settings.
235
553
  if (command === "board.swarm.model") {
236
554
  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}"` };
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
+ };
238
560
  const set = setChoice(store, named.id, request.model, request.effort);
239
561
  return set.ok ? { ok: true, agent: named.id, choice: set.choice } : set;
240
562
  }
241
563
  // 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 };
564
+ if (command === "board.swarm.routing")
565
+ return {
566
+ ok: true,
567
+ routing: setRouting(store, request.text),
568
+ defaultRouting: DEFAULT_ROUTING,
569
+ };
243
570
  // The coordinator's record: what each round saw, chose and cost. The evidence for its limits.
244
- 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
+ };
245
579
  if (command === "board.swarm.coordinator") {
246
- if (request.mode === "harness" || request.mode === "local") coordinator.setMode(request.mode);
580
+ if (request.mode === "harness" || request.mode === "local")
581
+ coordinator.setMode(request.mode);
247
582
  return { ok: true, mode: coordinator.mode() };
248
583
  }
249
584
  if (command === "board.swarm.stop_all") {
250
- 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
+ });
251
592
  return { ok: true, stopped };
252
593
  }
253
594
  if (command === "board.swarm.start") {
254
- const text = String(request.text || "").trim().slice(0, 20_000);
595
+ const text = String(request.text || "")
596
+ .trim()
597
+ .slice(0, 20_000);
255
598
  if (!text) return { ok: false, error: "a thread starts with a message" };
256
- 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;
257
603
  let folder = request.folder ? String(request.folder) : null;
258
- 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
+ };
259
609
  const to = recipient(request);
260
- if (to === false) return { ok: false, error: `${request.to} can't be written to in a thread` };
261
- 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
+ ]);
262
619
  const picked = picksIn(store, text, roomAgents);
263
620
  if (picked.error) return { ok: false, error: picked.error };
264
621
  if (from) {
265
- const agentName = String(from.agent || ""), sessionId = String(from.sessionId || "");
266
- 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
+ };
267
629
  const session = store.read({ agent: agentName, sessionId });
268
630
  if (!session) return { ok: false, error: "session not found" };
269
- const already = store.threadForSession(agentName, nativeSessionId(agentName, sessionId));
631
+ const already = store.threadForSession(
632
+ agentName,
633
+ nativeSessionId(agentName, sessionId)
634
+ );
270
635
  if (already) return post(store.getTask(already), text);
271
- 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
+ };
272
642
  team.add(agentName);
273
643
  folder ??= isDir(session.cwd) ? session.cwd : null;
274
644
  }
275
645
  // Where the work happens: the folder the person chose, else one picked from what they wrote, else where its agent
276
646
  // last worked (folders.mjs); never the home folder or a hidden one.
277
647
  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); }
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
+ }
281
673
  const opened = store.getTask(thread.id);
282
- 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
+ });
283
681
  wakeFor(thread.id, first, "user");
284
682
  notifyBoard(store.getTask(thread.id));
285
683
  return { ok: true, thread: summary(store.getTask(thread.id)) };
286
684
  };
287
- 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
+ };
288
692
  }
289
693
  const thread = store.getTask(String(request.id || ""));
290
- if (!thread || thread.kind !== "goal") return { ok: false, error: "thread not found" };
291
- if (command === "board.swarm.read") return read(thread);
292
- 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
+ );
293
703
  if (command === "board.swarm.merge" || command === "board.swarm.discard") {
294
704
  // Both change the person's repository, so both wait for the person, and never while an agent is changing files.
295
- 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" };
296
- const done = command === "board.swarm.merge" ? worktrees.merge(thread.id, thread.objective) : worktrees.discard(thread.id);
297
- 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." });
298
- 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
+ });
299
736
  notifyBoard(store.getTask(thread.id));
300
- 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;
301
740
  }
302
741
  // The person's Start for one run in the thread that waits for it.
303
742
  if (command === "board.swarm.start_run") {
304
- const run = store.threadRuns(thread.id).find((candidate) => candidate.id === String(request.runId || ""));
305
- 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" };
306
748
  const started = authorizeRun(run.id);
307
- 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)) };
308
752
  }
309
753
  if (command === "board.swarm.post") {
310
- const text = String(request.text || "").trim().slice(0, 20_000);
754
+ const text = String(request.text || "")
755
+ .trim()
756
+ .slice(0, 20_000);
311
757
  if (!text) return { ok: false, error: "a message needs text" };
312
758
  const to = recipient(request);
313
- 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
+ };
314
764
  const posted = post(thread, text, to);
315
- 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;
316
768
  }
317
769
  if (command === "board.swarm.stop") {
318
- 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}` };
319
772
  stopThreadRuns(thread.id);
320
773
  return { ok: true, thread: summary(store.getTask(thread.id)) };
321
774
  }
322
775
  if (command === "board.swarm.resume") {
323
- 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}` };
324
778
  store.setGoalState(thread.id, { status: "open" });
325
- 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
+ });
326
785
  notifyBoard(store.getTask(thread.id));
327
786
  return { ok: true, thread: summary(store.getTask(thread.id)) };
328
787
  }
@@ -330,16 +789,38 @@ export function createSwarm({ store, roomAgents, mentionsIn, wakeFor, notifyBoar
330
789
  // and a T3 thread belong to the folder they ran in.
331
790
  if (command === "board.swarm.folder") {
332
791
  const folder = String(request.folder || "");
333
- 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
+ };
334
797
  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" };
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
+ };
337
810
  const moved = worktrees.moved(thread.id);
338
811
  if (!moved.ok) return moved;
339
812
  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}.` });
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
+ });
343
824
  notifyBoard(store.getTask(thread.id));
344
825
  return { ok: true, thread: summary(store.getTask(thread.id)) };
345
826
  }