pipeline-moe 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 (48) hide show
  1. package/.env.example +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +408 -0
  4. package/bin/pipeline-moe.mjs +40 -0
  5. package/package.json +68 -0
  6. package/presets/2106BUILD.json +163 -0
  7. package/presets/CHEAPBUILD.json +97 -0
  8. package/presets/FREEROOM.json +96 -0
  9. package/presets/Versa.json +145 -0
  10. package/presets/cloud-main.json +76 -0
  11. package/presets/cloud-sprint.json +70 -0
  12. package/presets/local-default.json +152 -0
  13. package/presets/main.json +147 -0
  14. package/presets/mainmix.json +145 -0
  15. package/src/circuit-breaker.ts +141 -0
  16. package/src/config.ts +46 -0
  17. package/src/custom-tools/arxiv-search.ts +186 -0
  18. package/src/custom-tools/check-room.ts +45 -0
  19. package/src/custom-tools/destroy-room.ts +45 -0
  20. package/src/custom-tools/index.ts +75 -0
  21. package/src/custom-tools/spawn-room.ts +99 -0
  22. package/src/custom-tools/stop-room.ts +50 -0
  23. package/src/custom-tools/web-read.ts +104 -0
  24. package/src/custom-tools/web-search.ts +144 -0
  25. package/src/custom-tools/youcom-search.ts +230 -0
  26. package/src/custom-tools/youtube-transcript.ts +124 -0
  27. package/src/local-model-lock.ts +52 -0
  28. package/src/model.ts +131 -0
  29. package/src/orchestrator.ts +59 -0
  30. package/src/participant.ts +423 -0
  31. package/src/path-guard.ts +13 -0
  32. package/src/personas.ts +480 -0
  33. package/src/receipts.ts +120 -0
  34. package/src/registry.ts +317 -0
  35. package/src/room-manager.ts +505 -0
  36. package/src/room.ts +1657 -0
  37. package/src/sandbox-tools.ts +141 -0
  38. package/src/server.ts +1846 -0
  39. package/src/sse.ts +86 -0
  40. package/src/sshfs.ts +139 -0
  41. package/src/store.ts +79 -0
  42. package/src/types.ts +131 -0
  43. package/src/validation.ts +36 -0
  44. package/tsconfig.json +15 -0
  45. package/web/dist/assets/index-CmMGhMKG.css +1 -0
  46. package/web/dist/assets/index-LAGqbZII.js +41 -0
  47. package/web/dist/index.html +13 -0
  48. package/web/tsconfig.json +21 -0
package/src/room.ts ADDED
@@ -0,0 +1,1657 @@
1
+ // The Room: shared transcript + serial turn queue + @mention routing + work
2
+ // receipts. One room per process. All model work is serialised here, which
3
+ // also matches llama-server running with --parallel 1.
4
+
5
+ import { config } from "./config.js"
6
+ import { diffSnapshots, listWorkspace, receiptFromActivity, receiptHasChanges, snapshot } from "./receipts.js"
7
+ import type { Registry } from "./registry.js"
8
+ import type { Participant } from "./participant.js"
9
+ import type { ConversationStore } from "./store.js"
10
+ import { conversationMeta } from "./store.js"
11
+ import type { SseHub, SseEventName } from "./sse.js"
12
+ import type { LocalModelLock } from "./local-model-lock.js"
13
+ import { REPEAT_THRESHOLD, SIMILARITY_FLOOR, textSimilarity, LOOKBACK_WINDOW, checkToolLoop, TOOL_REPEAT_THRESHOLD } from "./circuit-breaker.js"
14
+ import { goalEvalPrompt } from "./personas.js"
15
+ import type {
16
+ Conversation,
17
+ ConversationMeta,
18
+ Persona,
19
+ RouteDecision,
20
+ RoutingMode,
21
+ ToolActivity,
22
+ TranscriptEntry,
23
+ WorkReceipt,
24
+ } from "./types.js"
25
+
26
+ /** Produce a compact one-line summary of a work receipt for injection into the next agent's context. */
27
+ function formatReceipt(r: WorkReceipt): string {
28
+ const parts: string[] = []
29
+ if (r.created.length > 0) parts.push(`created: ${r.created.join(", ")}`)
30
+ if (r.modified.length > 0) parts.push(`modified: ${r.modified.join(", ")}`)
31
+ if (r.deleted.length > 0) parts.push(`deleted: ${r.deleted.join(", ")}`)
32
+ return `📋 Work receipt from @${r.participantId}: ${parts.join("; ")}`
33
+ }
34
+
35
+ /** State when the pipeline is paused waiting for a user response to an ask_user. */
36
+ interface PendingQuestion {
37
+ askerId: string
38
+ heldQueue: Participant[]
39
+ }
40
+
41
+ /** A proposed handoff awaiting human approval (semi/manual routing). */
42
+ interface RouteProposal {
43
+ fromId: string
44
+ target: Participant
45
+ }
46
+
47
+ /** State when routing is paused for human approval (semi/manual mode). The
48
+ * heldQueue is work already queued before the proposal; it resumes once the
49
+ * human approves / redirects / drops. */
50
+ interface PendingRoute {
51
+ proposals: RouteProposal[]
52
+ heldQueue: Participant[]
53
+ }
54
+
55
+ const MENTION_RE = /@(\w+)/g
56
+
57
+ /** What one agent produced in a turn, before it is posted to the transcript. */
58
+ interface RunOutput {
59
+ target: Participant
60
+ reply: string
61
+ activity: ToolActivity[]
62
+ reasoning?: string
63
+ receipt: WorkReceipt
64
+ /** If the agent called ask_user, the question text. */
65
+ question?: string
66
+ }
67
+
68
+ function newConvId(): string {
69
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
70
+ }
71
+
72
+ export class Room {
73
+ private transcript: TranscriptEntry[] = []
74
+ private chain: Promise<void> = Promise.resolve()
75
+ /** Agents currently mid-turn. >1 when a parallel wave is running. */
76
+ private running = new Set<Participant>()
77
+ /** Id of the first agent that started the current turn. Used for UI targeting (steer). */
78
+ private runningAgentId: string | null = null
79
+ /** Pending agents to run in the current routing pass. Mutated as agents chain. */
80
+ private queue: Participant[] = []
81
+ private aborted = false
82
+ /** Routing mode. 'auto' chains @mentions directly (today's default); 'semi'
83
+ * pauses each proposed handoff for human approval; 'manual' honors no
84
+ * agent→agent chaining. The legacy `chaining` boolean is derived from this
85
+ * (auto/semi → on, manual → off) so existing settings, persistence, and tests
86
+ * keep working unchanged. */
87
+ private routingMode: RoutingMode = "auto"
88
+ private get chaining(): boolean { return this.routingMode !== "manual" }
89
+ private set chaining(value: boolean) { this.routingMode = value ? "auto" : "manual" }
90
+ /** Anti-loop: max chain hops per turn. Prevents A→B→A infinite loops. */
91
+ private maxChainHops = 30
92
+ private chainBudget = 0
93
+ /** Set when an agent called ask_user — pipeline is paused until user responds. */
94
+ private pendingQuestion: PendingQuestion | null = null
95
+ /** Set in semi/manual mode when proposed handoffs await human approval. */
96
+ private pendingRoute: PendingRoute | null = null
97
+ /** Agent that handles messages with no @mention. null = first active. */
98
+ private defaultAgentId: string | null = null
99
+ /** Agent that receives routing fallback when no agent is @-mentioned in a reply. null = disabled. */
100
+ private fallbackAgentId: string | null = "planner"
101
+ /** Agent whose circuit breaker tripped — used for fallback recovery routing. null = no breaker event. */
102
+ private circuitBreakerAgentId: string | null = null
103
+ /** Goal prompt if this room was started with a goal; null for interactive rooms. */
104
+ private goalText: string | null = null
105
+ /** Lifecycle status for goal-driven rooms. */
106
+ private goalStatus: "idle" | "running" | "completed" | "failed" | "cancelled" = "idle"
107
+ /** Set by abortCurrent() while a goal is running. Sticky for the whole goal
108
+ * run — NOT reset per eval iteration — so the goal-eval loop (which clears
109
+ * `aborted` on every pass) still terminates as "cancelled" instead of spinning
110
+ * to the next iteration. Cleared by submitGoal() for the next goal. */
111
+ private goalCancelled = false
112
+ /** Goal completion mode. "auto": complete when the pipeline drains naturally.
113
+ * "eval": after each drain, route to the evaluator to verify the goal and
114
+ * either dispatch more work or declare GOAL_MET. */
115
+ private goalMode: "auto" | "eval" = "auto"
116
+ /** Agent id that evaluates the goal in "eval" mode. */
117
+ private goalEvaluator = "planner"
118
+ /** Max eval iterations before the goal auto-fails (eval mode only). */
119
+ private maxGoalIterations = 10
120
+ /** Eval iterations consumed so far in the current goal run. */
121
+ private goalIteration = 0
122
+ /** Fallback agent saved while an eval-mode goal suppresses fallback routing.
123
+ * Restored when the eval loop terminates. */
124
+ private goalEvalSavedFallback: string | null = null
125
+
126
+ // ── Current conversation identity ──────────────────────────────────────────
127
+ private convId = newConvId()
128
+ private convTitle = "Discussion 1"
129
+ private convCreatedAt = Date.now()
130
+ private saveTimer: ReturnType<typeof setTimeout> | null = null
131
+
132
+ constructor(
133
+ private readonly registry: Registry,
134
+ private readonly hub: SseHub,
135
+ private readonly store: ConversationStore,
136
+ private readonly seedPersonas: Persona[],
137
+ /** Logical room identifier — included in all SSE broadcasts for future room-scoped filtering. */
138
+ readonly roomId: string = "default",
139
+ /** Optional process-global lock for serializing local-model inference across rooms. */
140
+ private readonly localLock?: LocalModelLock,
141
+ /** Whether the repetition/tool-loop circuit breaker is active. Defaults to
142
+ * config.circuitBreaker (ON). Disabled for cloud models that legitimately repeat.
143
+ * Mutable — can be toggled per-room via the Settings panel. */
144
+ private circuitBreakerEnabled: boolean = config.circuitBreaker,
145
+ /** Directory this room is scoped to: where file tools are confined, bash runs,
146
+ * work receipts snapshot, and the workspace listing looks. Defaults to the
147
+ * pipeline workspace. */
148
+ private readonly workspaceDir: string = config.workspaceDir,
149
+ /** True when the workspace is a remote (sshfs) mount. Walking the whole
150
+ * remote tree per turn would stall every action for ~a minute over the
151
+ * network, so work receipts and the live workspace listing are skipped
152
+ * for remote rooms. */
153
+ private readonly remote: boolean = false,
154
+ ) {}
155
+
156
+ /** Default thinking level for agents without a per-agent override.
157
+ * Mutable — can be changed per-room via the Settings panel.
158
+ * Propagated to the Registry so new participants use it. */
159
+ private defaultThinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" = config.thinkingLevel
160
+
161
+ /** Whether cloud models are allowed in this room. Mutable — can be toggled
162
+ * per-room via the Settings panel. Propagated to the Registry so new
163
+ * participants use the room's policy. */
164
+ private allowCloud: boolean = config.allowCloud
165
+
166
+ /** Reserve tokens for auto-compaction. Mutable — can be changed per-room via
167
+ * the Settings panel. Propagated to the Registry so new participants use
168
+ * the room's value. */
169
+ private compactionReserveTokens: number = 38000
170
+
171
+ /** Broadcast wrapper: tags object payloads with roomId; arrays pass through unmodified. */
172
+ private emit(event: SseEventName, data: unknown): void {
173
+ const payload =
174
+ data !== null && typeof data === "object" && !Array.isArray(data)
175
+ ? { roomId: this.roomId, ...(data as Record<string, unknown>) }
176
+ : data
177
+ this.hub.broadcast(event, payload, this.roomId)
178
+ }
179
+
180
+ getTranscript(): TranscriptEntry[] {
181
+ return this.transcript
182
+ }
183
+
184
+ /** The directory this room's agents are scoped to (file tools, bash cwd, receipts). */
185
+ getWorkspaceDir(): string {
186
+ return this.workspaceDir
187
+ }
188
+
189
+ /** Workspace file listing for the UI panel. Empty for remote (sshfs) rooms:
190
+ * walking the whole remote tree over the network would take ~a minute. */
191
+ async getWorkspaceListing(): Promise<Array<{ path: string; size: number }>> {
192
+ return this.remote ? [] : listWorkspace(this.workspaceDir)
193
+ }
194
+
195
+ private async emitWorkspace(): Promise<void> {
196
+ this.emit("workspace", await this.getWorkspaceListing())
197
+ }
198
+
199
+ /** Number of participants (active + inactive) in this room's registry. */
200
+ rosterLength(): number {
201
+ return this.registry.roster().length
202
+ }
203
+
204
+ getGoalText(): string | null { return this.goalText }
205
+ getGoalStatus(): "idle" | "running" | "completed" | "failed" | "cancelled" { return this.goalStatus }
206
+
207
+ /** Start a goal-driven pipeline run. Sets goalText/status and fires the first turn.
208
+ * In "eval" mode the evaluator agent verifies the goal after each drain and
209
+ * drives an iterative dispatch loop until it declares GOAL_MET or the iteration
210
+ * budget is exhausted. */
211
+ submitGoal(
212
+ text: string,
213
+ opts?: { mode?: "auto" | "eval"; evaluator?: string; maxIterations?: number },
214
+ ): void {
215
+ this.goalText = text
216
+ this.goalStatus = "running"
217
+ this.goalMode = opts?.mode ?? "auto"
218
+ this.goalEvaluator = opts?.evaluator?.trim() || "planner"
219
+ this.maxGoalIterations = Math.max(1, Math.min(50, Math.round(opts?.maxIterations ?? 10)))
220
+ this.goalIteration = 0
221
+ this.goalCancelled = false
222
+ // In eval mode the eval loop is the sole router: the evaluator is invoked
223
+ // deliberately after every natural drain. Leaving generic fallback routing
224
+ // active would re-invoke the evaluator (when it is also the fallback agent)
225
+ // with a misleading "routing fallback" context — doubling invocations and
226
+ // draining the iteration budget. Suppress fallback for the whole goal run
227
+ // (initial drain + eval loop); runGoalEval's finally restores it.
228
+ if (this.goalMode === "eval") {
229
+ this.goalEvalSavedFallback = this.fallbackAgentId
230
+ this.fallbackAgentId = null
231
+ }
232
+ this.submit(text)
233
+ }
234
+
235
+ getGoalMode(): "auto" | "eval" { return this.goalMode }
236
+
237
+ /** Expose the registry for server-side route handlers. */
238
+ getRegistry(): Registry {
239
+ return this.registry
240
+ }
241
+
242
+ getChaining(): boolean {
243
+ return this.chaining
244
+ }
245
+
246
+ setChaining(value: boolean): void {
247
+ this.chaining = value
248
+ this.broadcastSettings()
249
+ void this.saveCurrent()
250
+ }
251
+
252
+ getRoutingMode(): RoutingMode {
253
+ return this.routingMode
254
+ }
255
+
256
+ setRoutingMode(mode: RoutingMode): void {
257
+ this.routingMode = mode
258
+ this.broadcastSettings()
259
+ void this.saveCurrent()
260
+ }
261
+
262
+ getDefaultAgent(): string | null {
263
+ return this.defaultAgentId
264
+ }
265
+
266
+ /** Set the agent that handles un-mentioned messages. null = first active. */
267
+ setDefaultAgent(id: string | null): void {
268
+ if (id !== null && !this.registry.has(id)) throw new Error(`unknown participant "${id}"`)
269
+ this.defaultAgentId = id
270
+ this.broadcastSettings()
271
+ void this.saveCurrent()
272
+ }
273
+
274
+ getFallbackAgent(): string | null {
275
+ return this.fallbackAgentId
276
+ }
277
+
278
+ /** Set the agent that receives routing fallback. null = disabled. */
279
+ setFallbackAgent(id: string | null): void {
280
+ if (id !== null && !this.registry.has(id)) throw new Error(`unknown participant "${id}"`)
281
+ this.fallbackAgentId = id
282
+ this.broadcastSettings()
283
+ void this.saveCurrent()
284
+ }
285
+
286
+ getMaxChainHops(): number {
287
+ return this.maxChainHops
288
+ }
289
+
290
+ setMaxChainHops(n: number): void {
291
+ this.maxChainHops = Math.max(1, Math.min(100, Math.round(n)))
292
+ this.broadcastSettings()
293
+ void this.saveCurrent()
294
+ }
295
+
296
+ // ── Circuit breaker toggle ──────────────────────────────────────────────────
297
+
298
+ getCircuitBreaker(): boolean {
299
+ return this.circuitBreakerEnabled
300
+ }
301
+
302
+ setCircuitBreaker(enabled: boolean): void {
303
+ this.circuitBreakerEnabled = enabled
304
+ this.broadcastSettings()
305
+ void this.saveCurrent()
306
+ }
307
+
308
+ // ── Default thinking level ──────────────────────────────────────────────────
309
+
310
+ getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" {
311
+ return this.defaultThinkingLevel
312
+ }
313
+
314
+ setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void {
315
+ this.defaultThinkingLevel = level
316
+ // Propagate to registry so new participants use the updated default.
317
+ this.registry.setDefaultThinkingLevel(level)
318
+ this.broadcastSettings()
319
+ void this.saveCurrent()
320
+ }
321
+
322
+ // ── Allow cloud toggle ─────────────────────────────────────────────────────
323
+
324
+ getAllowCloud(): boolean {
325
+ return this.allowCloud
326
+ }
327
+
328
+ setAllowCloud(value: boolean): void {
329
+ this.allowCloud = value
330
+ // Propagate to registry so new participants use the updated policy.
331
+ this.registry.setAllowCloud(value)
332
+ this.broadcastSettings()
333
+ void this.saveCurrent()
334
+ }
335
+
336
+ // ── Compaction reserve tokens ────────────────────────────────────────────
337
+
338
+ getCompactionReserveTokens(): number {
339
+ return this.compactionReserveTokens
340
+ }
341
+
342
+ setCompactionReserveTokens(value: number): void {
343
+ this.compactionReserveTokens = Math.max(5000, Math.min(100000, Math.round(value)))
344
+ // Propagate to registry so new participants use the updated value.
345
+ this.registry.setCompactionReserveTokens(this.compactionReserveTokens)
346
+ this.broadcastSettings()
347
+ void this.saveCurrent()
348
+ }
349
+
350
+ private broadcastSettings(): void {
351
+ this.emit("settings", {
352
+ chaining: this.chaining,
353
+ routingMode: this.routingMode,
354
+ defaultAgent: this.defaultAgentId,
355
+ fallbackAgent: this.fallbackAgentId,
356
+ maxChainHops: this.maxChainHops,
357
+ circuitBreaker: this.circuitBreakerEnabled,
358
+ defaultThinkingLevel: this.defaultThinkingLevel,
359
+ allowCloud: this.allowCloud,
360
+ compactionReserveTokens: this.compactionReserveTokens,
361
+ })
362
+ }
363
+
364
+ // ── Conversation lifecycle ──────────────────────────────────────────────────
365
+
366
+ /** Load the most recent saved conversation, or seed a fresh one. Wires autosave. */
367
+ async init(): Promise<void> {
368
+ await this.store.init()
369
+ const metas = await this.store.list()
370
+ const latest = metas[0] ? await this.store.read(metas[0].id) : null
371
+ if (latest) {
372
+ await this.applyConversation(latest)
373
+ } else {
374
+ await this.startFresh(
375
+ "Discussion 1",
376
+ this.seedPersonas.map((p) => ({ ...p, active: true })),
377
+ )
378
+ }
379
+ // From now on, any roster change autosaves the current conversation.
380
+ this.registry.onChange = () => this.scheduleSave()
381
+ }
382
+
383
+ private buildConversation(): Conversation {
384
+ return {
385
+ id: this.convId,
386
+ title: this.convTitle,
387
+ createdAt: this.convCreatedAt,
388
+ updatedAt: Date.now(),
389
+ chaining: this.chaining,
390
+ routingMode: this.routingMode,
391
+ defaultAgent: this.defaultAgentId,
392
+ fallbackAgent: this.fallbackAgentId,
393
+ circuitBreaker: this.circuitBreakerEnabled,
394
+ defaultThinkingLevel: this.defaultThinkingLevel,
395
+ allowCloud: this.allowCloud,
396
+ compactionReserveTokens: this.compactionReserveTokens,
397
+ personas: this.registry.personaStates(),
398
+ transcript: this.transcript,
399
+ }
400
+ }
401
+
402
+ /** Persist the current conversation and push the refreshed list to clients. */
403
+ async saveCurrent(): Promise<void> {
404
+ if (this.saveTimer) {
405
+ clearTimeout(this.saveTimer)
406
+ this.saveTimer = null
407
+ }
408
+ await this.store.write(this.buildConversation())
409
+ await this.broadcastConversations()
410
+ }
411
+
412
+ /** Debounced autosave, for bursty roster mutations. */
413
+ private scheduleSave(): void {
414
+ if (this.saveTimer) clearTimeout(this.saveTimer)
415
+ this.saveTimer = setTimeout(() => {
416
+ this.saveTimer = null
417
+ void this.saveCurrent()
418
+ }, 400)
419
+ }
420
+
421
+ async getConversations(): Promise<{ currentId: string; list: ConversationMeta[] }> {
422
+ return { currentId: this.convId, list: await this.store.list() }
423
+ }
424
+
425
+ private async broadcastConversations(): Promise<void> {
426
+ this.emit("conversations", {
427
+ currentId: this.convId,
428
+ list: await this.store.list(),
429
+ })
430
+ }
431
+
432
+ /** True while an agent is running or queued — editing a roster member's
433
+ * session (which disposes+recreates it) is unsafe during this window. */
434
+ isBusy(): boolean {
435
+ return this.running.size > 0 || this.queue.length > 0 || this.pendingQuestion !== null || this.pendingRoute !== null
436
+ }
437
+
438
+ private ensureIdle(): void {
439
+ if (this.running.size > 0 || this.queue.length > 0 || this.pendingQuestion !== null || this.pendingRoute !== null) {
440
+ throw new Error("a turn is running — press Stop before switching discussions")
441
+ }
442
+ }
443
+
444
+ /** Become a brand-new empty conversation with the given roster. */
445
+ private async startFresh(title: string, personas: Conversation["personas"]): Promise<void> {
446
+ this.convId = newConvId()
447
+ this.convTitle = title
448
+ this.convCreatedAt = Date.now()
449
+ this.transcript = []
450
+ this.defaultAgentId = null // fresh discussion → first active is the default
451
+ await this.registry.reset(personas)
452
+ this.emit("transcript", this.transcript)
453
+ this.broadcastSettings()
454
+ await this.saveCurrent()
455
+ }
456
+
457
+ /** Make a saved conversation the live one (fresh sessions, replayed transcript). */
458
+ private async applyConversation(conv: Conversation): Promise<void> {
459
+ this.convId = conv.id
460
+ this.convTitle = conv.title
461
+ this.convCreatedAt = conv.createdAt
462
+ this.routingMode = conv.routingMode ?? (conv.chaining ? "auto" : "manual")
463
+ this.defaultAgentId = conv.defaultAgent ?? null
464
+ this.fallbackAgentId = conv.fallbackAgent ?? "planner"
465
+ // Back-compat: older saves don't have these fields — fall back to config defaults.
466
+ this.circuitBreakerEnabled = conv.circuitBreaker ?? config.circuitBreaker
467
+ if (conv.defaultThinkingLevel) {
468
+ const level = conv.defaultThinkingLevel as "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
469
+ this.defaultThinkingLevel = level
470
+ this.registry.setDefaultThinkingLevel(level)
471
+ }
472
+ // Back-compat: older saves don't have allowCloud — fall back to config default.
473
+ this.allowCloud = conv.allowCloud ?? config.allowCloud
474
+ this.registry.setAllowCloud(this.allowCloud)
475
+ // Back-compat: older saves don't have compactionReserveTokens — fall back to 38000.
476
+ this.compactionReserveTokens = conv.compactionReserveTokens ?? 38000
477
+ this.registry.setCompactionReserveTokens(this.compactionReserveTokens)
478
+
479
+ // Guard against a corrupt save with an empty roster (e.g. a botched
480
+ // out-of-band edit / mid-turn restart). An empty roster would brick the UI
481
+ // permanently — and since init() loads the most recent conversation, it
482
+ // would do so on every boot. Fall back to the seed personas and re-persist.
483
+ let healed = false
484
+ let personas = conv.personas
485
+ if (personas.length === 0) {
486
+ personas = this.seedPersonas.map((p) => ({ ...p, active: true }))
487
+ healed = true
488
+ }
489
+ await this.registry.reset(personas)
490
+ // cursor=0 (set by reset→create) means each agent catches up on the whole
491
+ // transcript on its next turn — its fresh session has no prior memory.
492
+ this.transcript = conv.transcript.map((e) => ({ ...e }))
493
+ this.broadcastSettings()
494
+ this.emit("transcript", this.transcript)
495
+ if (healed) {
496
+ this.notice(`"${conv.title}" had an empty roster — restored the seed agents.`, "info")
497
+ await this.saveCurrent() // make the repair stick on disk
498
+ }
499
+ await this.broadcastConversations()
500
+ }
501
+
502
+ /** Start a new discussion, inheriting the current roster. Returns its metadata. */
503
+ async newConversation(title?: string): Promise<ConversationMeta> {
504
+ this.ensureIdle()
505
+ await this.saveCurrent()
506
+ const personas = this.registry.personaStates()
507
+ const count = (await this.store.list()).length
508
+ await this.startFresh(title?.trim() || `Discussion ${count + 1}`, personas)
509
+ return conversationMeta(this.buildConversation())
510
+ }
511
+
512
+ /** Start a new discussion with a preset roster. Returns its metadata. */
513
+ async loadPreset(personas: Conversation["personas"], title?: string): Promise<ConversationMeta> {
514
+ this.ensureIdle()
515
+ await this.saveCurrent()
516
+ const count = (await this.store.list()).length
517
+ await this.startFresh(title?.trim() || `Discussion ${count + 1}`, personas)
518
+ return conversationMeta(this.buildConversation())
519
+ }
520
+
521
+ /** Apply a preset roster to the current room — replaces agents in-place without
522
+ * changing the conversation id, title, or transcript. Persists immediately so
523
+ * the roster survives reboot. */
524
+ async applyPreset(personas: Conversation["personas"]): Promise<ConversationMeta> {
525
+ this.ensureIdle()
526
+ await this.registry.reset(personas)
527
+ this.broadcastSettings()
528
+ await this.saveCurrent()
529
+ await this.broadcastConversations()
530
+ return conversationMeta(this.buildConversation())
531
+ }
532
+
533
+ /** Switch to a saved discussion. No-op if already current. */
534
+ async switchConversation(id: string): Promise<void> {
535
+ this.ensureIdle()
536
+ if (id === this.convId) return
537
+ const conv = await this.store.read(id)
538
+ if (!conv) throw new Error(`unknown conversation "${id}"`)
539
+ await this.saveCurrent() // flush the one we're leaving
540
+ await this.applyConversation(conv)
541
+ }
542
+
543
+ async renameConversation(id: string, title: string): Promise<void> {
544
+ const clean = title.trim()
545
+ if (!clean) throw new Error("title is required")
546
+ if (id === this.convId) {
547
+ this.convTitle = clean
548
+ await this.saveCurrent()
549
+ return
550
+ }
551
+ const conv = await this.store.read(id)
552
+ if (!conv) throw new Error(`unknown conversation "${id}"`)
553
+ conv.title = clean
554
+ conv.updatedAt = Date.now()
555
+ await this.store.write(conv)
556
+ await this.broadcastConversations()
557
+ }
558
+
559
+ async deleteConversation(id: string): Promise<void> {
560
+ this.ensureIdle()
561
+ await this.store.remove(id)
562
+ if (id === this.convId) {
563
+ // Deleted the live one: fall back to the most recent remaining, else seed.
564
+ const metas = await this.store.list()
565
+ const next = metas[0] ? await this.store.read(metas[0].id) : null
566
+ if (next) await this.applyConversation(next)
567
+ else
568
+ await this.startFresh(
569
+ "Discussion 1",
570
+ this.seedPersonas.map((p) => ({ ...p, active: true })),
571
+ )
572
+ } else {
573
+ await this.broadcastConversations()
574
+ }
575
+ }
576
+
577
+ private post(
578
+ author: string,
579
+ authorName: string,
580
+ text: string,
581
+ activity?: ToolActivity[],
582
+ reasoning?: string,
583
+ images?: string[],
584
+ question?: string,
585
+ ): TranscriptEntry {
586
+ const entry: TranscriptEntry = {
587
+ index: this.transcript.length,
588
+ author,
589
+ authorName,
590
+ text,
591
+ ts: Date.now(),
592
+ ...(activity && activity.length > 0 ? { activity } : {}),
593
+ ...(reasoning ? { reasoning } : {}),
594
+ ...(images && images.length > 0 ? { images } : {}),
595
+ ...(question ? { question } : {}),
596
+ }
597
+ this.transcript.push(entry)
598
+
599
+ // Circuit breaker — only for agents, not user
600
+ if (this.circuitBreakerEnabled && author !== "user" && this.checkRepetition(author, text)) {
601
+ this.aborted = true
602
+ this.circuitBreakerAgentId = author
603
+ const msg = `Circuit breaker: @${authorName} repeated similar output ${REPEAT_THRESHOLD} times — stopping.`
604
+ this.notice(msg, "error")
605
+ this.emit("circuit_breaker", { agentId: author, agentName: authorName, count: REPEAT_THRESHOLD })
606
+ }
607
+
608
+ // Tool-call loop breaker — detect repeated identical tool calls
609
+ if (this.circuitBreakerEnabled && author !== "user" && activity && activity.length > 0 && !this.aborted) {
610
+ const result = checkToolLoop(this.transcript, author, activity)
611
+ if (result.tripped) {
612
+ this.aborted = true
613
+ this.circuitBreakerAgentId = author
614
+ const sig = result.signature ?? "unknown"
615
+ const msg = `Circuit breaker: @${authorName} repeated tool call "${sig}" ${result.count} times — stopping.`
616
+ this.notice(msg, "error")
617
+ this.emit("circuit_breaker", { agentId: author, agentName: authorName, count: result.count, type: "tool_loop", signature: sig })
618
+ }
619
+ }
620
+
621
+ this.emit("message", entry)
622
+ return entry
623
+ }
624
+
625
+ /**
626
+ * Check if the current text is a repetition of recent messages from the same author.
627
+ * Scans the last LOOKBACK_WINDOW messages from that author; if ≥ REPEAT_THRESHOLD
628
+ * have similarity ≥ SIMILARITY_FLOOR, returns true.
629
+ */
630
+ private checkRepetition(author: string, text: string): boolean {
631
+ const recent: string[] = []
632
+ for (let i = this.transcript.length - 1; i >= 0; i--) {
633
+ const entry = this.transcript[i]
634
+ if (entry.author !== author) continue
635
+ recent.push(entry.text)
636
+ if (recent.length >= LOOKBACK_WINDOW) break
637
+ }
638
+
639
+ let similarCount = 0
640
+ for (const prev of recent) {
641
+ if (textSimilarity(text, prev) >= SIMILARITY_FLOOR) {
642
+ similarCount++
643
+ }
644
+ }
645
+
646
+ // similarCount includes the current message's match against itself,
647
+ // so we need ≥ REPEAT_THRESHOLD total (the current message + REPEAT_THRESHOLD-1 prior)
648
+ return similarCount >= REPEAT_THRESHOLD
649
+ }
650
+
651
+ private notice(msg: string, level: "info" | "error" = "info"): void {
652
+ this.emit("notice", { msg, level })
653
+ }
654
+
655
+ /** End the current turn — clears runningAgentId and broadcasts turn end. */
656
+ private async endTurn(): Promise<void> {
657
+ this.runningAgentId = null
658
+ // Natural turn completion: if a goal was running, resolve it.
659
+ if (this.goalText !== null && this.goalStatus === "running") {
660
+ if (this.goalMode === "eval") {
661
+ // Don't auto-complete — hand off to the evaluator to verify the goal
662
+ // and drive the dispatch loop. runGoalEval sets the terminal status.
663
+ await this.runGoalEval()
664
+ } else {
665
+ this.goalStatus = "completed"
666
+ this.emit("room", { type: "goal-completed", goalText: this.goalText })
667
+ }
668
+ }
669
+ this.emit("turn", { phase: "end" })
670
+ await this.emitWorkspace()
671
+ }
672
+
673
+ /** Matches the GOAL_MET completion token in any reasonable form. */
674
+ private static readonly GOAL_MET_RE = /\bGOAL[\s_-]?MET\b/i
675
+
676
+ /** Goal-eval loop (eval mode). After the pipeline drains naturally, route to
677
+ * the evaluator agent with a structured prompt. The evaluator verifies the
678
+ * goal independently (using its tools), then either:
679
+ * - declares GOAL_MET → goal completes, loop exits; or
680
+ * - @-mentions an agent → that agent runs (via drainQueue chaining), then
681
+ * the loop re-evaluates.
682
+ * Bounded by maxGoalIterations to guarantee termination. Called from within
683
+ * endTurn(); it drives drainQueue() directly and never re-enters endTurn(),
684
+ * so there is no recursion. */
685
+ private async runGoalEval(): Promise<void> {
686
+ const evaluator = this.registry.get(this.goalEvaluator)
687
+ if (!evaluator || !evaluator.active) {
688
+ // No evaluator available — fall back to auto-completion rather than hang
689
+ // the goal in "running" forever.
690
+ this.notice(`Goal eval: evaluator @${this.goalEvaluator} not available — completing goal without verification.`, "info")
691
+ this.goalStatus = "completed"
692
+ this.emit("room", { type: "goal-completed", goalText: this.goalText })
693
+ this.fallbackAgentId = this.goalEvalSavedFallback
694
+ this.runningAgentId = null
695
+ return
696
+ }
697
+
698
+ // Fallback routing is already suppressed (set null in submitGoal for the
699
+ // whole eval-mode run). The finally restores the original fallback agent and
700
+ // re-nulls runningAgentId per endTurn's documented contract.
701
+ try {
702
+ while (this.goalIteration < this.maxGoalIterations) {
703
+ // Cancellation (abortCurrent / stop_room / Stop button) wins over
704
+ // everything: end the goal as "cancelled" without another pass. Checked
705
+ // here (between iterations) and again after the drain below.
706
+ if (this.goalCancelled) {
707
+ this.goalStatus = "cancelled"
708
+ this.emit("room", { type: "goal-cancelled", goalText: this.goalText })
709
+ this.notice(`Goal cancelled on iteration ${this.goalIteration}.`, "info")
710
+ return
711
+ }
712
+ this.goalIteration++
713
+
714
+ // Inject the structured eval context (invisible in the transcript).
715
+ await evaluator.sendCustomMessage(
716
+ {
717
+ customType: "goal_eval",
718
+ content: goalEvalPrompt(this.goalText!, this.goalIteration, this.maxGoalIterations),
719
+ display: false,
720
+ },
721
+ { deliverAs: "nextTurn" },
722
+ )
723
+ this.emit("room", {
724
+ type: "goal-eval",
725
+ goalText: this.goalText,
726
+ iteration: this.goalIteration,
727
+ maxIterations: this.maxGoalIterations,
728
+ })
729
+
730
+ // Run the evaluator and any agents it dispatches via @mention chaining.
731
+ this.queue = [evaluator]
732
+ this.aborted = false
733
+ this.circuitBreakerAgentId = null
734
+ this.chainBudget = 0
735
+ this.runningAgentId = evaluator.persona.id
736
+ this.emit("turn", { phase: "chain", from: null, targets: [evaluator.persona.id] })
737
+ await this.drainQueue()
738
+
739
+ // Cancelled mid-drain — stop now, before reinterpreting the drain as a
740
+ // completion or circuit-breaker failure.
741
+ if (this.goalCancelled) {
742
+ this.goalStatus = "cancelled"
743
+ this.emit("room", { type: "goal-cancelled", goalText: this.goalText })
744
+ this.notice(`Goal cancelled on iteration ${this.goalIteration}.`, "info")
745
+ return
746
+ }
747
+
748
+ // Did the evaluator declare the goal met in its most recent message?
749
+ if (this.evaluatorDeclaredGoalMet(evaluator.persona.id)) {
750
+ this.goalStatus = "completed"
751
+ this.emit("room", { type: "goal-completed", goalText: this.goalText })
752
+ return
753
+ }
754
+
755
+ // Circuit breaker tripped during this eval pass — give up.
756
+ if (this.aborted) {
757
+ this.goalStatus = "failed"
758
+ this.emit("room", { type: "goal-failed", goalText: this.goalText, reason: "aborted" })
759
+ this.notice(`Goal eval aborted on iteration ${this.goalIteration} (circuit breaker).`, "error")
760
+ return
761
+ }
762
+ }
763
+
764
+ // Iteration budget exhausted without GOAL_MET.
765
+ this.goalStatus = "failed"
766
+ this.emit("room", { type: "goal-failed", goalText: this.goalText, reason: "max-iterations" })
767
+ this.notice(`Goal eval exhausted after ${this.maxGoalIterations} iterations without GOAL_MET.`, "error")
768
+ } finally {
769
+ this.fallbackAgentId = this.goalEvalSavedFallback
770
+ this.runningAgentId = null
771
+ }
772
+ }
773
+
774
+ /** True if the evaluator's most recent transcript message declares GOAL_MET. */
775
+ private evaluatorDeclaredGoalMet(evaluatorId: string): boolean {
776
+ for (let i = this.transcript.length - 1; i >= 0; i--) {
777
+ const e = this.transcript[i]
778
+ if (e.author === evaluatorId) return Room.GOAL_MET_RE.test(e.text)
779
+ }
780
+ return false
781
+ }
782
+
783
+ /** Steer a running agent mid-turn. Posts a (steered) notice to the transcript
784
+ * for visibility, then queues the message via the agent's session. */
785
+ async steer(targetId: string, text: string): Promise<void> {
786
+ const p = this.registry.get(targetId)
787
+ if (!p) throw new Error(`unknown participant "${targetId}"`)
788
+ // Post a visible record to the transcript.
789
+ this.post("user", "You", `↳ steered @${targetId}: ${text}`)
790
+ await p.steer(text)
791
+ }
792
+
793
+ /** Parse @mentions and resolve to the ordered list of participants to run. */
794
+ private resolveTargets(text: string): Participant[] {
795
+ const mentioned = new Set<string>()
796
+ let m: RegExpExecArray | null
797
+ while ((m = MENTION_RE.exec(text)) !== null) mentioned.add(m[1].toLowerCase())
798
+
799
+ // @all (human-only) fans out to everyone, even alongside other mentions.
800
+ if (mentioned.has("all")) {
801
+ return this.registry.activeParticipants()
802
+ }
803
+
804
+ // No mention → the default agent (or the first active one as fallback).
805
+ if (mentioned.size === 0) {
806
+ const active = this.registry.activeParticipants()
807
+ if (active.length === 0) return []
808
+ const preferred = this.defaultAgentId
809
+ ? active.find((p) => p.persona.id === this.defaultAgentId)
810
+ : undefined
811
+ return [preferred ?? active[0]]
812
+ }
813
+
814
+ const targets: Participant[] = []
815
+ for (const id of mentioned) {
816
+ const p = this.registry.get(id)
817
+ if (!p) {
818
+ this.notice(`No participant "@${id}" in the room.`, "error")
819
+ continue
820
+ }
821
+ if (!p.active) {
822
+ this.notice(`@${id} is deactivated — skipping.`, "info")
823
+ continue
824
+ }
825
+ targets.push(p)
826
+ }
827
+ return targets
828
+ }
829
+
830
+ /** Resolve @mentions emitted BY an agent. No @all (human-only), never the
831
+ * speaker itself, active participants only. Scans the full reply text.
832
+ * No budget / anti-rebound for now. */
833
+ private resolveAgentMentions(text: string, selfId: string): Participant[] {
834
+ const mentioned = new Set<string>()
835
+ let m: RegExpExecArray | null
836
+ while ((m = MENTION_RE.exec(text)) !== null) mentioned.add(m[1].toLowerCase())
837
+ mentioned.delete("all") // agents cannot fan out to everyone
838
+ mentioned.delete(selfId) // no self-invocation
839
+
840
+ const out: Participant[] = []
841
+ for (const id of mentioned) {
842
+ const p = this.registry.get(id)
843
+ if (p && p.active) out.push(p)
844
+ }
845
+ return out
846
+ }
847
+
848
+ /** Build the prompt for a participant: the room lines it hasn't seen yet
849
+ * (excluding its own past messages, which live in its session memory).
850
+ * Also collects images from the last user message for vision support. */
851
+ private buildContext(p: Participant): { text: string; images?: string[] } {
852
+ const unseen = this.transcript
853
+ .slice(p.cursor)
854
+ .filter((e) => e.author !== p.persona.id)
855
+ const lines = unseen.map((e) => `${e.authorName}: ${e.text}`).join("\n\n")
856
+
857
+ // Collect images from the last user message in the unseen range.
858
+ // These are the images the user attached to their most recent message.
859
+ const userEntry = [...unseen].reverse().find((e) => e.author === "user")
860
+ const images = userEntry?.images
861
+
862
+ return {
863
+ text: `${lines}\n\n---\nYou are ${p.persona.name}. Respond to the conversation above from your perspective now.`,
864
+ images,
865
+ }
866
+ }
867
+
868
+ /** Public entry point. Enqueues the message; processing streams over SSE. */
869
+ submit(text: string, images?: string[]): void {
870
+ this.chain = this.chain.then(() => this.process(text, images)).catch((err) => {
871
+ this.notice(`Room error: ${err instanceof Error ? err.message : String(err)}`, "error")
872
+ })
873
+ }
874
+
875
+ private async process(text: string, images?: string[]): Promise<void> {
876
+ const trimmed = text.trim()
877
+ this.chainBudget = 0 // reset chain budget for this turn
878
+
879
+ // Handle /cancel while paused — cancel the question and drain the held queue.
880
+ if (this.pendingQuestion && trimmed === "/cancel") {
881
+ const held = this.pendingQuestion.heldQueue
882
+ this.pendingQuestion = null
883
+ this.post("user", "You", trimmed)
884
+ this.notice("Question cancelled. Resuming pipeline.")
885
+ this.queue = held
886
+ this.aborted = false
887
+ const paused = await this.drainQueue()
888
+ if (paused) {
889
+ await this.emitWorkspace()
890
+ await this.saveCurrent()
891
+ return
892
+ }
893
+ this.queue = []
894
+ await this.endTurn()
895
+ await this.saveCurrent()
896
+ return
897
+ }
898
+
899
+ if (await this.handleSlashCommand(trimmed)) return
900
+
901
+ // ── Resume from paused state ──────────────────────────────────────────
902
+ if (this.pendingQuestion) {
903
+ const pq = this.pendingQuestion
904
+ this.pendingQuestion = null
905
+
906
+ this.post("user", "You", trimmed, undefined, undefined, images)
907
+ this.emit("turn", { phase: "resume", askerId: pq.askerId })
908
+ this.aborted = false
909
+
910
+ // Force-route to the agent that asked the question.
911
+ const asker = this.registry.get(pq.askerId)
912
+ if (!asker || !asker.active) {
913
+ this.notice(`@${pq.askerId} is no longer active — resuming held queue.`, "info")
914
+ this.queue = pq.heldQueue
915
+ const paused = await this.drainQueue()
916
+ if (paused) {
917
+ await this.emitWorkspace()
918
+ await this.saveCurrent()
919
+ return
920
+ }
921
+ this.queue = []
922
+ await this.endTurn()
923
+ await this.saveCurrent()
924
+ return
925
+ }
926
+
927
+ // Use followUp() instead of runAgent() — the user's answer is delivered
928
+ // directly to the agent that asked the question. The agent already has the
929
+ // conversation context in its session memory; followUp() guarantees it's
930
+ // the next thing the agent processes.
931
+ const result = await this.followUpAgent(asker, { text: trimmed, images })
932
+ if (result && !this.aborted) {
933
+ // Post with question field if the asker asked another question.
934
+ this.post(asker.persona.id, asker.persona.name, result.reply || "(no response)", result.activity, result.reasoning, undefined, result.question)
935
+ if (receiptHasChanges(result.receipt)) this.emit("receipt", result.receipt)
936
+ asker.cursor = this.transcript.length
937
+
938
+ // If the asker asked ANOTHER question, re-pause.
939
+ if (result.question) {
940
+ this.pendingQuestion = { askerId: asker.persona.id, heldQueue: pq.heldQueue }
941
+ this.emit("turn", { phase: "pause", askerId: asker.persona.id, question: result.question })
942
+ await this.emitWorkspace()
943
+ await this.saveCurrent()
944
+ return
945
+ }
946
+
947
+ // Chain from the asker's reply onto the held queue (it resumes draining
948
+ // below). Routes identically to the main drain loop — a handoff made right
949
+ // after answering a question now continues instead of being dropped.
950
+ if (this.chaining) {
951
+ const proposed = await this.proposeChain(asker.persona.id, result.reply, pq.heldQueue)
952
+ if (proposed.length > 0) {
953
+ // semi/manual: pause for approval instead of continuing the drain.
954
+ this.pendingRoute = {
955
+ proposals: proposed.map((t) => ({ fromId: asker.persona.id, target: t })),
956
+ heldQueue: pq.heldQueue,
957
+ }
958
+ this.emitRoutingProposed()
959
+ await this.emitWorkspace()
960
+ await this.saveCurrent()
961
+ return
962
+ }
963
+ }
964
+ }
965
+
966
+ // Restore the held queue and continue draining.
967
+ this.queue = pq.heldQueue
968
+ const paused = await this.drainQueue()
969
+ if (paused) {
970
+ await this.emitWorkspace()
971
+ await this.saveCurrent()
972
+ return
973
+ }
974
+ this.queue = []
975
+ await this.endTurn()
976
+ await this.saveCurrent()
977
+ return
978
+ }
979
+
980
+ // ── Normal (non-paused) flow ──────────────────────────────────────────
981
+ this.post("user", "You", trimmed, undefined, undefined, images)
982
+
983
+ const initial = this.resolveTargets(trimmed)
984
+ if (initial.length === 0) {
985
+ this.notice("No active participants to route to.", "info")
986
+ return
987
+ }
988
+
989
+ this.queue = [...initial]
990
+ this.aborted = false
991
+ this.circuitBreakerAgentId = null
992
+ this.runningAgentId = initial[0]?.persona.id ?? null
993
+ this.emit("turn", { phase: "start", targets: initial.map((t) => t.persona.id), agentId: this.runningAgentId })
994
+
995
+ // Drain the queue — shared method handles questions, chaining, and parallel waves.
996
+ const paused = await this.drainQueue()
997
+ if (paused) {
998
+ await this.emitWorkspace()
999
+ await this.saveCurrent()
1000
+ return
1001
+ }
1002
+
1003
+ // Circuit breaker recovery: if the breaker tripped and a fallback agent
1004
+ // is configured (and is not the looping agent), route to it instead of
1005
+ // silently dying. Loop up to MAX_RECOVERY_DEPTH times to handle cases where
1006
+ // the looping agent re-enters after the fallback hands back to it.
1007
+ const MAX_RECOVERY_DEPTH = 2
1008
+ let recoveryDepth = 0
1009
+ while (
1010
+ this.aborted &&
1011
+ this.circuitBreakerAgentId &&
1012
+ this.fallbackAgentId &&
1013
+ this.circuitBreakerAgentId !== this.fallbackAgentId &&
1014
+ recoveryDepth < MAX_RECOVERY_DEPTH
1015
+ ) {
1016
+ recoveryDepth++
1017
+ const fb = this.registry.get(this.fallbackAgentId)
1018
+ if (!fb || !fb.active) break
1019
+ const depthNote = MAX_RECOVERY_DEPTH > 1 ? ` (recovery ${recoveryDepth}/${MAX_RECOVERY_DEPTH})` : ""
1020
+ this.notice(`Circuit breaker tripped on @${this.circuitBreakerAgentId} — routing to @${this.fallbackAgentId} for recovery${depthNote}.`, "info")
1021
+ this.emit("turn", { phase: "chain", from: this.circuitBreakerAgentId, targets: [this.fallbackAgentId] })
1022
+ // Inject recovery context so the fallback agent knows why it's being called.
1023
+ await fb.sendCustomMessage(
1024
+ {
1025
+ customType: "circuit_breaker_recovery",
1026
+ content: `(Circuit breaker tripped on @${this.circuitBreakerAgentId} — it looped. Take over: assess the situation, assign work to another agent by @-mentioning them, or declare the work done if appropriate. Do not attempt the same action that caused the loop.)`,
1027
+ display: false,
1028
+ },
1029
+ { deliverAs: "nextTurn" },
1030
+ )
1031
+ // Reset abort state and resume draining with the fallback agent.
1032
+ this.aborted = false
1033
+ this.circuitBreakerAgentId = null
1034
+ this.queue = [fb]
1035
+ this.runningAgentId = fb.persona.id
1036
+ const recovered = await this.drainQueue()
1037
+ if (recovered) {
1038
+ await this.emitWorkspace()
1039
+ await this.saveCurrent()
1040
+ return
1041
+ }
1042
+ // If drainQueue exited without pause, loop to check if another circuit breaker fired.
1043
+ }
1044
+
1045
+ // Goal terminated by abort without recovery. Set before endTurn so its
1046
+ // completion guard doesn't overwrite the status. A user/planner cancel
1047
+ // (goalCancelled) resolves to "cancelled"; a circuit-breaker abort to
1048
+ // "failed".
1049
+ if (this.aborted && this.goalText !== null && this.goalStatus === "running") {
1050
+ if (this.goalCancelled) {
1051
+ this.goalStatus = "cancelled"
1052
+ this.emit("room", { type: "goal-cancelled", goalText: this.goalText })
1053
+ } else {
1054
+ this.goalStatus = "failed"
1055
+ this.emit("room", { type: "goal-failed", goalText: this.goalText })
1056
+ }
1057
+ // Aborting during the INITIAL drain of an eval-mode goal means runGoalEval
1058
+ // never ran, so its finally never restored the fallback agent that
1059
+ // submitGoal suppressed. Restore it here so the room isn't left with
1060
+ // fallback routing silently disabled.
1061
+ if (this.goalMode === "eval") this.fallbackAgentId = this.goalEvalSavedFallback
1062
+ }
1063
+ this.queue = []
1064
+ await this.endTurn()
1065
+ await this.saveCurrent()
1066
+ }
1067
+
1068
+ /** Pull the next group off the queue: a contiguous run of parallel-flagged
1069
+ * agents (a concurrent wave), or a single non-parallel agent (serial). */
1070
+ private nextGroup(): Participant[] {
1071
+ const first = this.queue.shift()!
1072
+ const group = [first]
1073
+ if (first.parallel) {
1074
+ while (this.queue.length > 0 && this.queue[0].parallel) group.push(this.queue.shift()!)
1075
+ }
1076
+ return group
1077
+ }
1078
+
1079
+ /** The concurrency lane an agent runs on. Same-lane agents are serialized;
1080
+ * different lanes run concurrently. All local models share one lane because
1081
+ * llama-server runs --parallel 1; each cloud provider is its own lane. */
1082
+ private laneOf(p: Participant): string {
1083
+ const provider = p.persona.model ? p.persona.model.split("/")[0] : "local"
1084
+ return provider === "local" ? "local" : `cloud:${provider}`
1085
+ }
1086
+
1087
+ /** Run a group concurrently. All members see the same pre-wave transcript.
1088
+ * Per-lane serialization keeps local agents one-at-a-time; cloud agents on
1089
+ * distinct endpoints run truly in parallel. Results come back in group order. */
1090
+ private async runWave(group: Participant[]): Promise<Array<RunOutput | null>> {
1091
+ const contexts = new Map(group.map((p) => [p, this.buildContext(p)]))
1092
+ const laneTail = new Map<string, Promise<unknown>>()
1093
+
1094
+ return Promise.all(
1095
+ group.map((p) => {
1096
+ const ctx = contexts.get(p) ?? { text: "" }
1097
+ const task = () => this.runAgent(p, ctx)
1098
+ const lane = this.laneOf(p)
1099
+ // Local lane is single-slot: chain tasks so they never overlap.
1100
+ if (lane === "local") {
1101
+ const prev = laneTail.get(lane) ?? Promise.resolve()
1102
+ const result = prev.then(task)
1103
+ laneTail.set(lane, result.catch(() => {}))
1104
+ return result
1105
+ }
1106
+ // Cloud lanes: independent endpoints, run immediately/concurrently.
1107
+ return task()
1108
+ }),
1109
+ )
1110
+ }
1111
+
1112
+ /** Execute one agent end to end: snapshot, prompt/followUp, snapshot, diff.
1113
+ * Does NOT post — the caller posts results in group order to keep the
1114
+ * transcript deterministic. */
1115
+ private async executeAgent(
1116
+ target: Participant,
1117
+ context: { text: string; images?: string[] },
1118
+ mode: "prompt" | "followUp",
1119
+ ): Promise<RunOutput | null> {
1120
+ // Remote rooms skip the full-tree diff (too slow over sshfs) — the receipt is
1121
+ // rebuilt from the agent's write/edit tool calls below instead.
1122
+ const before = this.remote ? undefined : await snapshot(this.workspaceDir)
1123
+ this.running.add(target)
1124
+ // Acquire the local-model lock only for local agents (cloud agents bypass).
1125
+ const isLocal = this.laneOf(target) === "local"
1126
+ let lockAcquired = false
1127
+ try {
1128
+ if (isLocal && this.localLock) {
1129
+ await this.localLock.acquire()
1130
+ lockAcquired = true
1131
+ }
1132
+ const result = mode === "prompt"
1133
+ ? await target.run(context.text, context.images)
1134
+ : await target.followUp(context.text, context.images)
1135
+ if (this.aborted) return null
1136
+ const after = this.remote ? undefined : await snapshot(this.workspaceDir)
1137
+
1138
+ // Broadcast context usage and session stats after the turn — piggyback on status event.
1139
+ // The idle status already fires from Participant.run() finally block;
1140
+ // this second broadcast adds contextUsage and sessionStats to the payload.
1141
+ const usage = target.getContextUsage?.()
1142
+ const stats = target.getSessionStats?.()
1143
+ if (usage || stats) {
1144
+ const payload: Record<string, unknown> = { id: target.persona.id, status: "idle" }
1145
+ if (usage) payload.contextUsage = usage
1146
+ if (stats) payload.sessionStats = stats
1147
+ this.emit("status", payload)
1148
+ }
1149
+
1150
+ return {
1151
+ target,
1152
+ reply: result.text,
1153
+ activity: result.activity,
1154
+ reasoning: result.reasoning,
1155
+ question: result.question,
1156
+ receipt: before && after
1157
+ ? diffSnapshots(before, after, target.persona.id)
1158
+ : receiptFromActivity(result.activity, target.persona.id),
1159
+ }
1160
+ } catch (err) {
1161
+ this.notice(
1162
+ `@${target.persona.id} failed: ${err instanceof Error ? err.message : String(err)}`,
1163
+ "error",
1164
+ )
1165
+ target.cursor = this.transcript.length
1166
+ return null
1167
+ } finally {
1168
+ if (lockAcquired) this.localLock?.release()
1169
+ this.running.delete(target)
1170
+ }
1171
+ }
1172
+
1173
+ /** Run one agent via prompt. Thin wrapper around executeAgent. */
1174
+ private runAgent(
1175
+ target: Participant,
1176
+ context: { text: string; images?: string[] },
1177
+ ): Promise<RunOutput | null> {
1178
+ return this.executeAgent(target, context, "prompt")
1179
+ }
1180
+
1181
+ /** Follow-up one agent via session.followUp(). Thin wrapper around executeAgent.
1182
+ * Used for self-chaining (ask_user resume) — guaranteed to be the next thing
1183
+ * the agent processes. */
1184
+ private followUpAgent(
1185
+ target: Participant,
1186
+ context: { text: string; images?: string[] },
1187
+ ): Promise<RunOutput | null> {
1188
+ return this.executeAgent(target, context, "followUp")
1189
+ }
1190
+
1191
+ /** Handle slash commands. Returns true if handled. */
1192
+ private async handleSlashCommand(text: string): Promise<boolean> {
1193
+ if (!text.startsWith("/")) return false
1194
+ const [cmd, ...args] = text.split(/\s+/)
1195
+ const rawTarget = args[0]
1196
+ const id = rawTarget?.replace(/^@/, "").toLowerCase()
1197
+
1198
+ switch (cmd) {
1199
+ case "/kick":
1200
+ if (id && this.registry.has(id)) {
1201
+ this.registry.kick(id)
1202
+ this.notice(`Kicked @${id}.`)
1203
+ } else this.notice(`/kick: unknown participant "${rawTarget ?? ""}".`, "error")
1204
+ return true
1205
+ case "/deactivate":
1206
+ if (id && this.registry.has(id)) {
1207
+ this.registry.setActive(id, false)
1208
+ this.notice(`Deactivated @${id}.`)
1209
+ } else this.notice(`/deactivate: unknown participant "${rawTarget ?? ""}".`, "error")
1210
+ return true
1211
+ case "/activate":
1212
+ if (id && this.registry.has(id)) {
1213
+ this.registry.setActive(id, true)
1214
+ this.notice(`Activated @${id}.`)
1215
+ } else this.notice(`/activate: unknown participant "${rawTarget ?? ""}".`, "error")
1216
+ return true
1217
+ case "/compact": {
1218
+ if (!id) {
1219
+ this.notice(`/compact: usage — /compact @agent`, "error")
1220
+ return true
1221
+ }
1222
+ const target = this.registry.get(id)
1223
+ if (!target) {
1224
+ this.notice(`/compact: unknown participant "${rawTarget ?? ""}".`, "error")
1225
+ return true
1226
+ }
1227
+ if (this.isBusy()) {
1228
+ this.notice(`/compact: wait until the current turn finishes.`, "error")
1229
+ return true
1230
+ }
1231
+ this.notice(`Compacting @${id}'s context…`)
1232
+ try {
1233
+ const result = await target.compact()
1234
+ this.notice(`@${id} compacted: ${result.tokensBefore} tokens before → summary generated.`)
1235
+ } catch (err) {
1236
+ this.notice(`/compact @${id} failed: ${err instanceof Error ? err.message : String(err)}`, "error")
1237
+ }
1238
+ return true
1239
+ }
1240
+ case "/help":
1241
+ this.notice(
1242
+ "Commands: /help, /kick @agent, /activate @agent, /deactivate @agent, " +
1243
+ "/compact @agent, /model @agent provider/id, /thinking [level|@agent level], " +
1244
+ "/stats [@agent], /chaining on|off, /default @agent|none, /fallback @agent|none, " +
1245
+ "/provider [list|add <name> <key>|remove <name>]"
1246
+ )
1247
+ return true
1248
+ case "/model": {
1249
+ const agentId = args[0]?.replace(/^@/, "").toLowerCase()
1250
+ const modelRef = args[1]
1251
+ if (!agentId || !modelRef) {
1252
+ this.notice("/model: usage — /model @agent provider/id", "error")
1253
+ return true
1254
+ }
1255
+ if (!this.registry.has(agentId)) {
1256
+ this.notice(`/model: unknown participant "@${agentId}".`, "error")
1257
+ return true
1258
+ }
1259
+ if (!this.registry.isAllowedModel(modelRef)) {
1260
+ this.notice(`/model: "${modelRef}" is not available. Use GET /api/models to list.`, "error")
1261
+ return true
1262
+ }
1263
+ try {
1264
+ await this.registry.update(agentId, { model: modelRef })
1265
+ this.notice(`@${agentId} model → ${modelRef}`)
1266
+ } catch (err) {
1267
+ this.notice(`/model @${agentId} failed: ${err instanceof Error ? err.message : String(err)}`, "error")
1268
+ }
1269
+ return true
1270
+ }
1271
+ case "/thinking": {
1272
+ const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const
1273
+ if (args[0]?.startsWith("@")) {
1274
+ // Per-agent: /thinking @agent level
1275
+ const agentId = args[0].replace(/^@/, "").toLowerCase()
1276
+ const level = args[1]
1277
+ if (!level || !(LEVELS as readonly string[]).includes(level)) {
1278
+ this.notice(`/thinking: usage — /thinking @agent ${LEVELS.join("|")}`, "error")
1279
+ return true
1280
+ }
1281
+ const p = this.registry.get(agentId)
1282
+ if (!p) {
1283
+ this.notice(`/thinking: unknown participant "@${agentId}".`, "error")
1284
+ return true
1285
+ }
1286
+ const available = p.getAvailableThinkingLevels?.()
1287
+ if (available && available.length > 0 && !available.includes(level)) {
1288
+ this.notice(`/thinking: "${level}" not available for @${agentId}. Available: ${available.join(", ")}`, "error")
1289
+ return true
1290
+ }
1291
+ try {
1292
+ await this.registry.setThinkingLevel(agentId, level as typeof LEVELS[number])
1293
+ this.notice(`@${agentId} thinking → ${level}`)
1294
+ } catch (err) {
1295
+ this.notice(`/thinking @${agentId} failed: ${err instanceof Error ? err.message : String(err)}`, "error")
1296
+ }
1297
+ } else {
1298
+ // Global: /thinking level
1299
+ const level = args[0]
1300
+ if (!level || !(LEVELS as readonly string[]).includes(level)) {
1301
+ this.notice(`/thinking: usage — /thinking ${LEVELS.join("|")}`, "error")
1302
+ return true
1303
+ }
1304
+ config.thinkingLevel = level as typeof LEVELS[number]
1305
+ this.notice(`Global thinking → ${level}`)
1306
+ }
1307
+ return true
1308
+ }
1309
+ case "/stats": {
1310
+ if (id) {
1311
+ // Per-agent stats
1312
+ const p = this.registry.get(id)
1313
+ if (!p) {
1314
+ this.notice(`/stats: unknown participant "@${id}".`, "error")
1315
+ return true
1316
+ }
1317
+ const stats = p.getSessionStats?.()
1318
+ const ctx = p.getContextUsage?.()
1319
+ const parts: string[] = [`@${id}:`]
1320
+ if (stats) {
1321
+ const { input, output, cacheRead, total } = stats.tokens
1322
+ const cachePct = total > 0 ? Math.round((cacheRead / total) * 100) : 0
1323
+ parts.push(`${input}i / ${output}o · cache ${cachePct}% · ${stats.toolCalls} tools · ${stats.userMessages + stats.assistantMessages} msgs`)
1324
+ }
1325
+ if (ctx) {
1326
+ parts.push(`context: ${ctx.tokens ?? "?"}/${ctx.contextWindow} (${ctx.percent ?? "?"}%)`)
1327
+ }
1328
+ if (!stats && !ctx) parts.push("no stats yet")
1329
+ this.notice(parts.join(" · "))
1330
+ } else {
1331
+ // All agents summary
1332
+ for (const item of this.registry.roster()) {
1333
+ const p = this.registry.get(item.id)
1334
+ if (!p) continue
1335
+ const stats = p.getSessionStats?.()
1336
+ const ctx = p.getContextUsage?.()
1337
+ const parts: string[] = [`@${item.id}`]
1338
+ if (stats) {
1339
+ const { input, output, cacheRead, total } = stats.tokens
1340
+ const cachePct = total > 0 ? Math.round((cacheRead / total) * 100) : 0
1341
+ parts.push(`${input}i / ${output}o · cache ${cachePct}% · ${stats.toolCalls} tools`)
1342
+ }
1343
+ if (ctx) {
1344
+ parts.push(`ctx ${ctx.percent ?? "?"}%`)
1345
+ }
1346
+ if (!stats && !ctx) parts.push("no stats yet")
1347
+ this.notice(parts.join(" · "))
1348
+ }
1349
+ }
1350
+ return true
1351
+ }
1352
+ case "/chaining": {
1353
+ const val = args[0]?.toLowerCase()
1354
+ if (val === "on") {
1355
+ this.setChaining(true)
1356
+ this.notice("Chaining → on")
1357
+ } else if (val === "off") {
1358
+ this.setChaining(false)
1359
+ this.notice("Chaining → off")
1360
+ } else {
1361
+ this.notice("/chaining: usage — /chaining on|off", "error")
1362
+ }
1363
+ return true
1364
+ }
1365
+ case "/default": {
1366
+ if (!rawTarget || rawTarget.toLowerCase() === "none") {
1367
+ this.setDefaultAgent(null)
1368
+ this.notice("Default agent → none (first active)")
1369
+ } else {
1370
+ const agentId = rawTarget.replace(/^@/, "").toLowerCase()
1371
+ try {
1372
+ this.setDefaultAgent(agentId)
1373
+ this.notice(`Default agent → @${agentId}`)
1374
+ } catch (err) {
1375
+ this.notice(`/default: ${err instanceof Error ? err.message : String(err)}`, "error")
1376
+ }
1377
+ }
1378
+ return true
1379
+ }
1380
+ case "/fallback": {
1381
+ if (!rawTarget || rawTarget.toLowerCase() === "none") {
1382
+ this.setFallbackAgent(null)
1383
+ this.notice("Fallback routing → disabled")
1384
+ } else {
1385
+ const agentId = rawTarget.replace(/^@/, "").toLowerCase()
1386
+ try {
1387
+ this.setFallbackAgent(agentId)
1388
+ this.notice(`Fallback routing → @${agentId}`)
1389
+ } catch (err) {
1390
+ this.notice(`/fallback: ${err instanceof Error ? err.message : String(err)}`, "error")
1391
+ }
1392
+ }
1393
+ return true
1394
+ }
1395
+ case "/provider": {
1396
+ const subCmd = args[0]?.toLowerCase()
1397
+ if (subCmd === "list" || !subCmd) {
1398
+ // List providers
1399
+ const providers = this.registry.getProviderList()
1400
+ const lines = providers.map((p) =>
1401
+ `${p.configured ? "✓" : "○"} ${p.displayName} (${p.models} models)`,
1402
+ )
1403
+ this.notice(`Providers:\n${lines.join("\n")}`)
1404
+ } else if (subCmd === "add") {
1405
+ const providerName = args[1]
1406
+ const apiKey = args[2]
1407
+ if (!providerName || !apiKey) {
1408
+ this.notice("/provider add: usage — /provider add <name> <api_key>", "error")
1409
+ } else {
1410
+ try {
1411
+ this.registry.setProviderKey(providerName, apiKey)
1412
+ this.notice(`Provider "${providerName}" configured. Models should now be available.`)
1413
+ } catch (err) {
1414
+ this.notice(`/provider add failed: ${err instanceof Error ? err.message : String(err)}`, "error")
1415
+ }
1416
+ }
1417
+ } else if (subCmd === "remove") {
1418
+ const providerName = args[1]
1419
+ if (!providerName) {
1420
+ this.notice("/provider remove: usage — /provider remove <name>", "error")
1421
+ } else {
1422
+ try {
1423
+ this.registry.removeProviderKey(providerName)
1424
+ this.notice(`Provider "${providerName}" removed.`)
1425
+ } catch (err) {
1426
+ this.notice(`/provider remove failed: ${err instanceof Error ? err.message : String(err)}`, "error")
1427
+ }
1428
+ }
1429
+ } else {
1430
+ this.notice(`/provider: unknown sub-command "${subCmd}". Use: list, add <name> <key>, remove <name>`, "error")
1431
+ }
1432
+ return true
1433
+ }
1434
+ default:
1435
+ this.notice(`Unknown command "${cmd}".`, "error")
1436
+ return true
1437
+ }
1438
+ }
1439
+
1440
+ /** Shared drain loop — replaces the 4 duplicated inline loops.
1441
+ * Returns true if the pipeline was paused by an ask_user (caller should return).
1442
+ * Handles parallel-wave questions correctly: posts ALL results from the wave
1443
+ * before pausing on the first question, so no agent output is silently dropped. */
1444
+ /** Decide who runs next from `fromId`'s reply and append them onto `target`.
1445
+ * Honors the chain-hop budget; when no @mention is found and a fallback agent
1446
+ * is configured, routes there with a nudge to pick the next agent. Shared by
1447
+ * the main drain loop and the ask-user resume path so routing is identical in
1448
+ * both — the resume path previously pushed @mentions onto a queue it then
1449
+ * discarded, silently dropping a handoff made right after answering a question. */
1450
+ private async proposeChain(fromId: string, reply: string, target: Participant[]): Promise<Participant[]> {
1451
+ const mentioned = this.resolveAgentMentions(reply, fromId)
1452
+ if (mentioned.length > 0) {
1453
+ // De-dupe against the pending queue: if e.g. scout AND builder both hand
1454
+ // off to @planner in the same pass, enqueue the planner once instead of
1455
+ // running it back-to-back (the loop kept returning to it 2-3× in a row).
1456
+ const next = mentioned.filter((p) => !target.includes(p))
1457
+ if (next.length === 0) return []
1458
+ if (this.routingMode !== "auto") {
1459
+ // semi/manual: hand these back for human approval. Don't enqueue or spend
1460
+ // hop budget yet — that happens when the human approves.
1461
+ return next
1462
+ }
1463
+ if (this.chainBudget < this.maxChainHops) {
1464
+ this.chainBudget += next.length
1465
+ target.push(...next)
1466
+ this.emit("turn", { phase: "chain", from: fromId, targets: next.map((t) => t.persona.id) })
1467
+ } else {
1468
+ this.notice(`Chain budget exhausted (${this.maxChainHops} hops) — stopping.`, "info")
1469
+ }
1470
+ return []
1471
+ }
1472
+ if (
1473
+ this.fallbackAgentId &&
1474
+ fromId !== this.fallbackAgentId &&
1475
+ this.chainBudget < this.maxChainHops
1476
+ ) {
1477
+ const fb = this.registry.get(this.fallbackAgentId)
1478
+ if (fb && fb.active && !target.includes(fb)) {
1479
+ this.chainBudget += 1
1480
+ target.push(fb)
1481
+ this.notice(`No handoff detected — routing to @${this.fallbackAgentId}`, "info")
1482
+ this.emit("turn", { phase: "chain", from: fromId, targets: [this.fallbackAgentId] })
1483
+ // Inject routing context so the fallback agent knows why it's being called.
1484
+ await fb.sendCustomMessage(
1485
+ {
1486
+ customType: "routing_fallback",
1487
+ content: `(Routing fallback: @${fromId} finished without handing off. Based on the conversation state, decide who should go next — @-mention them in your reply. If the work is complete, say so without mentioning anyone.)`,
1488
+ display: false,
1489
+ },
1490
+ { deliverAs: "nextTurn" },
1491
+ )
1492
+ }
1493
+ }
1494
+ return []
1495
+ }
1496
+
1497
+ /** Broadcast the current routing proposal so the UI can render the approval card. */
1498
+ private emitRoutingProposed(): void {
1499
+ if (!this.pendingRoute) return
1500
+ this.emit("routing", {
1501
+ type: "proposed",
1502
+ proposals: this.pendingRoute.proposals.map((p) => ({
1503
+ from: p.fromId,
1504
+ target: p.target.persona.id,
1505
+ targetName: p.target.persona.name,
1506
+ })),
1507
+ })
1508
+ }
1509
+
1510
+ /** Serializable snapshot of a pending routing proposal (for state bootstrap),
1511
+ * or null when nothing is awaiting approval. */
1512
+ getPendingRoute(): { proposals: Array<{ from: string; target: string; targetName: string }> } | null {
1513
+ if (!this.pendingRoute) return null
1514
+ return {
1515
+ proposals: this.pendingRoute.proposals.map((p) => ({
1516
+ from: p.fromId,
1517
+ target: p.target.persona.id,
1518
+ targetName: p.target.persona.name,
1519
+ })),
1520
+ }
1521
+ }
1522
+
1523
+ /** Apply the human's decision on a pending routing proposal (semi/manual).
1524
+ * Serialized onto the room's turn chain so it can't race a running turn. */
1525
+ resolveRoute(decision: RouteDecision): void {
1526
+ this.chain = this.chain.then(() => this.processRouteDecision(decision)).catch((err) => {
1527
+ this.notice(`Room error: ${err instanceof Error ? err.message : String(err)}`, "error")
1528
+ })
1529
+ }
1530
+
1531
+ private async processRouteDecision(decision: RouteDecision): Promise<void> {
1532
+ const pr = this.pendingRoute
1533
+ if (!pr) return // nothing pending (already resolved, or raced an abort)
1534
+ this.pendingRoute = null
1535
+ this.aborted = false
1536
+
1537
+ let toRun: Participant[] = []
1538
+ if (decision.action === "approve") {
1539
+ toRun = pr.proposals.map((p) => p.target)
1540
+ } else if (decision.action === "redirect") {
1541
+ toRun = (decision.targetIds ?? [])
1542
+ .map((id) => this.registry.get(id))
1543
+ .filter((p): p is Participant => !!p && p.active)
1544
+ }
1545
+ // "drop" → toRun stays empty: continue with whatever work was already held.
1546
+
1547
+ const fresh = toRun.filter((p) => !pr.heldQueue.includes(p))
1548
+ if (fresh.length > 0) {
1549
+ this.chainBudget += fresh.length
1550
+ pr.heldQueue.push(...fresh)
1551
+ this.emit("turn", { phase: "chain", from: null, targets: fresh.map((t) => t.persona.id) })
1552
+ }
1553
+ this.emit("routing", { type: "resolved", action: decision.action, targets: fresh.map((t) => t.persona.id) })
1554
+
1555
+ this.queue = pr.heldQueue
1556
+ const paused = await this.drainQueue()
1557
+ if (paused) {
1558
+ await this.emitWorkspace()
1559
+ await this.saveCurrent()
1560
+ return
1561
+ }
1562
+ this.queue = []
1563
+ await this.endTurn()
1564
+ await this.saveCurrent()
1565
+ }
1566
+
1567
+ private async drainQueue(): Promise<boolean> {
1568
+ while (this.queue.length > 0 && !this.aborted) {
1569
+ const group = this.nextGroup()
1570
+ if (group.length > 1) {
1571
+ this.notice(`running ${group.length} in parallel: ${group.map((g) => `@${g.persona.id}`).join(" ")}`)
1572
+ this.emit("turn", { phase: "parallel", targets: group.map((g) => g.persona.id) })
1573
+ }
1574
+
1575
+ const results = await this.runWave(group)
1576
+
1577
+ // Collect which results have questions (we pause on the first one,
1578
+ // but still post ALL results from this wave to avoid data loss).
1579
+ let paused = false
1580
+ let pauseAskerId: string | null = null
1581
+ let pauseQuestion: string | null = null
1582
+ const waveProposals: RouteProposal[] = []
1583
+
1584
+ for (const out of results) {
1585
+ if (!out || this.aborted) continue
1586
+
1587
+ if (out.question && !paused) {
1588
+ // First question in this wave — remember it, but don't return yet.
1589
+ paused = true
1590
+ pauseAskerId = out.target.persona.id
1591
+ pauseQuestion = out.question
1592
+ }
1593
+
1594
+ // Post the result (with question field if applicable).
1595
+ this.post(out.target.persona.id, out.target.persona.name, out.reply || "(no response)", out.activity, out.reasoning, undefined, out.question)
1596
+ if (receiptHasChanges(out.receipt)) this.emit("receipt", out.receipt)
1597
+ out.target.cursor = this.transcript.length
1598
+
1599
+ // Chain from this reply (even if it had a question — the question is
1600
+ // posted as part of the message, so @mentions in the text still chain).
1601
+ if (this.chaining) {
1602
+ const proposed = await this.proposeChain(out.target.persona.id, out.reply, this.queue)
1603
+ for (const t of proposed) {
1604
+ if (!waveProposals.some((wp) => wp.target === t)) {
1605
+ waveProposals.push({ fromId: out.target.persona.id, target: t })
1606
+ }
1607
+ }
1608
+ }
1609
+
1610
+ // Inject work receipt into the next agent in queue (if there is one and there are changes).
1611
+ if (receiptHasChanges(out.receipt) && this.queue.length > 0) {
1612
+ const nextTarget = this.queue[0]
1613
+ await nextTarget.sendCustomMessage(
1614
+ { customType: "work_receipt", content: formatReceipt(out.receipt), display: false },
1615
+ { deliverAs: "nextTurn" },
1616
+ )
1617
+ }
1618
+ }
1619
+
1620
+ // If we encountered a question in this wave, pause now (after all wave results are posted).
1621
+ if (paused) {
1622
+ this.pendingQuestion = { askerId: pauseAskerId!, heldQueue: [...this.queue] }
1623
+ this.queue = []
1624
+ this.emit("turn", { phase: "pause", askerId: pauseAskerId!, question: pauseQuestion! })
1625
+ return true
1626
+ }
1627
+
1628
+ // semi/manual: if any handoffs were proposed this wave, pause for approval
1629
+ // before running them. The held queue resumes after the human decides.
1630
+ if (waveProposals.length > 0) {
1631
+ this.pendingRoute = { proposals: waveProposals, heldQueue: [...this.queue] }
1632
+ this.queue = []
1633
+ this.emitRoutingProposed()
1634
+ return true
1635
+ }
1636
+ }
1637
+ return false
1638
+ }
1639
+
1640
+ /** Stop everything: clear the pending queue and abort every running agent
1641
+ * (a parallel wave can have several in flight at once). */
1642
+ async abortCurrent(): Promise<boolean> {
1643
+ this.aborted = true
1644
+ // Mark an in-flight goal as cancelled. The goal-eval loop resets `aborted`
1645
+ // each iteration, so a separate sticky flag is what actually makes it stop
1646
+ // (see runGoalEval). submitGoal() clears it for the next goal run.
1647
+ if (this.goalText !== null && this.goalStatus === "running") {
1648
+ this.goalCancelled = true
1649
+ }
1650
+ const had = this.queue.length > 0 || this.running.size > 0 || this.pendingQuestion !== null || this.pendingRoute !== null
1651
+ this.queue = []
1652
+ this.pendingQuestion = null
1653
+ this.pendingRoute = null
1654
+ await Promise.all([...this.running].map((p) => p.abort()))
1655
+ return had
1656
+ }
1657
+ }