@loompad/cli 0.1.0

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 (177) hide show
  1. package/ARCHITECTURE.md +211 -0
  2. package/CHANGELOG.md +470 -0
  3. package/LICENSE +21 -0
  4. package/README.md +630 -0
  5. package/dist/adapters/base.d.ts +71 -0
  6. package/dist/adapters/base.js +187 -0
  7. package/dist/adapters/base.js.map +1 -0
  8. package/dist/adapters/bridges/antigravity.d.ts +53 -0
  9. package/dist/adapters/bridges/antigravity.js +106 -0
  10. package/dist/adapters/bridges/antigravity.js.map +1 -0
  11. package/dist/adapters/bridges/cdp.d.ts +82 -0
  12. package/dist/adapters/bridges/cdp.js +222 -0
  13. package/dist/adapters/bridges/cdp.js.map +1 -0
  14. package/dist/adapters/bridges/gui-chat.d.ts +113 -0
  15. package/dist/adapters/bridges/gui-chat.js +498 -0
  16. package/dist/adapters/bridges/gui-chat.js.map +1 -0
  17. package/dist/adapters/bridges/kiro.d.ts +19 -0
  18. package/dist/adapters/bridges/kiro.js +22 -0
  19. package/dist/adapters/bridges/kiro.js.map +1 -0
  20. package/dist/adapters/bridges/profiles.d.ts +82 -0
  21. package/dist/adapters/bridges/profiles.js +125 -0
  22. package/dist/adapters/bridges/profiles.js.map +1 -0
  23. package/dist/adapters/claude-code.d.ts +26 -0
  24. package/dist/adapters/claude-code.js +209 -0
  25. package/dist/adapters/claude-code.js.map +1 -0
  26. package/dist/adapters/codex.d.ts +51 -0
  27. package/dist/adapters/codex.js +291 -0
  28. package/dist/adapters/codex.js.map +1 -0
  29. package/dist/adapters/echo.d.ts +20 -0
  30. package/dist/adapters/echo.js +79 -0
  31. package/dist/adapters/echo.js.map +1 -0
  32. package/dist/adapters/grok.d.ts +61 -0
  33. package/dist/adapters/grok.js +217 -0
  34. package/dist/adapters/grok.js.map +1 -0
  35. package/dist/adapters/index.d.ts +9 -0
  36. package/dist/adapters/index.js +33 -0
  37. package/dist/adapters/index.js.map +1 -0
  38. package/dist/adapters/opencode.d.ts +64 -0
  39. package/dist/adapters/opencode.js +426 -0
  40. package/dist/adapters/opencode.js.map +1 -0
  41. package/dist/cli/common.d.ts +11 -0
  42. package/dist/cli/common.js +40 -0
  43. package/dist/cli/common.js.map +1 -0
  44. package/dist/cli/doctor.d.ts +12 -0
  45. package/dist/cli/doctor.js +181 -0
  46. package/dist/cli/doctor.js.map +1 -0
  47. package/dist/cli/index.d.ts +8 -0
  48. package/dist/cli/index.js +641 -0
  49. package/dist/cli/index.js.map +1 -0
  50. package/dist/cli/tui-model.d.ts +73 -0
  51. package/dist/cli/tui-model.js +312 -0
  52. package/dist/cli/tui-model.js.map +1 -0
  53. package/dist/cli/tui.d.ts +15 -0
  54. package/dist/cli/tui.js +451 -0
  55. package/dist/cli/tui.js.map +1 -0
  56. package/dist/cli/ui.d.ts +10 -0
  57. package/dist/cli/ui.js +135 -0
  58. package/dist/cli/ui.js.map +1 -0
  59. package/dist/core/ades.d.ts +79 -0
  60. package/dist/core/ades.js +130 -0
  61. package/dist/core/ades.js.map +1 -0
  62. package/dist/core/baton.d.ts +37 -0
  63. package/dist/core/baton.js +92 -0
  64. package/dist/core/baton.js.map +1 -0
  65. package/dist/core/brain-extract.d.ts +111 -0
  66. package/dist/core/brain-extract.js +248 -0
  67. package/dist/core/brain-extract.js.map +1 -0
  68. package/dist/core/brain-index.d.ts +128 -0
  69. package/dist/core/brain-index.js +338 -0
  70. package/dist/core/brain-index.js.map +1 -0
  71. package/dist/core/brain.d.ts +214 -0
  72. package/dist/core/brain.js +425 -0
  73. package/dist/core/brain.js.map +1 -0
  74. package/dist/core/claude-cli.d.ts +12 -0
  75. package/dist/core/claude-cli.js +47 -0
  76. package/dist/core/claude-cli.js.map +1 -0
  77. package/dist/core/distill.d.ts +19 -0
  78. package/dist/core/distill.js +88 -0
  79. package/dist/core/distill.js.map +1 -0
  80. package/dist/core/eventlog.d.ts +43 -0
  81. package/dist/core/eventlog.js +211 -0
  82. package/dist/core/eventlog.js.map +1 -0
  83. package/dist/core/git.d.ts +203 -0
  84. package/dist/core/git.js +460 -0
  85. package/dist/core/git.js.map +1 -0
  86. package/dist/core/logbook.d.ts +59 -0
  87. package/dist/core/logbook.js +118 -0
  88. package/dist/core/logbook.js.map +1 -0
  89. package/dist/core/memory.d.ts +34 -0
  90. package/dist/core/memory.js +124 -0
  91. package/dist/core/memory.js.map +1 -0
  92. package/dist/core/notify.d.ts +11 -0
  93. package/dist/core/notify.js +30 -0
  94. package/dist/core/notify.js.map +1 -0
  95. package/dist/core/projection.d.ts +20 -0
  96. package/dist/core/projection.js +96 -0
  97. package/dist/core/projection.js.map +1 -0
  98. package/dist/core/registry.d.ts +76 -0
  99. package/dist/core/registry.js +147 -0
  100. package/dist/core/registry.js.map +1 -0
  101. package/dist/core/router.d.ts +42 -0
  102. package/dist/core/router.js +99 -0
  103. package/dist/core/router.js.map +1 -0
  104. package/dist/core/routes.d.ts +93 -0
  105. package/dist/core/routes.js +399 -0
  106. package/dist/core/routes.js.map +1 -0
  107. package/dist/core/search.d.ts +63 -0
  108. package/dist/core/search.js +0 -0
  109. package/dist/core/search.js.map +1 -0
  110. package/dist/core/setup.d.ts +70 -0
  111. package/dist/core/setup.js +250 -0
  112. package/dist/core/setup.js.map +1 -0
  113. package/dist/core/suggestions.d.ts +15 -0
  114. package/dist/core/suggestions.js +38 -0
  115. package/dist/core/suggestions.js.map +1 -0
  116. package/dist/core/worktree.d.ts +32 -0
  117. package/dist/core/worktree.js +83 -0
  118. package/dist/core/worktree.js.map +1 -0
  119. package/dist/daemon/app-page.d.ts +32 -0
  120. package/dist/daemon/app-page.js +6363 -0
  121. package/dist/daemon/app-page.js.map +1 -0
  122. package/dist/daemon/auth.d.ts +51 -0
  123. package/dist/daemon/auth.js +135 -0
  124. package/dist/daemon/auth.js.map +1 -0
  125. package/dist/daemon/board.d.ts +117 -0
  126. package/dist/daemon/board.js +202 -0
  127. package/dist/daemon/board.js.map +1 -0
  128. package/dist/daemon/brand-icons.d.ts +29 -0
  129. package/dist/daemon/brand-icons.js +44 -0
  130. package/dist/daemon/brand-icons.js.map +1 -0
  131. package/dist/daemon/client.d.ts +127 -0
  132. package/dist/daemon/client.js +237 -0
  133. package/dist/daemon/client.js.map +1 -0
  134. package/dist/daemon/geist-font.d.ts +10 -0
  135. package/dist/daemon/geist-font.js +783 -0
  136. package/dist/daemon/geist-font.js.map +1 -0
  137. package/dist/daemon/guards.d.ts +18 -0
  138. package/dist/daemon/guards.js +40 -0
  139. package/dist/daemon/guards.js.map +1 -0
  140. package/dist/daemon/linear.d.ts +59 -0
  141. package/dist/daemon/linear.js +117 -0
  142. package/dist/daemon/linear.js.map +1 -0
  143. package/dist/daemon/push.d.ts +22 -0
  144. package/dist/daemon/push.js +58 -0
  145. package/dist/daemon/push.js.map +1 -0
  146. package/dist/daemon/runtime.d.ts +299 -0
  147. package/dist/daemon/runtime.js +1016 -0
  148. package/dist/daemon/runtime.js.map +1 -0
  149. package/dist/daemon/server.d.ts +125 -0
  150. package/dist/daemon/server.js +1728 -0
  151. package/dist/daemon/server.js.map +1 -0
  152. package/dist/daemon/tasks.d.ts +153 -0
  153. package/dist/daemon/tasks.js +264 -0
  154. package/dist/daemon/tasks.js.map +1 -0
  155. package/dist/daemon/terminals.d.ts +82 -0
  156. package/dist/daemon/terminals.js +372 -0
  157. package/dist/daemon/terminals.js.map +1 -0
  158. package/dist/index.d.ts +20 -0
  159. package/dist/index.js +20 -0
  160. package/dist/index.js.map +1 -0
  161. package/dist/sdk.d.ts +17 -0
  162. package/dist/sdk.js +17 -0
  163. package/dist/sdk.js.map +1 -0
  164. package/dist/types.d.ts +236 -0
  165. package/dist/types.js +18 -0
  166. package/dist/types.js.map +1 -0
  167. package/docs/SETUP.md +265 -0
  168. package/docs/adapters.md +132 -0
  169. package/docs/design-system.md +245 -0
  170. package/docs/img/board-linear.png +0 -0
  171. package/docs/img/board-projects.png +0 -0
  172. package/docs/img/brain.png +0 -0
  173. package/docs/img/settings.png +0 -0
  174. package/docs/img/workspace.png +0 -0
  175. package/docs/integration-notes.md +76 -0
  176. package/docs/proposals/brain.md +262 -0
  177. package/package.json +84 -0
@@ -0,0 +1,1016 @@
1
+ /**
2
+ * ProjectRuntime — one live project inside the daemon: its event log, its
3
+ * agents, and its baton. All mutations flow through here so the log stays
4
+ * the single source of truth.
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { isAdapter, MAIN_CHAT } from "../types.js";
9
+ import { createAgent, knownAgentKinds } from "../adapters/index.js";
10
+ import { BatonManager, NotHolderError } from "../core/baton.js";
11
+ import { Brain, CONFIDENCE_FLOOR } from "../core/brain.js";
12
+ import { compileBrief, retrieve } from "../core/brain-index.js";
13
+ import { extractFromTurn } from "../core/brain-extract.js";
14
+ import { claudeText } from "../core/claude-cli.js";
15
+ import { EventLog } from "../core/eventlog.js";
16
+ import { renderProjection } from "../core/distill.js";
17
+ import { buildUnifiedMemory, hashContent, readNativeMemory, } from "../core/memory.js";
18
+ import { notify } from "../core/notify.js";
19
+ import { RouteEngine } from "../core/routes.js";
20
+ import { buildBriefing, buildProjection } from "../core/projection.js";
21
+ import { newId, projectLoomDir, readProjectConfig, readProjectState, writeProjectConfig, writeProjectState, writeMemoryFile, } from "../core/registry.js";
22
+ import { suggestHandoff } from "../core/suggestions.js";
23
+ import { diffSinceSnapshot, porcelainStatus, workingTree, } from "../core/worktree.js";
24
+ const PROJECTION_WINDOW = 400; // recent events distilled on handoff
25
+ export class ProjectRuntime {
26
+ info;
27
+ config;
28
+ log;
29
+ baton;
30
+ routes;
31
+ /** Memory as units — see core/brain.ts. Reads and writes through `log`. */
32
+ brain;
33
+ agents = new Map();
34
+ startedAgents = new Set();
35
+ configMtime = 0;
36
+ /**
37
+ * Which conversation each agent's current turn belongs to. Set when a turn
38
+ * starts and left in place afterwards — an agent's trailing events (a late
39
+ * run_complete, a diff) still belong to the chat that prompted them.
40
+ */
41
+ turnChat = new Map();
42
+ constructor(info, config, log) {
43
+ this.info = info;
44
+ this.config = config;
45
+ this.log = log;
46
+ this.baton = new BatonManager(info.dir, log);
47
+ this.brain = new Brain(log);
48
+ // Same path as addAgent: an agent added at runtime must behave exactly like
49
+ // one that was here at open, and two copies of this loop would drift.
50
+ // (An agent streams events long after send() returns and has no idea which
51
+ // conversation prompted it — spawnAgent tags them with the chat that started
52
+ // the turn, so a reply lands where the question was asked.)
53
+ for (const agentCfg of config.agents)
54
+ this.spawnAgent(agentCfg);
55
+ this.routes = new RouteEngine({
56
+ projectName: info.name,
57
+ projectDir: info.dir,
58
+ config,
59
+ log,
60
+ handoff: (to) => this.handoff(to, { source: "route" }),
61
+ send: (text, agentId) => this.sendMessage(text, agentId, { source: "route" }),
62
+ interrupt: () => this.interrupt({ source: "route" }),
63
+ costTotal: () => this.costs.totalUsd,
64
+ isAdapterId: (id) => {
65
+ const agent = this.agents.get(id);
66
+ return Boolean(agent && isAdapter(agent));
67
+ },
68
+ });
69
+ }
70
+ static async open(info) {
71
+ const config = readProjectConfig(info.dir);
72
+ if (!config)
73
+ throw new Error(`project at ${info.dir} has no .loom/config.json — run loom init`);
74
+ const log = await EventLog.open(projectLoomDir(info.dir));
75
+ const rt = new ProjectRuntime(info, config, log);
76
+ rt.configMtime = configMtimeOf(info.dir);
77
+ rt.rehydrateCosts();
78
+ // Pull each connected ADE's native memory into the shared brain on open.
79
+ try {
80
+ rt.importMemories();
81
+ }
82
+ catch {
83
+ // Memory import is best-effort; never block opening a project.
84
+ }
85
+ return rt;
86
+ }
87
+ // -------------------------------------------------------------------------
88
+ // Cost telemetry — O(1) incremental, rehydrated from the log on open
89
+ // -------------------------------------------------------------------------
90
+ costs = { totalUsd: 0, turns: 0, totalMs: 0 };
91
+ costsByAgent = new Map();
92
+ rehydrateCosts() {
93
+ for (const event of this.log.list({ kinds: ["status", "run_complete"] })) {
94
+ this.trackCost(event);
95
+ }
96
+ }
97
+ trackCost(event) {
98
+ const agentId = event.agentId ?? "unknown";
99
+ const entry = this.costsByAgent.get(agentId) ?? { usd: 0, turns: 0, ms: 0 };
100
+ if (event.kind === "status" && event.payload.state === "turn_cost") {
101
+ const usd = Number(event.payload.costUsd ?? 0);
102
+ if (usd > 0) {
103
+ this.costs.totalUsd += usd;
104
+ entry.usd += usd;
105
+ this.costsByAgent.set(agentId, entry);
106
+ }
107
+ }
108
+ else if (event.kind === "run_complete") {
109
+ const ms = Number(event.payload.durationMs ?? 0);
110
+ this.costs.turns += 1;
111
+ this.costs.totalMs += ms;
112
+ entry.turns += 1;
113
+ entry.ms += ms;
114
+ this.costsByAgent.set(agentId, entry);
115
+ }
116
+ }
117
+ costSummary() {
118
+ const byAgent = [...this.costsByAgent.entries()]
119
+ .map(([agentId, c]) => ({ agentId, ...c }))
120
+ .sort((a, b) => b.usd - a.usd || b.turns - a.turns);
121
+ return {
122
+ totalUsd: this.costs.totalUsd,
123
+ turns: this.costs.turns,
124
+ totalMs: this.costs.totalMs,
125
+ byAgent,
126
+ };
127
+ }
128
+ /** Has .loom/config.json changed since this runtime was opened? */
129
+ configStale() {
130
+ return configMtimeOf(this.info.dir) > this.configMtime;
131
+ }
132
+ /**
133
+ * Rename an agent's job. Writes .loom/config.json (the source of truth) and
134
+ * updates this runtime in place — the generic hot-reload would do it too, but
135
+ * only once the project is quiet, and a label you just typed shouldn't wait
136
+ * on an agent's turn to finish. Nothing is torn down: a role is a name, not
137
+ * a capability, so no adapter needs restarting.
138
+ */
139
+ setAgentRole(agentId, role) {
140
+ const cfg = this.config.agents.find((a) => a.id === agentId);
141
+ if (!cfg)
142
+ return null;
143
+ cfg.role = role;
144
+ this.saveConfig();
145
+ return { id: agentId, role };
146
+ }
147
+ /**
148
+ * Edit the project's settings the Settings screen owns — the brain extractor,
149
+ * the projection mode, the default agent. These were config-file-only until
150
+ * now; everything is read live from this.config (brain?.extractor at turn end,
151
+ * projection at handoff), so a merge here takes effect on the next turn/hop
152
+ * with no restart. Only the known keys are honoured; unknown ones are ignored.
153
+ */
154
+ patchConfig(patch) {
155
+ // Validate everything that can be rejected BEFORE touching this.config, so a
156
+ // bad field can't leave a half-applied change in memory that the next save
157
+ // would then persist.
158
+ const wantsDefault = typeof patch.defaultAgent === "string";
159
+ const defaultId = wantsDefault ? patch.defaultAgent.trim() : "";
160
+ if (wantsDefault && defaultId && !this.config.agents.some((a) => a.id === defaultId)) {
161
+ throw new Error(`no agent "${defaultId}" in this project`);
162
+ }
163
+ if (patch.brain) {
164
+ const b = { ...(this.config.brain ?? {}) };
165
+ if (patch.brain.extractor === "auto" || patch.brain.extractor === "off")
166
+ b.extractor = patch.brain.extractor;
167
+ if (typeof patch.brain.model === "string")
168
+ b.model = patch.brain.model.trim() || undefined;
169
+ this.config.brain = b;
170
+ }
171
+ if (patch.projection) {
172
+ const pr = { ...(this.config.projection ?? {}) };
173
+ if (patch.projection.mode === "template" || patch.projection.mode === "llm")
174
+ pr.mode = patch.projection.mode;
175
+ if (typeof patch.projection.model === "string")
176
+ pr.model = patch.projection.model.trim() || undefined;
177
+ this.config.projection = pr;
178
+ }
179
+ if (wantsDefault) {
180
+ // empty clears it; a real value was checked against the roster above
181
+ if (!defaultId)
182
+ delete this.config.defaultAgent;
183
+ else
184
+ this.config.defaultAgent = defaultId;
185
+ }
186
+ this.saveConfig();
187
+ return this.config;
188
+ }
189
+ /**
190
+ * The slice of config the Settings screen edits, read back for display: the
191
+ * brain extractor, the projection mode, the default agent, and the roster the
192
+ * default-agent picker chooses from. Defaults are spelled out here (extractor
193
+ * "auto", projection "template") so the screen shows the effective value, not
194
+ * a blank that hides what's actually running.
195
+ */
196
+ settings() {
197
+ return {
198
+ brain: {
199
+ extractor: this.config.brain?.extractor === "off" ? "off" : "auto",
200
+ model: this.config.brain?.model ?? "",
201
+ },
202
+ projection: {
203
+ mode: this.config.projection?.mode === "llm" ? "llm" : "template",
204
+ model: this.config.projection?.model ?? "",
205
+ },
206
+ defaultAgent: this.config.defaultAgent ?? "",
207
+ agents: this.config.agents.map((a) => ({ id: a.id, kind: a.kind, role: a.role })),
208
+ };
209
+ }
210
+ /**
211
+ * Put an agent in this project.
212
+ *
213
+ * Until this existed a project's roster was whatever was detected the moment
214
+ * it was created, forever. Install a new ADE and your existing projects never
215
+ * heard about it — which is why a machine with six agents had boards offering
216
+ * two, and looked like a bug in the board.
217
+ *
218
+ * The role defaults to the kind, which is a description rather than an
219
+ * opinion: Loom has no basis for deciding Codex is "the reviewer".
220
+ */
221
+ addAgent(kind, opts = {}) {
222
+ if (!knownAgentKinds().includes(kind)) {
223
+ throw new Error(`unknown agent kind "${kind}" (known: ${knownAgentKinds().join(", ")})`);
224
+ }
225
+ const id = (opts.id ?? kind).trim().slice(0, 40);
226
+ if (!id)
227
+ throw new Error("an agent needs an id");
228
+ if (this.config.agents.some((a) => a.id === id)) {
229
+ throw new Error(`"${id}" is already in this project`);
230
+ }
231
+ const cfg = { id, kind, role: (opts.role ?? kind).trim().slice(0, 40) || kind };
232
+ // Build it before saving. A config entry with no live agent behind it makes
233
+ // status() throw the moment anything asks — this.agent(id) doesn't find it —
234
+ // so the project 500s on every poll and the roster you just changed becomes
235
+ // unreachable. Writing the file is the easy half; the runtime has to learn
236
+ // too, and it can't wait for a restart to do it.
237
+ this.spawnAgent(cfg);
238
+ this.config.agents.push(cfg);
239
+ this.saveConfig();
240
+ return cfg;
241
+ }
242
+ /**
243
+ * Create one agent and subscribe to it, exactly as the constructor does.
244
+ *
245
+ * Shared so a roster change can't drift from a cold start: an agent added at
246
+ * runtime must stream its events into the log the same way as one that was
247
+ * there when the project opened.
248
+ */
249
+ spawnAgent(cfg) {
250
+ const agent = createAgent(cfg, this.info.dir);
251
+ this.agents.set(cfg.id, agent);
252
+ agent.onEvent((e) => {
253
+ const chat = this.turnChat.get(agent.id);
254
+ const event = this.log.append({
255
+ kind: e.kind,
256
+ agentId: agent.id,
257
+ ...(chat ? { chat } : {}),
258
+ payload: e.payload,
259
+ });
260
+ this.afterAgentEvent(event);
261
+ });
262
+ return agent;
263
+ }
264
+ /**
265
+ * Take an agent out.
266
+ *
267
+ * Refused while it holds the baton or is mid-turn: removing it there would
268
+ * strand the lock on an agent that no longer exists, and the thread would
269
+ * show a turn that nothing is running.
270
+ */
271
+ removeAgent(agentId) {
272
+ const cfg = this.config.agents.find((a) => a.id === agentId);
273
+ if (!cfg)
274
+ throw new Error(`unknown agent "${agentId}"`);
275
+ const holder = this.validHolder();
276
+ if (holder === agentId) {
277
+ throw new Error(`"${agentId}" holds the baton — hand it to someone else first`);
278
+ }
279
+ const live = this.agents.get(agentId);
280
+ if (live && isAdapter(live) && live.busy()) {
281
+ throw new Error(`"${agentId}" is mid-turn — interrupt it first`);
282
+ }
283
+ // Its events stay in the log: the history happened, and a roster change
284
+ // doesn't unhappen it. Only the roster forgets.
285
+ this.config.agents = this.config.agents.filter((a) => a.id !== agentId);
286
+ this.saveConfig();
287
+ if (live) {
288
+ void Promise.resolve(live.stop()).catch(() => { });
289
+ this.agents.delete(agentId);
290
+ }
291
+ return { removed: agentId };
292
+ }
293
+ /**
294
+ * Point an agent at a different model.
295
+ *
296
+ * The model is read once, when the adapter is constructed (createAgent hands
297
+ * it cfg.options), so changing it means building a fresh agent — which drops
298
+ * the CLI session the old one was resuming. That's the right behaviour for a
299
+ * model switch: continuing one model's conversation on another model is not a
300
+ * thing the underlying CLIs support anyway. Refused mid-turn, because swapping
301
+ * the process out from under a running turn would strand it.
302
+ *
303
+ * An empty model clears the override, so the CLI falls back to its own default
304
+ * — the honest "Default" the picker offers.
305
+ */
306
+ setAgentModel(agentId, model) {
307
+ const cfg = this.config.agents.find((a) => a.id === agentId);
308
+ if (!cfg)
309
+ throw new Error(`unknown agent "${agentId}"`);
310
+ const live = this.agents.get(agentId);
311
+ if (live && isAdapter(live) && live.busy()) {
312
+ throw new Error(`"${agentId}" is mid-turn — wait for it to finish, then switch models`);
313
+ }
314
+ const next = model.trim().slice(0, 80);
315
+ const options = { ...(cfg.options ?? {}) };
316
+ if (next)
317
+ options.model = next;
318
+ else
319
+ delete options.model;
320
+ cfg.options = options;
321
+ // Rebuild so the new model actually takes: stop the old process, spawn a
322
+ // replacement subscribed exactly as the constructor's loop does.
323
+ if (live) {
324
+ void Promise.resolve(live.stop()).catch(() => { });
325
+ this.agents.delete(agentId);
326
+ }
327
+ this.spawnAgent(cfg);
328
+ this.saveConfig();
329
+ return cfg;
330
+ }
331
+ /** Write the roster, without tripping our own staleness check. */
332
+ saveConfig() {
333
+ writeProjectConfig(this.info.dir, this.config);
334
+ // we just wrote the file, so don't let configStale() see our own write and
335
+ // schedule a pointless reload
336
+ this.configMtime = configMtimeOf(this.info.dir);
337
+ }
338
+ // -------------------------------------------------------------------------
339
+ // Chats — several conversations, one brain
340
+ // -------------------------------------------------------------------------
341
+ /**
342
+ * Every conversation in this project, main first. Main is implicit: it's
343
+ * always there and it owns every event written before chats existed, so it
344
+ * is never stored. The rest live in state.json — a chat you created and
345
+ * haven't spoken in yet has no events to derive it from.
346
+ */
347
+ chats() {
348
+ const stored = readProjectState(this.info.dir).chats ?? [];
349
+ return [
350
+ { id: MAIN_CHAT, title: "Main", createdAt: 0 },
351
+ ...stored.filter((c) => c.id !== MAIN_CHAT),
352
+ ];
353
+ }
354
+ createChat(title) {
355
+ const state = readProjectState(this.info.dir);
356
+ const chat = {
357
+ id: newId(4),
358
+ // numbered, not "New chat" — the button already says New chat, and a
359
+ // sidebar of identical rows tells you nothing
360
+ title: title.trim().slice(0, 60) || `Chat ${(state.chats ?? []).length + 2}`,
361
+ createdAt: Date.now(),
362
+ };
363
+ state.chats = [...(state.chats ?? []), chat];
364
+ writeProjectState(this.info.dir, state);
365
+ return chat;
366
+ }
367
+ renameChat(id, title) {
368
+ if (id === MAIN_CHAT)
369
+ return null; // main's name is not yours to change
370
+ const state = readProjectState(this.info.dir);
371
+ const chat = (state.chats ?? []).find((c) => c.id === id);
372
+ if (!chat)
373
+ return null;
374
+ chat.title = title.trim().slice(0, 60) || chat.title;
375
+ writeProjectState(this.info.dir, state);
376
+ return chat;
377
+ }
378
+ /**
379
+ * Forget a conversation. Its events stay in the log — it's append-only, and
380
+ * the brain is built from all of them; deleting the thread you had with an
381
+ * agent shouldn't quietly rewrite what the project decided. The chat just
382
+ * stops being listed.
383
+ */
384
+ deleteChat(id) {
385
+ if (id === MAIN_CHAT)
386
+ return false; // there is always a main chat
387
+ const state = readProjectState(this.info.dir);
388
+ const before = (state.chats ?? []).length;
389
+ state.chats = (state.chats ?? []).filter((c) => c.id !== id);
390
+ if (state.chats.length === before)
391
+ return false;
392
+ writeProjectState(this.info.dir, state);
393
+ return true;
394
+ }
395
+ // -------------------------------------------------------------------------
396
+ // Board tasks — the cards you write yourself
397
+ // -------------------------------------------------------------------------
398
+ boardTasks() {
399
+ return readProjectState(this.info.dir).tasks ?? [];
400
+ }
401
+ createTask(input) {
402
+ const state = readProjectState(this.info.dir);
403
+ const task = {
404
+ id: newId(4),
405
+ title: input.title.trim().slice(0, 200),
406
+ column: input.column ?? "working",
407
+ ...(input.agent ? { agent: input.agent } : {}),
408
+ createdAt: Date.now(),
409
+ };
410
+ state.tasks = [...(state.tasks ?? []), task];
411
+ writeProjectState(this.info.dir, state);
412
+ return task;
413
+ }
414
+ /** Move or retitle a card. Yours, so this is the real state — not a hint. */
415
+ updateTask(id, patch) {
416
+ const state = readProjectState(this.info.dir);
417
+ const task = (state.tasks ?? []).find((t) => t.id === id);
418
+ if (!task)
419
+ return null;
420
+ if (patch.title !== undefined)
421
+ task.title = patch.title.trim().slice(0, 200) || task.title;
422
+ if (patch.column !== undefined)
423
+ task.column = patch.column;
424
+ if (patch.agent !== undefined)
425
+ task.agent = patch.agent;
426
+ writeProjectState(this.info.dir, state);
427
+ return task;
428
+ }
429
+ deleteTask(id) {
430
+ const state = readProjectState(this.info.dir);
431
+ const before = (state.tasks ?? []).length;
432
+ state.tasks = (state.tasks ?? []).filter((t) => t.id !== id);
433
+ if (state.tasks.length === before)
434
+ return false;
435
+ writeProjectState(this.info.dir, state);
436
+ return true;
437
+ }
438
+ /** Any adapter mid-turn? (Hot reloads are deferred while work is in flight.) */
439
+ anyBusy() {
440
+ return [...this.agents.values()].some((a) => isAdapter(a) && a.busy());
441
+ }
442
+ agent(id) {
443
+ const agent = this.agents.get(id);
444
+ if (!agent)
445
+ throw new Error(`unknown agent "${id}" in project "${this.info.name}"`);
446
+ return agent;
447
+ }
448
+ async ensureStarted(agentId) {
449
+ const agent = this.agent(agentId);
450
+ if (!this.startedAgents.has(agentId)) {
451
+ await agent.start();
452
+ this.startedAgents.add(agentId);
453
+ }
454
+ return agent;
455
+ }
456
+ /** Pre-turn porcelain snapshots, for per-prompt diff attribution. */
457
+ preTurnTree = new Map();
458
+ /** After a turn: log which files that prompt changed (turn_diff), then learn. */
459
+ captureTurnDiff(agentId) {
460
+ const before = this.preTurnTree.get(agentId);
461
+ if (before === undefined) {
462
+ // No snapshot (e.g. a turn with no pre-tree) — still worth reading.
463
+ this.extractMemory(agentId, []);
464
+ return;
465
+ }
466
+ this.preTurnTree.delete(agentId);
467
+ void diffSinceSnapshot(this.info.dir, before)
468
+ .then((diff) => {
469
+ if (diff) {
470
+ this.log.append({
471
+ kind: "turn_diff",
472
+ agentId,
473
+ payload: {
474
+ files: diff.files,
475
+ added: diff.added,
476
+ removed: diff.removed,
477
+ patch: diff.patch,
478
+ truncated: diff.truncated,
479
+ },
480
+ });
481
+ }
482
+ // Learn from the turn once we know which files it touched — the files
483
+ // sharpen candidate retrieval. Runs after the diff so recentTurnFiles
484
+ // isn't needed; the files are right here.
485
+ this.extractMemory(agentId, (diff?.files ?? []).map((f) => f.path));
486
+ })
487
+ .catch(() => this.extractMemory(agentId, []));
488
+ }
489
+ /**
490
+ * Phase 2: read a finished turn for durable memory.
491
+ *
492
+ * Fire-and-forget on purpose. A slow or missing extractor must never delay
493
+ * anything — extractFromTurn already swallows engine failures, and this is
494
+ * void-ed so even an unexpected throw can't escape into the event pipeline.
495
+ * Off entirely when config says so; a no-op when Claude isn't available.
496
+ */
497
+ extractMemory(agentId, files) {
498
+ if (this.config.brain?.extractor === "off")
499
+ return;
500
+ const chat = this.turnChat.get(agentId) ?? MAIN_CHAT;
501
+ const turn = this.gatherTurnText(chat);
502
+ if (turn.length < 40)
503
+ return; // nothing substantial to learn from
504
+ const model = this.config.brain?.model ?? "haiku";
505
+ const engine = (p) => claudeText(`${p.system}\n\n${p.user}`, { model, timeoutMs: 60_000 });
506
+ void extractFromTurn(this.brain, turn, {
507
+ engine,
508
+ agentId,
509
+ chat,
510
+ ...(files.length ? { files } : {}),
511
+ eventId: this.log.lastId(),
512
+ })
513
+ .then((res) => {
514
+ const learned = res.added.length + res.updated.length + res.forgotten.length;
515
+ if (learned > 0) {
516
+ this.log.append({
517
+ kind: "status",
518
+ payload: {
519
+ state: "brain_extract",
520
+ agentId,
521
+ added: res.added.length,
522
+ updated: res.updated.length,
523
+ forgotten: res.forgotten.length,
524
+ },
525
+ });
526
+ }
527
+ })
528
+ .catch(() => { });
529
+ }
530
+ /**
531
+ * The transcript of the most recent turn in a chat: from the last human
532
+ * message to now — the user's ask and what the agent did in reply.
533
+ */
534
+ gatherTurnText(chat) {
535
+ const events = this.log.list({ chat, limit: 40 });
536
+ let start = 0;
537
+ for (let i = events.length - 1; i >= 0; i--) {
538
+ if (events[i].kind === "message" && !events[i].agentId) {
539
+ start = i;
540
+ break;
541
+ }
542
+ }
543
+ const lines = [];
544
+ for (const e of events.slice(start)) {
545
+ const p = e.payload;
546
+ if (e.kind === "message") {
547
+ lines.push(`${e.agentId ?? "user"}: ${String(p.text ?? "").slice(0, 2000)}`);
548
+ }
549
+ else if (e.kind === "tool_call") {
550
+ lines.push(`[${e.agentId} used ${String(p.tool ?? "a tool")}] ${String(p.summary ?? "")}`.trim());
551
+ }
552
+ else if (e.kind === "file_edit") {
553
+ lines.push(`[${e.agentId} edited ${String(p.path ?? "")}]`);
554
+ }
555
+ else if (e.kind === "decision") {
556
+ lines.push(`decision: ${String(p.text ?? "")}`);
557
+ }
558
+ }
559
+ return lines.join("\n").trim();
560
+ }
561
+ workingTree() {
562
+ return workingTree(this.info.dir);
563
+ }
564
+ // -------------------------------------------------------------------------
565
+ // Unified memory — "multiple memory in one"
566
+ // -------------------------------------------------------------------------
567
+ /** Freshly read every connected ADE's native memory from disk. */
568
+ importedMemory() {
569
+ return readNativeMemory(this.info.dir, this.config);
570
+ }
571
+ /** The merged brain: decisions + imported ADE memories + shared context. */
572
+ unifiedMemory() {
573
+ return buildUnifiedMemory(this.info.name, this.log.list(), this.importedMemory());
574
+ }
575
+ /**
576
+ * Phase 3: the brain brief for a handoff — the memories relevant to the work
577
+ * in flight, compiled. Query is the recent conversation plus the files recent
578
+ * turns touched; scoped to the incoming agent; low-confidence memories are
579
+ * held back from injection (they stay visible in the Brain tab). Empty string
580
+ * when there's nothing relevant, so callers append it unconditionally.
581
+ */
582
+ retrieveBrief(events, agentId) {
583
+ const query = events
584
+ .filter((e) => e.kind === "message")
585
+ .slice(-8)
586
+ .map((e) => String(e.payload.text ?? ""))
587
+ .join(" ");
588
+ const files = [
589
+ ...new Set(events
590
+ .filter((e) => e.kind === "turn_diff")
591
+ .flatMap((e) => {
592
+ // turn_diff stores ChangedFile[] ({status, path}); older events or
593
+ // other shapes may carry bare strings. Normalise to paths.
594
+ const raw = e.payload.files ?? [];
595
+ return raw.map((f) => (typeof f === "string" ? f : (f?.path ?? ""))).filter(Boolean);
596
+ })),
597
+ ].slice(-20);
598
+ if (!query.trim() && !files.length)
599
+ return "";
600
+ const hits = retrieve(this.brain, {
601
+ ...(query.trim() ? { query } : {}),
602
+ ...(files.length ? { files } : {}),
603
+ agent: agentId,
604
+ minConfidence: CONFIDENCE_FLOOR,
605
+ limit: 14,
606
+ });
607
+ return compileBrief(hits.map((h) => h.memory));
608
+ }
609
+ /**
610
+ * Pull each ADE's native memory into the shared log. Idempotent — a source
611
+ * whose content hasn't changed since its last import is skipped, so this is
612
+ * safe to call on connect, on demand, or on a timer.
613
+ */
614
+ importMemories() {
615
+ const seen = new Map(); // file -> last imported hash
616
+ for (const e of this.log.list({ kinds: ["memory_import"] })) {
617
+ seen.set(String(e.payload.file), String(e.payload.hash));
618
+ }
619
+ const sources = [];
620
+ let imported = 0;
621
+ for (const block of this.importedMemory()) {
622
+ const hash = hashContent(block.content);
623
+ if (seen.get(block.file) === hash)
624
+ continue;
625
+ this.log.append({
626
+ kind: "memory_import",
627
+ agentId: block.agentId,
628
+ payload: { file: block.file, kind: block.kind, chars: block.content.length, hash },
629
+ });
630
+ sources.push(block.file);
631
+ imported += 1;
632
+ }
633
+ return { imported, sources };
634
+ }
635
+ /** Fire-and-notify hooks + routing + suggested handoffs, off the log. */
636
+ afterAgentEvent(event) {
637
+ this.trackCost(event);
638
+ this.routes.handleAgentEvent(event);
639
+ if (event.kind === "run_complete" && event.agentId) {
640
+ this.captureTurnDiff(event.agentId);
641
+ }
642
+ if (event.kind === "needs_input") {
643
+ notify({
644
+ title: `Loom · ${this.info.name}`,
645
+ body: `${event.agentId} needs input: ${String(event.payload.question ?? "")}`,
646
+ });
647
+ }
648
+ else if (event.kind === "run_complete") {
649
+ notify({
650
+ title: `Loom · ${this.info.name}`,
651
+ body: `${event.agentId} finished its turn`,
652
+ });
653
+ }
654
+ else if (event.kind === "message") {
655
+ if (event.agentId)
656
+ this.captureDecisions(event);
657
+ if (!this.routes.isActive()) {
658
+ // A route drives its own handoffs — suggestions would be noise.
659
+ const suggestion = suggestHandoff(event, this.config, this.baton.holder());
660
+ if (suggestion) {
661
+ this.log.append({ kind: "suggestion", payload: { ...suggestion, from: event.agentId } });
662
+ }
663
+ }
664
+ }
665
+ }
666
+ /**
667
+ * Convention: any agent line starting "Decision: …" is pinned into shared
668
+ * memory automatically — it survives every future handoff projection.
669
+ */
670
+ captureDecisions(event) {
671
+ const text = String(event.payload.text ?? "");
672
+ const matches = [...text.matchAll(/^[ \t]*decision:\s*(.+)$/gim)].slice(0, 5);
673
+ for (const m of matches) {
674
+ this.log.append({
675
+ kind: "decision",
676
+ payload: { text: m[1].trim(), author: event.agentId, auto: true },
677
+ });
678
+ }
679
+ }
680
+ /**
681
+ * The persisted holder, unless it refers to an agent that has since been
682
+ * removed from the config — ghost holders are cleared, not fatal.
683
+ */
684
+ validHolder() {
685
+ const holder = this.baton.holder();
686
+ if (holder && !this.agents.has(holder)) {
687
+ this.baton.forceClear(`agent "${holder}" no longer in config`);
688
+ return null;
689
+ }
690
+ return holder;
691
+ }
692
+ /**
693
+ * Send a user message. Routing rules (decided in the design interview):
694
+ * - no explicit agent → goes to the baton holder (or defaultAgent/first
695
+ * adapter on first contact, which acquires the baton);
696
+ * - explicit agent that is NOT the holder → NotHolderError; surfaces
697
+ * prompt the user to confirm a handoff (explicit, never silent).
698
+ */
699
+ /**
700
+ * Hand a prompt to a GUI agent by typing it into its own window.
701
+ *
702
+ * This is the road not taken by sendMessage. Antigravity and Kiro can't hold
703
+ * the baton — they edit the tree on their own schedule and know nothing about
704
+ * Loom's lock, so giving them the baton would be a promise Loom can't keep.
705
+ * But they can be *driven*: Loom types into the chat panel of the app you're
706
+ * already signed into, exactly as you would, and reads back what appeared.
707
+ * That's what makes them reachable from your phone.
708
+ *
709
+ * The exchange lands in the thread like any other, because the whole point of
710
+ * Loom is one place where you can see what was said to whom. It just never
711
+ * touches the baton on the way, so an adapter mid-turn is undisturbed.
712
+ *
713
+ * Awaited, unlike sendMessage's fire-and-notify: there's no event stream to
714
+ * follow here, only a panel that stops changing.
715
+ */
716
+ async askBridge(agentId, text, opts = {}) {
717
+ const chat = opts.chat ?? MAIN_CHAT;
718
+ const agent = this.agent(agentId);
719
+ if (isAdapter(agent)) {
720
+ throw new Error(`agent "${agentId}" takes turns — send to it normally`);
721
+ }
722
+ const bridge = agent;
723
+ if (typeof bridge.ask !== "function") {
724
+ throw new Error(`agent "${agentId}" can be watched but not driven`);
725
+ }
726
+ await this.ensureStarted(agentId);
727
+ this.turnChat.set(agentId, chat);
728
+ this.log.append({ kind: "message", chat, agentId, payload: { text, author: "user" } });
729
+ try {
730
+ const reply = await bridge.ask(text);
731
+ return { agentId, reply };
732
+ }
733
+ catch (err) {
734
+ const message = err instanceof Error ? err.message : String(err);
735
+ // The bridge's own words: "signed out", "launch it with…". They're the
736
+ // actionable part, and burying them behind "bridge failed" helps nobody.
737
+ this.log.append({ kind: "error", chat, agentId, payload: { message } });
738
+ throw err;
739
+ }
740
+ }
741
+ async sendMessage(text, agentId, opts = {}) {
742
+ const source = opts.source ?? "user";
743
+ const chat = opts.chat ?? MAIN_CHAT;
744
+ let target = agentId ?? this.validHolder() ?? this.defaultAdapterId();
745
+ const agent = this.agent(target);
746
+ if (!isAdapter(agent)) {
747
+ throw new Error(`agent "${target}" is a bridge (read-only) — it cannot take turns`);
748
+ }
749
+ const holder = this.validHolder();
750
+ if (holder === null) {
751
+ this.baton.acquire(target);
752
+ }
753
+ else if (holder !== target) {
754
+ throw new NotHolderError(target, holder);
755
+ }
756
+ // A user reply to a paused route's question resumes the route.
757
+ if (source === "user")
758
+ this.routes.onUserMessage(target);
759
+ // everything this turn produces belongs to the chat you sent from
760
+ this.turnChat.set(target, chat);
761
+ this.log.append({
762
+ kind: "message",
763
+ chat,
764
+ payload: { text, author: source === "route" ? "loom" : "user" },
765
+ });
766
+ await this.ensureStarted(target);
767
+ const pendingBriefing = this.consumePendingBriefing(target);
768
+ const input = pendingBriefing ? { text, briefing: pendingBriefing } : { text };
769
+ // Snapshot the tree so this prompt's changes can be attributed to it.
770
+ this.preTurnTree.set(target, await porcelainStatus(this.info.dir));
771
+ // Fire-and-notify: the turn runs in the background; progress streams
772
+ // into the log and completion lands as run_complete.
773
+ void agent.send(input).catch((err) => {
774
+ this.log.append({
775
+ kind: "error",
776
+ agentId: target,
777
+ payload: { message: String(err instanceof Error ? err.message : err) },
778
+ });
779
+ });
780
+ return { agentId: target };
781
+ }
782
+ defaultAdapterId() {
783
+ const cfg = (this.config.defaultAgent &&
784
+ this.config.agents.find((a) => a.id === this.config.defaultAgent)) ||
785
+ this.config.agents.find((a) => isAdapter(this.agent(a.id)));
786
+ if (!cfg)
787
+ throw new Error(`project "${this.info.name}" has no full-duplex adapters`);
788
+ return cfg.id;
789
+ }
790
+ // -------------------------------------------------------------------------
791
+ // Handoff
792
+ // -------------------------------------------------------------------------
793
+ /** Briefings are injected with the first turn after a handoff. */
794
+ pendingBriefings = new Map();
795
+ consumePendingBriefing(agentId) {
796
+ const briefing = this.pendingBriefings.get(agentId);
797
+ this.pendingBriefings.delete(agentId);
798
+ return briefing;
799
+ }
800
+ /**
801
+ * Explicit baton pass: interrupt the current holder if mid-turn, project
802
+ * the log into the target's namespaced memory, arm the one-shot briefing.
803
+ * A *manual* handoff cancels any active route — the human outranks it.
804
+ */
805
+ async handoff(to, opts = {}) {
806
+ const target = this.agent(to);
807
+ if (!isAdapter(target)) {
808
+ throw new Error(`cannot hand the baton to "${to}" — bridges are read-only by design`);
809
+ }
810
+ if ((opts.source ?? "user") === "user")
811
+ this.routes.onManualHandoff();
812
+ // Audit trail: snapshot the outgoing holder's working-tree state into the
813
+ // handoff event, so "who left what uncommitted" is always answerable.
814
+ let handoffMeta = { projected: true };
815
+ const holder = this.validHolder();
816
+ if (holder && holder !== to) {
817
+ const current = this.agent(holder);
818
+ if (isAdapter(current)) {
819
+ if (current.busy())
820
+ await current.interrupt();
821
+ const diff = await current.diff().catch(() => "");
822
+ if (diff)
823
+ handoffMeta = { ...handoffMeta, dirty: true, diff: diff.slice(0, 2000) };
824
+ }
825
+ }
826
+ // Refresh the shared brain from every ADE's native memory before handing
827
+ // off, so the incoming agent inherits what the others knew.
828
+ this.importMemories();
829
+ const events = this.log.list({ limit: PROJECTION_WINDOW });
830
+ const input = {
831
+ projectName: this.info.name,
832
+ config: this.config,
833
+ events,
834
+ targetAgentId: to,
835
+ fromAgentId: holder,
836
+ };
837
+ // Template by default; LLM-distilled when the project opts in — always
838
+ // falling back to the template so a broken Claude never blocks a handoff.
839
+ const distillStart = Date.now();
840
+ const rendered = await renderProjection(input, this.config.projection);
841
+ // Phase 3: the memories relevant to the work in flight, retrieved and
842
+ // compiled — this is the part the recency-window projection can't do. Query
843
+ // is the recent conversation plus the files recent turns touched; scoped to
844
+ // this chat and to the incoming agent; low-confidence memories are held back
845
+ // from injection (they're still visible in the Brain tab).
846
+ const brainBrief = this.retrieveBrief(events, to);
847
+ // Append the unified cross-ADE memory so the incoming agent sees the
848
+ // whole brain, not just this project's log.
849
+ const unified = this.unifiedMemory();
850
+ const parts = [rendered.content];
851
+ if (brainBrief)
852
+ parts.push(brainBrief);
853
+ if (unified.sources.length > 0)
854
+ parts.push(unified.document);
855
+ const enriched = parts.join("\n\n---\n");
856
+ await target.injectMemory(enriched);
857
+ writeMemoryFile(this.info.dir, to, enriched); // idempotent with default impl
858
+ this.pendingBriefings.set(to, buildBriefing(input));
859
+ if (rendered.mode === "llm") {
860
+ this.log.append({
861
+ kind: "status",
862
+ payload: { state: "projection", mode: "llm", ms: Date.now() - distillStart },
863
+ });
864
+ }
865
+ // Bridges (GUI agents) are passive observers — keep their shared-context
866
+ // files fresh on every hop so e.g. Antigravity always sees the weave.
867
+ // (Always template views: N bridges × LLM calls per hop would be waste.)
868
+ for (const cfg of this.config.agents) {
869
+ const bystander = this.agents.get(cfg.id);
870
+ if (!bystander || isAdapter(bystander) || cfg.id === to)
871
+ continue;
872
+ // Bridges get the retrieved brain brief too — they can't take a system
873
+ // prompt, but their shared-context file is the only memory they have, so
874
+ // it shouldn't be the one view without the learned memories in it.
875
+ const bridgeBrief = this.retrieveBrief(events, cfg.id);
876
+ const bridgeView = bridgeBrief
877
+ ? `${buildProjection({ ...input, targetAgentId: cfg.id })}\n\n---\n${bridgeBrief}`
878
+ : buildProjection({ ...input, targetAgentId: cfg.id });
879
+ await bystander.injectMemory(bridgeView).catch(() => { });
880
+ }
881
+ const { from } = this.baton.handoff(to, handoffMeta);
882
+ await this.ensureStarted(to);
883
+ return { from };
884
+ }
885
+ async interrupt(opts = {}) {
886
+ if ((opts.source ?? "user") === "user")
887
+ this.routes.onManualInterrupt();
888
+ const holder = this.validHolder();
889
+ if (!holder)
890
+ return { interrupted: null };
891
+ const agent = this.agent(holder);
892
+ if (isAdapter(agent) && agent.busy()) {
893
+ await agent.interrupt();
894
+ return { interrupted: holder };
895
+ }
896
+ return { interrupted: null };
897
+ }
898
+ // -------------------------------------------------------------------------
899
+ // Routing
900
+ // -------------------------------------------------------------------------
901
+ /**
902
+ * Start a multi-hop route. `spec` may be: "auto" (dynamic — a router picks
903
+ * every hop), an array of steps, a named route from config, or a comma
904
+ * list of agent ids/roles. Undefined → the "ship" route if defined, else
905
+ * every adapter in config order.
906
+ */
907
+ async startRoute(opts) {
908
+ if (typeof opts.spec === "string" && opts.spec.trim() === "auto") {
909
+ return this.routes.startDynamic(opts.task, {
910
+ ...(opts.router ? { router: opts.router } : {}),
911
+ ...(opts.maxHops ? { maxHops: opts.maxHops } : {}),
912
+ });
913
+ }
914
+ let steps;
915
+ let name;
916
+ if (Array.isArray(opts.spec)) {
917
+ steps = opts.spec;
918
+ }
919
+ else if (typeof opts.spec === "string" && opts.spec.trim()) {
920
+ const named = this.config.routes?.[opts.spec.trim()];
921
+ if (named) {
922
+ steps = named;
923
+ name = opts.spec.trim();
924
+ }
925
+ else {
926
+ steps = opts.spec.split(",").map((s) => s.trim()).filter(Boolean);
927
+ }
928
+ }
929
+ else {
930
+ const ship = this.config.routes?.["ship"];
931
+ if (ship) {
932
+ steps = ship;
933
+ name = "ship";
934
+ }
935
+ else {
936
+ steps = this.config.agents
937
+ .filter((a) => {
938
+ const agent = this.agents.get(a.id);
939
+ return agent && isAdapter(agent);
940
+ })
941
+ .map((a) => a.id);
942
+ }
943
+ }
944
+ return this.routes.start(steps ?? [], opts.task, name);
945
+ }
946
+ async abortRoute() {
947
+ return this.routes.abort();
948
+ }
949
+ routeState() {
950
+ return this.routes.state();
951
+ }
952
+ // -------------------------------------------------------------------------
953
+ // Status / board
954
+ // -------------------------------------------------------------------------
955
+ async status() {
956
+ const holder = this.validHolder();
957
+ const agents = await Promise.all(this.config.agents.map(async (cfg) => {
958
+ const agent = this.agent(cfg.id);
959
+ return {
960
+ id: cfg.id,
961
+ kind: cfg.kind,
962
+ role: cfg.role,
963
+ tier: agent.capabilities.tier,
964
+ available: await agent.available().catch(() => false),
965
+ busy: isAdapter(agent) ? agent.busy() : false,
966
+ holdsBaton: holder === cfg.id,
967
+ // The picker shows a tick next to the active model; "" means the
968
+ // adapter's own default, which is the honest baseline.
969
+ model: cfg.options?.model ?? "",
970
+ };
971
+ }));
972
+ const recent = this.log.list({ limit: 50 });
973
+ const lastEvent = recent[recent.length - 1] ?? null;
974
+ const lastUserMsg = [...recent]
975
+ .reverse()
976
+ .find((e) => e.kind === "message" && !e.agentId);
977
+ const lastNeedsInput = [...recent].reverse().find((e) => e.kind === "needs_input");
978
+ const needsInput = Boolean(lastNeedsInput && (!lastUserMsg || lastNeedsInput.id > lastUserMsg.id));
979
+ return {
980
+ id: this.info.id,
981
+ name: this.info.name,
982
+ dir: this.info.dir,
983
+ holder,
984
+ agents,
985
+ lastEvent,
986
+ needsInput,
987
+ // which agent is waiting, not just that someone is — the board needs a
988
+ // name to put on the card, and every caller already gets needsInput
989
+ blockedAgent: needsInput ? (lastNeedsInput?.agentId ?? null) : null,
990
+ chats: this.chats(),
991
+ route: this.routes.state(),
992
+ routeNames: ["auto", ...Object.keys(this.config.routes ?? {})],
993
+ costUsd: this.costs.totalUsd,
994
+ };
995
+ }
996
+ async close() {
997
+ for (const id of this.startedAgents) {
998
+ await this.agent(id).stop().catch(() => { });
999
+ }
1000
+ this.startedAgents.clear();
1001
+ this.brain.close(); // unsubscribes before the log drops its listeners
1002
+ this.log.close();
1003
+ }
1004
+ }
1005
+ export function relativeToProject(projectDir, p) {
1006
+ return path.isAbsolute(p) ? path.relative(projectDir, p) : p;
1007
+ }
1008
+ function configMtimeOf(projectDir) {
1009
+ try {
1010
+ return fs.statSync(path.join(projectDir, ".loom", "config.json")).mtimeMs;
1011
+ }
1012
+ catch {
1013
+ return 0;
1014
+ }
1015
+ }
1016
+ //# sourceMappingURL=runtime.js.map