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/server.ts ADDED
@@ -0,0 +1,1846 @@
1
+ // Pipeline-MoE backend entry point.
2
+ //
3
+ // REST + SSE over Express. Manages a roster of stateful pi AgentSession
4
+ // instances (one per participant) sharing a workspace, routes @mentions to
5
+ // them via a serial queue, and streams everything to the UI over SSE.
6
+
7
+ import { createHash } from "node:crypto"
8
+ import { access, mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises"
9
+ import { readFileSync } from "node:fs"
10
+ import { dirname, extname, join, resolve } from "node:path"
11
+ import { fileURLToPath } from "node:url"
12
+ import cors from "cors"
13
+ import rateLimit from "express-rate-limit"
14
+ import express, { Router } from "express"
15
+ import { config } from "./config.js"
16
+
17
+ /** Package root (this file lives in <root>/src) — anchors paths that must
18
+ * work when installed as a dependency and run from an arbitrary cwd. */
19
+ const packageRoot = () => resolve(dirname(fileURLToPath(import.meta.url)), "..")
20
+ import { downgradeUnavailableModels, isAllowedModel, listModels, resolveModel, type ResolvedModel } from "./model.js"
21
+ import { listWorkspace } from "./receipts.js"
22
+ import { BASE_PROMPT, BUILDER_OVERLAY, PLANNER_OVERLAY, SEED_PERSONAS } from "./personas.js"
23
+ import { Room } from "./room.js"
24
+ import { RoomManager, type RoomDetails } from "./room-manager.js"
25
+ import type { RoomOrchestrator } from "./orchestrator.js"
26
+ import { SseHub } from "./sse.js"
27
+ import { cleanupStaleMounts, isSshTarget, mountSshfs, unmountSshfs, type RoomMount } from "./sshfs.js"
28
+ import type { Persona, PersonaState, RouteDecision } from "./types.js"
29
+ import { parsePersona, VALID_TOOLS } from "./validation.js"
30
+
31
+ /** Directory for saved user images (relative to workspace). */
32
+ function mediaDir(): string {
33
+ return join(config.workspaceDir, "media")
34
+ }
35
+
36
+ /** Resolve a base64 data URI to a saved file path, returning the workspace-relative path. */
37
+ async function saveImage(uri: string): Promise<string> {
38
+ // Parse "data:image/png;base64,ABCD" → { ext: "png", data: "ABCD" }
39
+ const match = uri.match(/^data:image\/(png|jpeg|webp|gif);base64,([A-Za-z0-9+\/=]+)$/)
40
+ if (!match) throw new Error(`unsupported image format: ${uri.slice(0, 30)}...`);
41
+ const [, ext, b64] = match
42
+ const hash = createHash("md5").update(b64).digest("hex").slice(0, 12)
43
+ const fileName = `${hash}.${ext}`
44
+ const filePath = join(mediaDir(), fileName)
45
+ await writeFile(filePath, Buffer.from(b64, "base64"))
46
+ return `media/${fileName}`
47
+ }
48
+
49
+ async function saveIncomingImages(images: unknown): Promise<string[]> {
50
+ if (!Array.isArray(images)) return []
51
+ const results: string[] = []
52
+ for (const uri of images) {
53
+ if (typeof uri !== "string") continue
54
+ try {
55
+ results.push(await saveImage(uri))
56
+ } catch (err) {
57
+ // Skip images that fail to save; the message still goes through.
58
+ console.warn(`[server] image save failed: ${err instanceof Error ? err.message : String(err)}`)
59
+ }
60
+ }
61
+ return results
62
+ }
63
+
64
+
65
+ // ── Preset I/O ──────────────────────────────────────────────────────────────
66
+
67
+ /** A persona as persisted in a preset file — systemPrompt is optional since
68
+ * seed personas rehydrate from SEED_PERSONAS at load time. */
69
+ interface PresetPersona extends Omit<PersonaState, "systemPrompt"> {
70
+ systemPrompt?: string
71
+ }
72
+
73
+ /** A saved preset: roster configuration + metadata. */
74
+ interface PresetFile {
75
+ name: string
76
+ personas: PresetPersona[]
77
+ }
78
+
79
+ function presetsDir(): string {
80
+ return join(config.workspaceDir, "presets")
81
+ }
82
+
83
+ function presetPath(name: string): string {
84
+ return join(presetsDir(), `${name}.json`)
85
+ }
86
+
87
+ async function listPresets(): Promise<PresetFile[]> {
88
+ await mkdir(presetsDir(), { recursive: true })
89
+ const files = await readdir(presetsDir())
90
+ const results: PresetFile[] = []
91
+ for (const f of files) {
92
+ if (!f.endsWith(".json")) continue
93
+ try {
94
+ const content = await readFile(presetPath(f.replace(".json", "")), "utf-8")
95
+ const parsed = JSON.parse(content) as PresetFile
96
+ results.push(parsed)
97
+ } catch {
98
+ // Skip corrupt files
99
+ }
100
+ }
101
+ return results.sort((a, b) => a.name.localeCompare(b.name))
102
+ }
103
+
104
+ async function readPreset(name: string): Promise<PresetFile | null> {
105
+ const path = presetPath(name)
106
+ try {
107
+ const content = await readFile(path, "utf-8")
108
+ return JSON.parse(content) as PresetFile
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ async function writePreset(preset: PresetFile): Promise<void> {
115
+ await mkdir(presetsDir(), { recursive: true })
116
+ const content = JSON.stringify(preset, null, 2)
117
+ await writeFile(presetPath(preset.name), content, "utf-8")
118
+ }
119
+
120
+ async function deletePreset(name: string): Promise<boolean> {
121
+ const path = presetPath(name)
122
+ try {
123
+ await unlink(path)
124
+ return true
125
+ } catch {
126
+ return false
127
+ }
128
+ }
129
+
130
+ /** Strip systemPrompt from seed persona IDs so presets don't carry stale prompts. */
131
+ function stripSeedPrompts(personas: (PersonaState | PresetPersona)[]): PresetPersona[] {
132
+ const seedIds = new Set(SEED_PERSONAS.map(p => p.id))
133
+ return personas.map(p => {
134
+ if (seedIds.has(p.id)) {
135
+ const { systemPrompt: _, ...rest } = p
136
+ return rest
137
+ }
138
+ return p as PresetPersona
139
+ })
140
+ }
141
+
142
+ /** Rehydrate systemPrompt from current SEED_PERSONAS for matching IDs. */
143
+ function rehydratePrompts(personas: PresetPersona[]): PersonaState[] {
144
+ const seedMap = new Map(SEED_PERSONAS.map(p => [p.id, p]))
145
+ return personas.map(p => {
146
+ const seed = seedMap.get(p.id)
147
+ if (seed && !p.systemPrompt) {
148
+ return { ...p, systemPrompt: seed.systemPrompt }
149
+ }
150
+ return p as PersonaState
151
+ })
152
+ }
153
+
154
+ /** Re-seed any missing default presets (allows user deletion, restored on restart). */
155
+ async function seedDefaultPresets(): Promise<void> {
156
+ const existing = await listPresets()
157
+ const existingNames = new Set(existing.map(p => p.name))
158
+
159
+ const localDefault: PresetFile = {
160
+ name: "local-default",
161
+ personas: stripSeedPrompts(SEED_PERSONAS.map(p => ({ ...p, active: true, parallel: false }))),
162
+ }
163
+
164
+ // Cloud-sprint: builder2 (Haiku), auditor (Sonnet), planner (Opus), tester (Sonnet)
165
+ const cloudSprint: PresetFile = {
166
+ name: "cloud-sprint",
167
+ personas: stripSeedPrompts([
168
+ {
169
+ id: "builder2",
170
+ name: "Builder2",
171
+ color: "#EF9F27",
172
+ icon: "🔨",
173
+ tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
174
+ systemPrompt: BASE_PROMPT + BUILDER_OVERLAY,
175
+ model: "anthropic/claude-haiku-4-20250719",
176
+ active: true,
177
+ parallel: true,
178
+ },
179
+ {
180
+ id: "auditor",
181
+ name: "Auditor",
182
+ color: "#AFA9EC",
183
+ icon: "🛡️",
184
+ tools: ["read", "grep", "find", "ls"],
185
+ model: "anthropic/claude-sonnet-4-20250514",
186
+ active: true,
187
+ parallel: true,
188
+ },
189
+ {
190
+ id: "planner",
191
+ name: "Planner",
192
+ color: "#4A90D9",
193
+ icon: "📋",
194
+ tools: ["read", "grep", "find", "ls"],
195
+ systemPrompt: BASE_PROMPT + PLANNER_OVERLAY,
196
+ model: "anthropic/claude-opus-4-6-20250603",
197
+ active: true,
198
+ parallel: true,
199
+ },
200
+ {
201
+ id: "tester",
202
+ name: "Tester",
203
+ color: "#97C459",
204
+ icon: "🧪",
205
+ tools: ["read", "bash", "grep", "find", "ls"],
206
+ model: "anthropic/claude-sonnet-4-20250514",
207
+ active: true,
208
+ parallel: true,
209
+ },
210
+ ]),
211
+ }
212
+
213
+ let seeded = 0
214
+ if (!existingNames.has("local-default")) {
215
+ await writePreset(localDefault)
216
+ seeded++
217
+ }
218
+ if (!existingNames.has("cloud-sprint")) {
219
+ await writePreset(cloudSprint)
220
+ seeded++
221
+ }
222
+ if (seeded > 0) {
223
+ console.log(`[presets] seeded ${seeded} default preset(s)`)
224
+ }
225
+ }
226
+
227
+ /** Build the provider list for SSE broadcast and API responses. */
228
+ async function getProviderList(resolved: ResolvedModel, explicitlyEnabled: Set<string>) {
229
+ const allModels = resolved.modelRegistry.getAll()
230
+ const providerSet = new Set(allModels.map((m) => m.provider))
231
+ // Pre-compute which providers support OAuth login
232
+ const oauthProviderIds = new Set(
233
+ resolved.authStorage.getOAuthProviders().map((p) => p.id),
234
+ )
235
+ return Array.from(providerSet).map((name) => {
236
+ const authStatus = resolved.modelRegistry.getProviderAuthStatus(name)
237
+ const models = allModels
238
+ .filter((m) => m.provider === name)
239
+ .map((m) => ({ id: m.id, name: (m as { name?: string }).name ?? m.id }))
240
+ return {
241
+ name,
242
+ displayName: resolved.modelRegistry.getProviderDisplayName(name),
243
+ ...authStatus,
244
+ explicitlyEnabled: explicitlyEnabled.has(name),
245
+ supportsOAuth: oauthProviderIds.has(name),
246
+ models,
247
+ }
248
+ })
249
+ }
250
+
251
+ /** Error carrying an HTTP-style status so a route can map it to a response and
252
+ * a tool can map it to error text. Thrown by provisionRoom(). */
253
+ class RoomProvisionError extends Error {
254
+ constructor(
255
+ public readonly status: number,
256
+ message: string,
257
+ public readonly details?: unknown,
258
+ ) {
259
+ super(message)
260
+ this.name = "RoomProvisionError"
261
+ }
262
+ }
263
+
264
+ async function main(): Promise<void> {
265
+ await mkdir(config.workspaceDir, { recursive: true })
266
+ await mkdir(mediaDir(), { recursive: true })
267
+
268
+ const resolved = await resolveModel()
269
+ const hub = new SseHub()
270
+
271
+ // Track providers the user has explicitly added via the API — their models
272
+ // appear in listModels() even when PIPELINE_ALLOW_CLOUD is false.
273
+ const explicitlyEnabledProviders = new Set<string>()
274
+
275
+ const roomManager = new RoomManager(resolved, hub, explicitlyEnabledProviders, SEED_PERSONAS)
276
+
277
+ // Preset loads downgrade unavailable models (stale cloud id, swapped local
278
+ // quant…) to the process default instead of blocking the whole load.
279
+ const downgradeModels = (personas: Array<{ name: string; model?: string }>) =>
280
+ downgradeUnavailableModels(personas, (m) => registry.isAllowedModel(m))
281
+
282
+ // Shared room-provisioning path used by both the POST /api/rooms route and the
283
+ // sub-room orchestrator (spawn_room tool). Single source of truth so the two
284
+ // entry points cannot drift on validation, mounting, preset resolution, or
285
+ // rollback. Throws RoomProvisionError(status, msg) on any failure.
286
+ async function provisionRoom(opts: {
287
+ roomId?: string
288
+ name: string
289
+ preset?: string
290
+ goal?: string
291
+ workspaceDir?: string
292
+ goalMode?: "auto" | "eval"
293
+ goalEvaluator?: string
294
+ maxGoalIterations?: number
295
+ }): Promise<RoomDetails> {
296
+ const name = opts.name.trim()
297
+ if (!name) throw new RoomProvisionError(400, "`name` is required")
298
+
299
+ const rawId = (opts.roomId ?? "").trim().replace(/[^a-zA-Z0-9_-]/g, "")
300
+ const roomId = rawId || `room-${Date.now().toString(36)}`
301
+ // Reject a duplicate roomId *before* any sshfs mount. The mountpoint is
302
+ // derived from roomId, so mounting for a duplicate would collide with the
303
+ // existing room's mountpoint and leak. Fail fast here instead.
304
+ if (roomManager.getRoom(roomId)) {
305
+ throw new RoomProvisionError(409, `Room "${roomId}" already exists`)
306
+ }
307
+
308
+ // Global concurrency cap. Counts ALL rooms (default included). Both entry
309
+ // points — POST /api/rooms and the spawn_room tool — flow through here, so
310
+ // the cap can't be bypassed. Reject before any sshfs mount so a refused
311
+ // spawn leaks no resources, and give the planner an actionable message.
312
+ if (roomManager.listRooms().length >= config.maxRooms) {
313
+ throw new RoomProvisionError(
314
+ 429,
315
+ `room limit reached (${config.maxRooms}) — stop or destroy a room before creating another`,
316
+ )
317
+ }
318
+
319
+ const presetName = opts.preset?.trim() || undefined
320
+ const goal = opts.goal?.trim() || undefined
321
+ const workspaceDir = opts.workspaceDir?.trim() || undefined
322
+
323
+ // Per-room scope: local path or sshfs target. Empty = pipeline workspace.
324
+ let effectiveWorkspaceDir = workspaceDir
325
+ let mount: RoomMount | undefined
326
+ if (workspaceDir && isSshTarget(workspaceDir)) {
327
+ // Remote scope: mount now. Mounting can fail (auth, unreachable, bad path,
328
+ // sshfs missing) — surface a 400 before the room exists.
329
+ try {
330
+ const mountpoint = await mountSshfs(roomId, workspaceDir)
331
+ effectiveWorkspaceDir = mountpoint
332
+ mount = { mountpoint, sshTarget: workspaceDir }
333
+ } catch (err) {
334
+ throw new RoomProvisionError(400, err instanceof Error ? err.message : String(err))
335
+ }
336
+ } else if (workspaceDir) {
337
+ // Local scope: require an existing directory. A typo would silently make a
338
+ // broken room (empty listing, nonexistent bash cwd).
339
+ const scope = resolve(workspaceDir)
340
+ try {
341
+ const st = await stat(scope)
342
+ if (!st.isDirectory()) {
343
+ throw new RoomProvisionError(400, `workspaceDir "${scope}" is not a directory`)
344
+ }
345
+ } catch (err) {
346
+ if (err instanceof RoomProvisionError) throw err
347
+ throw new RoomProvisionError(400, `workspaceDir "${scope}" does not exist`)
348
+ }
349
+ }
350
+
351
+ try {
352
+ // Resolve preset roster if requested.
353
+ let overridePersonas: PersonaState[] | undefined
354
+ if (presetName) {
355
+ const presetFile = await readPreset(presetName)
356
+ if (!presetFile) throw new RoomProvisionError(404, `preset "${presetName}" not found`)
357
+ const personas = rehydratePrompts(presetFile.personas)
358
+ if (personas.length === 0) {
359
+ throw new RoomProvisionError(400, `preset "${presetName}" has no personas`)
360
+ }
361
+ // Unavailable models fall back to the default rather than failing room creation.
362
+ downgradeModels(personas)
363
+ overridePersonas = personas
364
+ }
365
+
366
+ const newRoom = roomManager.createRoom(roomId, name, overridePersonas, effectiveWorkspaceDir, mount)
367
+ await newRoom.init()
368
+
369
+ // Fire the goal BEFORE broadcasting "created": submitGoal sets the status to
370
+ // "running" synchronously, so the broadcast below carries an accurate
371
+ // goalStatus and the room tab shows the right badge from the start (a
372
+ // goal-less room stays "idle" instead of being mislabeled "running").
373
+ if (goal) {
374
+ newRoom.submitGoal(goal, {
375
+ mode: opts.goalMode,
376
+ evaluator: opts.goalEvaluator,
377
+ maxIterations: opts.maxGoalIterations,
378
+ })
379
+ }
380
+
381
+ hub.broadcast("room", {
382
+ type: "created",
383
+ roomId,
384
+ name,
385
+ participantCount: newRoom.rosterLength(),
386
+ goalStatus: newRoom.getGoalStatus(),
387
+ })
388
+
389
+ return roomManager.getRoomDetails(roomId)!
390
+ } catch (err) {
391
+ // Roll back after a successful mount. If the room reached the map (init
392
+ // threw), destroyRoom unmounts + removes it; otherwise unmount directly.
393
+ if (roomManager.getRoom(roomId)) {
394
+ await roomManager.destroyRoom(roomId).catch(() => {})
395
+ } else if (mount) {
396
+ await unmountSshfs(mount.mountpoint).catch(() => {})
397
+ }
398
+ if (err instanceof RoomProvisionError) throw err
399
+ const msg = err instanceof Error ? err.message : String(err)
400
+ throw new RoomProvisionError(msg.includes("already exists") ? 409 : 500, msg)
401
+ }
402
+ }
403
+
404
+ /** Clone a built-in seed persona into a room as a NEW participant with a unique
405
+ * id ("builder" → "builder-2" → "builder-3") and a matching display name. Keeps
406
+ * the template's tools, prompt, icon, color, and model — unless that model is
407
+ * unavailable (e.g. cloud disabled), in which case it falls back to the room
408
+ * default instead of failing the add. Returns the new roster item. */
409
+ async function addFromTemplate(room: Room, templateId: string) {
410
+ const tpl = SEED_PERSONAS.find((p) => p.id === templateId)
411
+ if (!tpl) throw new Error(`no persona template "${templateId}"`)
412
+ const reg = room.getRegistry()
413
+
414
+ let id = tpl.id
415
+ let n = 1
416
+ while (reg.has(id)) {
417
+ n++
418
+ id = `${tpl.id}-${n}`
419
+ }
420
+ const name = n === 1 ? tpl.name : `${tpl.name} ${n}`
421
+
422
+ const clone: Persona = { ...tpl, id, name }
423
+ if (clone.model && !reg.isAllowedModel(clone.model)) {
424
+ clone.model = undefined
425
+ }
426
+ await reg.create(clone)
427
+ return reg.roster().find((r) => r.id === id)
428
+ }
429
+
430
+ // Wire the sub-room orchestrator BEFORE any room is created, so participants
431
+ // built during room.init() receive spawn/check/destroy_room tools. The
432
+ // orchestrator closes over provisionRoom (preset + mount logic lives here).
433
+ const orchestrator: RoomOrchestrator = {
434
+ async spawnRoom(o) {
435
+ const d = await provisionRoom({
436
+ name: o.name,
437
+ goal: o.goal,
438
+ preset: o.preset,
439
+ workspaceDir: o.workspaceDir,
440
+ goalMode: o.goalMode,
441
+ goalEvaluator: o.goalEvaluator,
442
+ maxGoalIterations: o.maxGoalIterations,
443
+ })
444
+ return { roomId: d.roomId, name: d.name, goalStatus: d.goalStatus }
445
+ },
446
+ checkRoom(roomId) {
447
+ const r = roomManager.getRoom(roomId)
448
+ if (!r) return { found: false, roomId }
449
+ const details = roomManager.getRoomDetails(roomId)
450
+ const lastMessages = r.getTranscript().slice(-5).map((e) => `${e.authorName}: ${e.text}`)
451
+ return {
452
+ found: true,
453
+ roomId,
454
+ name: details?.name,
455
+ goalStatus: r.getGoalStatus(),
456
+ goalText: r.getGoalText(),
457
+ lastMessages,
458
+ }
459
+ },
460
+ async stopRoom(roomId) {
461
+ // The default room is the planner's OWN room — stopping it would abort the
462
+ // planner mid-turn. Protect it; stop_room is for sub-rooms only.
463
+ if (roomId === "default") return false
464
+ const r = roomManager.getRoom(roomId)
465
+ if (!r) return false
466
+ await r.abortCurrent()
467
+ hub.broadcast("room", { type: "stopped", roomId })
468
+ return true
469
+ },
470
+ async destroyRoom(roomId) {
471
+ if (roomId === "default") return false
472
+ const removed = await roomManager.destroyRoom(roomId)
473
+ if (removed) hub.broadcast("room", { type: "destroyed", roomId })
474
+ return removed
475
+ },
476
+ }
477
+ roomManager.setOrchestrator(orchestrator)
478
+
479
+ const room = roomManager.createDefaultRoom()
480
+ const registry = room.getRegistry()
481
+
482
+ // Load the most recent saved discussion, or seed a fresh one.
483
+ await room.init()
484
+ await seedDefaultPresets()
485
+ console.log(`[room] roster: ${registry.roster().length} participants`)
486
+
487
+ // Re-create rooms that were created in a previous run. The Map is in-memory;
488
+ // the manifest (sessions/rooms.json) is the durable record. Default room is
489
+ // already created above — restoreRooms only adds the others (and restores a
490
+ // renamed default name). sshfs rooms are re-mounted here.
491
+ //
492
+ // First clear any mounts leaked by a prior process that was killed before
493
+ // teardown — otherwise a resume/restore that re-mounts the same deterministic
494
+ // path fails with "fusermount3: … Permission denied". restoreRooms re-mounts
495
+ // the rooms that are still valid right after.
496
+ await cleanupStaleMounts()
497
+ await roomManager.restoreRooms()
498
+
499
+ const app = express()
500
+ app.use(cors({ origin: config.corsOrigins }))
501
+ app.use(express.json({ limit: "5mb" }))
502
+
503
+ app.get("/api/health", (_req, res) => {
504
+ res.json({ ok: true, workspace: config.workspaceDir, clients: hub.clientCount })
505
+ })
506
+
507
+ // ── Presets ────────────────────────────────────────────────────────────────
508
+
509
+ // Preset save/load/apply shared by the default-room (/api) and room-scoped
510
+ // (/api/rooms/:id) routes, so an action targets the room you're viewing
511
+ // — not always the default room.
512
+ const doSavePreset = async (r: Room, name: string, res: express.Response): Promise<void> => {
513
+ if (!name) {
514
+ res.status(400).json({ error: "`name` is required (alphanumeric, dash, underscore)" })
515
+ return
516
+ }
517
+ if (r.isBusy()) {
518
+ res.status(409).json({ error: "a turn is running — press Stop before saving a preset" })
519
+ return
520
+ }
521
+ try {
522
+ const personas = stripSeedPrompts(r.getRegistry().personaStates())
523
+ const preset: PresetFile = { name, personas }
524
+ await writePreset(preset)
525
+ res.status(201).json(preset)
526
+ } catch (err) {
527
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
528
+ }
529
+ }
530
+
531
+ const doLoadPreset = async (r: Room, name: string, res: express.Response): Promise<void> => {
532
+ if (r.isBusy()) {
533
+ res.status(409).json({ error: "a turn is running — press Stop before loading a preset" })
534
+ return
535
+ }
536
+ try {
537
+ const preset = await readPreset(name)
538
+ if (!preset) {
539
+ res.status(404).json({ error: `preset "${name}" not found` })
540
+ return
541
+ }
542
+ const personas = rehydratePrompts(preset.personas)
543
+ if (personas.length === 0) {
544
+ res.status(400).json({ error: `preset "${name}" has no personas — would brick the session` })
545
+ return
546
+ }
547
+ // Unavailable models (stale cloud id, swapped local quant…) fall back to the
548
+ // default instead of blocking the whole preset — the human is told which.
549
+ const downgraded = downgradeModels(personas)
550
+ const meta = await r.loadPreset(personas, `${name} — ${new Date().toLocaleTimeString()}`)
551
+ res.json({ ok: true, conversation: meta, downgraded })
552
+ } catch (err) {
553
+ const msg = err instanceof Error ? err.message : String(err)
554
+ res.status(msg.includes("unknown") ? 404 : 409).json({ error: msg })
555
+ }
556
+ }
557
+
558
+ const doApplyPreset = async (r: Room, name: string, res: express.Response): Promise<void> => {
559
+ if (r.isBusy()) {
560
+ res.status(409).json({ error: "a turn is running — press Stop before applying a preset" })
561
+ return
562
+ }
563
+ try {
564
+ const preset = await readPreset(name)
565
+ if (!preset) {
566
+ res.status(404).json({ error: `preset "${name}" not found` })
567
+ return
568
+ }
569
+ const personas = rehydratePrompts(preset.personas)
570
+ if (personas.length === 0) {
571
+ res.status(400).json({ error: `preset "${name}" has no personas — would brick the session` })
572
+ return
573
+ }
574
+ const downgraded = downgradeModels(personas)
575
+ const meta = await r.applyPreset(personas)
576
+ res.json({ ok: true, conversation: meta, downgraded })
577
+ } catch (err) {
578
+ const msg = err instanceof Error ? err.message : String(err)
579
+ res.status(msg.includes("unknown") ? 404 : 409).json({ error: msg })
580
+ }
581
+ }
582
+
583
+ app.get("/api/presets", async (_req, res) => {
584
+ try {
585
+ const presets = await listPresets()
586
+ res.json(presets)
587
+ } catch (err) {
588
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
589
+ }
590
+ })
591
+
592
+ app.post("/api/presets", async (req, res) => {
593
+ const name = String(req.body?.name ?? "").trim().replace(/[^a-zA-Z0-9_-]/g, "")
594
+ await doSavePreset(room, name, res)
595
+ })
596
+
597
+ app.delete("/api/presets/:name", async (req, res) => {
598
+ const name = req.params.name
599
+ const deleted = await deletePreset(name)
600
+ if (!deleted) {
601
+ res.status(404).json({ error: `preset "${name}" not found` })
602
+ return
603
+ }
604
+ res.status(204).end()
605
+ })
606
+
607
+ app.post("/api/presets/:name/load", (req, res) => doLoadPreset(room, req.params.name, res))
608
+
609
+ // ── Apply preset roster to current room (in-place, no new conversation) ──
610
+
611
+ app.post("/api/presets/:name/apply", (req, res) => doApplyPreset(room, req.params.name, res))
612
+
613
+ // Serve saved images from the media directory.
614
+ app.get("/api/media/:filename", async (req, res) => {
615
+ const filename = req.params.filename
616
+ const ext = extname(filename).toLowerCase()
617
+ const allowed = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"])
618
+ if (!allowed.has(ext)) {
619
+ res.status(400).json({ error: "unsupported file type" })
620
+ return
621
+ }
622
+ const filePath = join(mediaDir(), filename)
623
+ try {
624
+ await access(filePath)
625
+ res.sendFile(filePath)
626
+ } catch {
627
+ res.status(404).json({ error: "image not found" })
628
+ }
629
+ })
630
+
631
+ // SSE stream of all room events.
632
+ // Global (unfiltered) SSE stream. Consumed by App.tsx for process-wide `room`
633
+ // lifecycle events (created/destroyed/renamed/goal-*), which are broadcast
634
+ // without a roomId and so must reach a non-room-filtered subscriber. Per-room
635
+ // UI state uses /api/rooms/:roomId/events instead.
636
+ app.get("/api/events", (_req, res) => {
637
+ hub.addClient(res)
638
+ // Write the current roster directly to *this* connecting client. Using
639
+ // hub.broadcast() here would push the default room's roster to every
640
+ // room-filtered client, clobbering their roster on any new connection.
641
+ res.write(`event: roster\ndata: ${JSON.stringify(registry.roster())}\n\n`)
642
+ })
643
+
644
+ app.get("/api/participants", (_req, res) => {
645
+ res.json(registry.roster())
646
+ })
647
+
648
+ // Built-in persona templates for the "Add agent" picker — clone one into a room
649
+ // (e.g. a second builder) without rebuilding it or loading a whole preset.
650
+ app.get("/api/persona-templates", (_req, res) => {
651
+ res.json(
652
+ SEED_PERSONAS.map((p) => ({
653
+ id: p.id, name: p.name, color: p.color, icon: p.icon, tools: p.tools, model: p.model,
654
+ })),
655
+ )
656
+ })
657
+
658
+ // Models offered for per-agent selection (local-only unless PIPELINE_ALLOW_CLOUD,
659
+ // or the provider has been explicitly enabled by the user via /api/providers).
660
+ app.get("/api/models", (_req, res) => {
661
+ const models = listModels(resolved, room.getAllowCloud(), explicitlyEnabledProviders)
662
+ res.json({ models, allowCloud: room.getAllowCloud() })
663
+ })
664
+
665
+ // ── Providers ──────────────────────────────────────────────────────────────
666
+
667
+ // List all known providers with their auth status (no secrets exposed).
668
+ app.get("/api/providers", (_req, res) => {
669
+ const allModels = resolved.modelRegistry.getAll()
670
+ const providerSet = new Set(allModels.map((m) => m.provider))
671
+ const providers = Array.from(providerSet).map((name) => {
672
+ const authStatus = resolved.modelRegistry.getProviderAuthStatus(name)
673
+ const models = allModels
674
+ .filter((m) => m.provider === name)
675
+ .map((m) => ({ id: m.id, name: (m as { name?: string }).name ?? m.id }))
676
+ return {
677
+ name,
678
+ displayName: resolved.modelRegistry.getProviderDisplayName(name),
679
+ ...authStatus,
680
+ explicitlyEnabled: explicitlyEnabledProviders.has(name),
681
+ models,
682
+ }
683
+ })
684
+ res.json({ providers, explicitlyEnabled: Array.from(explicitlyEnabledProviders) })
685
+ })
686
+
687
+ // Set an API key for a provider (persisted to auth.json).
688
+ app.post("/api/providers/:name", async (req, res) => {
689
+ const name = req.params.name
690
+ const body = req.body ?? {}
691
+
692
+ if (!body.key || typeof body.key !== "string") {
693
+ res.status(400).json({ error: "`key` is required (string)" })
694
+ return
695
+ }
696
+ if (room.isBusy()) {
697
+ res.status(409).json({ error: "a turn is running — press Stop before changing provider credentials" })
698
+ return
699
+ }
700
+
701
+ // Verify the provider exists in the registry
702
+ const allModels = resolved.modelRegistry.getAll()
703
+ const providerExists = allModels.some((m) => m.provider === name)
704
+ if (!providerExists && !body.baseUrl) {
705
+ res.status(404).json({ error: `provider "${name}" not found in model registry` })
706
+ return
707
+ }
708
+
709
+ try {
710
+ resolved.authStorage.set(name, { type: "api_key", key: body.key })
711
+ explicitlyEnabledProviders.add(name)
712
+ // Refresh so getAvailable() picks up the new models immediately
713
+ resolved.modelRegistry.refresh()
714
+
715
+ // Broadcast updated provider list to SSE clients
716
+ hub.broadcast("providers", {
717
+ providers: await getProviderList(resolved, explicitlyEnabledProviders),
718
+ explicitlyEnabled: Array.from(explicitlyEnabledProviders),
719
+ })
720
+
721
+ // Return only the auth status — never the key
722
+ const status = resolved.modelRegistry.getProviderAuthStatus(name)
723
+ res.status(200).json({ name, ...status })
724
+ } catch (err) {
725
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
726
+ }
727
+ })
728
+
729
+ // Start OAuth login flow for a provider (device-code or auth URL).
730
+ app.post("/api/providers/:name/login", async (req, res) => {
731
+ const name = req.params.name
732
+ const oauthProviders = resolved.authStorage.getOAuthProviders()
733
+ const oauthProvider = oauthProviders.find((p) => p.id === name)
734
+ if (!oauthProvider) {
735
+ res.status(404).json({ error: `provider "${name}" does not support OAuth login` })
736
+ return
737
+ }
738
+ if (room.isBusy()) {
739
+ res.status(409).json({ error: "a turn is running — press Stop before starting OAuth login" })
740
+ return
741
+ }
742
+
743
+ // Start the login flow in the background — communicate progress via SSE.
744
+ // Return 202 immediately so the client can wait for SSE events.
745
+ const providerName = name // capture for closure
746
+ setImmediate(async () => {
747
+ try {
748
+ await resolved.authStorage.login(name, {
749
+ onDeviceCode: (info) => {
750
+ hub.broadcast("oauth_progress", {
751
+ provider: providerName,
752
+ type: "device_code",
753
+ userCode: info.userCode,
754
+ verificationUri: info.verificationUri,
755
+ })
756
+ },
757
+ onAuth: (info) => {
758
+ hub.broadcast("oauth_progress", {
759
+ provider: providerName,
760
+ type: "auth_url",
761
+ url: info.url,
762
+ instructions: info.instructions,
763
+ })
764
+ },
765
+ onProgress: (message) => {
766
+ hub.broadcast("oauth_progress", {
767
+ provider: providerName,
768
+ type: "progress",
769
+ message,
770
+ })
771
+ },
772
+ onPrompt: async (prompt) => {
773
+ // For headless: reject prompts that require user input
774
+ hub.broadcast("oauth_progress", {
775
+ provider: providerName,
776
+ type: "error",
777
+ message: `OAuth requires interactive prompt: "${prompt.message}" — use the pi CLI for this provider.`,
778
+ })
779
+ throw new Error("interactive prompt not supported in headless mode")
780
+ },
781
+ onSelect: async () => {
782
+ hub.broadcast("oauth_progress", {
783
+ provider: providerName,
784
+ type: "error",
785
+ message: "OAuth requires a selection — use the pi CLI for this provider.",
786
+ })
787
+ throw new Error("interactive selection not supported in headless mode")
788
+ },
789
+ })
790
+
791
+ // Success — broadcast updated provider list
792
+ resolved.modelRegistry.refresh()
793
+ hub.broadcast("providers", {
794
+ providers: await getProviderList(resolved, explicitlyEnabledProviders),
795
+ explicitlyEnabled: Array.from(explicitlyEnabledProviders),
796
+ })
797
+ hub.broadcast("oauth_progress", {
798
+ provider: providerName,
799
+ type: "success",
800
+ message: `Authenticated with ${providerName}.`,
801
+ })
802
+ } catch (err) {
803
+ const msg = err instanceof Error ? err.message : String(err)
804
+ hub.broadcast("oauth_progress", {
805
+ provider: providerName,
806
+ type: "error",
807
+ message: msg,
808
+ })
809
+ }
810
+ })
811
+
812
+ res.status(202).json({ accepted: true, provider: name })
813
+ })
814
+
815
+ // Remove credentials for a provider.
816
+ app.delete("/api/providers/:name", async (req, res) => {
817
+ const name = req.params.name
818
+ if (name === "local") {
819
+ res.status(400).json({ error: "cannot remove the local provider" })
820
+ return
821
+ }
822
+ if (room.isBusy()) {
823
+ res.status(409).json({ error: "a turn is running — press Stop before changing provider credentials" })
824
+ return
825
+ }
826
+
827
+ // Check if any active participant uses this provider
828
+ const roster = registry.roster()
829
+ const agentsUsing = roster.filter((p) => {
830
+ const model = p.model
831
+ return model && model.startsWith(`${name}/`)
832
+ }).map((p) => p.name)
833
+
834
+ try {
835
+ resolved.authStorage.remove(name)
836
+ explicitlyEnabledProviders.delete(name)
837
+ resolved.modelRegistry.refresh()
838
+
839
+ hub.broadcast("providers", {
840
+ providers: await getProviderList(resolved, explicitlyEnabledProviders),
841
+ explicitlyEnabled: Array.from(explicitlyEnabledProviders),
842
+ })
843
+
844
+ const status = resolved.modelRegistry.getProviderAuthStatus(name)
845
+ res.status(200).json({ name, ...status, agentsUsing })
846
+ } catch (err) {
847
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
848
+ }
849
+ })
850
+
851
+ app.post("/api/participants", async (req, res) => {
852
+ try {
853
+ const persona = parsePersona(req.body ?? {})
854
+ if (registry.has(persona.id)) {
855
+ res.status(409).json({ error: `participant "${persona.id}" already exists` })
856
+ return
857
+ }
858
+ await registry.create(persona)
859
+ res.status(201).json(registry.roster().find((r) => r.id === persona.id))
860
+ } catch (err) {
861
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) })
862
+ }
863
+ })
864
+
865
+ app.post("/api/participants/from-template", async (req, res) => {
866
+ try {
867
+ const item = await addFromTemplate(room, String(req.body?.templateId ?? ""))
868
+ res.status(201).json(item)
869
+ } catch (err) {
870
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) })
871
+ }
872
+ })
873
+
874
+ // Reorder the roster (first-turn / @all execution order). Registered before
875
+ // the "/:id" routes so "reorder" is never parsed as an id.
876
+ app.post("/api/participants/reorder", (req, res) => {
877
+ const order = req.body?.order
878
+ if (!Array.isArray(order) || !order.every((x: unknown) => typeof x === "string")) {
879
+ res.status(400).json({ error: "`order` must be an array of participant ids" })
880
+ return
881
+ }
882
+ registry.reorder(order as string[])
883
+ res.json(registry.roster())
884
+ })
885
+
886
+ // Full persona (incl. systemPrompt) for the edit form.
887
+ app.get("/api/participants/:id", (req, res) => {
888
+ const p = registry.get(req.params.id)
889
+ if (!p) {
890
+ res.status(404).json({ error: `unknown participant "${req.params.id}"` })
891
+ return
892
+ }
893
+ res.json({ ...p.persona, availableThinkingLevels: p.getAvailableThinkingLevels() })
894
+ })
895
+
896
+ // Activate / deactivate AND edit persona (name, prompt, tools, color, icon).
897
+ app.patch("/api/participants/:id", async (req, res) => {
898
+ const { id } = req.params
899
+ if (!registry.has(id)) {
900
+ res.status(404).json({ error: `unknown participant "${id}"` })
901
+ return
902
+ }
903
+ const body = req.body ?? {}
904
+
905
+ // Build a persona patch from any editable fields present in the body.
906
+ const patch: Record<string, unknown> = {}
907
+ if (typeof body.name === "string" && body.name.trim()) patch.name = body.name.trim()
908
+ if (typeof body.systemPrompt === "string" && body.systemPrompt.trim())
909
+ patch.systemPrompt = body.systemPrompt.trim()
910
+ if (typeof body.color === "string") patch.color = body.color
911
+ if (typeof body.icon === "string") patch.icon = body.icon
912
+ if (Array.isArray(body.tools))
913
+ patch.tools = body.tools.map(String).filter((t: string) => VALID_TOOLS.has(t))
914
+ const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh"])
915
+ if ("thinkingLevel" in body) {
916
+ const tv = body.thinkingLevel
917
+ if (tv === null || tv === "") {
918
+ patch.thinkingLevel = undefined // reset to the global default
919
+ } else if (typeof tv === "string" && VALID_THINKING.has(tv)) {
920
+ patch.thinkingLevel = tv
921
+ } else {
922
+ res.status(400).json({ error: `invalid thinkingLevel "${String(tv)}" — must be one of: off, minimal, low, medium, high, xhigh` })
923
+ return
924
+ }
925
+ }
926
+ if ("model" in body) {
927
+ const mv = body.model
928
+ if (mv === null || mv === "") {
929
+ patch.model = undefined // reset to the process default model
930
+ } else if (typeof mv === "string" && registry.isAllowedModel(mv)) {
931
+ patch.model = mv
932
+ } else {
933
+ res.status(400).json({
934
+ error: room.getAllowCloud()
935
+ ? `unknown model "${String(mv)}"`
936
+ : `model "${String(mv)}" unavailable — cloud is disabled (toggle in Settings)`,
937
+ })
938
+ return
939
+ }
940
+ }
941
+ if ("compactionInstructions" in body) {
942
+ const ci = body.compactionInstructions
943
+ if (ci === null || ci === "") {
944
+ patch.compactionInstructions = undefined
945
+ } else if (typeof ci === "string" && ci.length <= 500) {
946
+ patch.compactionInstructions = ci
947
+ } else if (typeof ci === "string") {
948
+ res.status(400).json({ error: `compactionInstructions too long (${ci.length} chars, max 500)` })
949
+ return
950
+ } else {
951
+ res.status(400).json({ error: "compactionInstructions must be a string" })
952
+ return
953
+ }
954
+ }
955
+
956
+ try {
957
+ if (typeof body.active === "boolean") registry.setActive(id, body.active)
958
+ if (typeof body.parallel === "boolean") registry.setParallel(id, body.parallel)
959
+ if (Object.keys(patch).length > 0) {
960
+ // Fast path: thinkingLevel-only change → in-place, no session recreation.
961
+ if (Object.keys(patch).length === 1 && "thinkingLevel" in patch && patch.thinkingLevel !== undefined) {
962
+ const level = patch.thinkingLevel as "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
963
+ await registry.setThinkingLevel(id, level)
964
+ } else {
965
+ // Heavy path: dispose + recreate session.
966
+ if (room.isBusy()) {
967
+ res.status(409).json({ error: "a turn is running — press Stop before editing an agent" })
968
+ return
969
+ }
970
+ await registry.update(id, patch)
971
+ }
972
+ }
973
+ res.json(registry.roster().find((r) => r.id === id))
974
+ } catch (err) {
975
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) })
976
+ }
977
+ })
978
+
979
+ app.delete("/api/participants/:id", (req, res) => {
980
+ const { id } = req.params
981
+ if (!registry.has(id)) {
982
+ res.status(404).json({ error: `unknown participant "${id}"` })
983
+ return
984
+ }
985
+ registry.kick(id)
986
+ res.status(204).end()
987
+ })
988
+
989
+ app.get("/api/transcript", (_req, res) => {
990
+ res.json(room.getTranscript())
991
+ })
992
+
993
+ app.get("/api/workspace", async (_req, res) => {
994
+ res.json(await listWorkspace(config.workspaceDir))
995
+ })
996
+
997
+ // ── Conversations (saved group discussions) ───────────────────────────────
998
+ app.get("/api/conversations", async (_req, res) => {
999
+ res.json(await room.getConversations())
1000
+ })
1001
+
1002
+ app.post("/api/conversations", async (req, res) => {
1003
+ try {
1004
+ const title = req.body?.title ? String(req.body.title) : undefined
1005
+ res.status(201).json(await room.newConversation(title))
1006
+ } catch (err) {
1007
+ res.status(409).json({ error: err instanceof Error ? err.message : String(err) })
1008
+ }
1009
+ })
1010
+
1011
+ app.post("/api/conversations/:id/load", async (req, res) => {
1012
+ try {
1013
+ await room.switchConversation(req.params.id)
1014
+ res.json({ ok: true })
1015
+ } catch (err) {
1016
+ const msg = err instanceof Error ? err.message : String(err)
1017
+ res.status(msg.includes("unknown") ? 404 : 409).json({ error: msg })
1018
+ }
1019
+ })
1020
+
1021
+ app.patch("/api/conversations/:id", async (req, res) => {
1022
+ const title = String(req.body?.title ?? "").trim()
1023
+ if (!title) {
1024
+ res.status(400).json({ error: "`title` is required" })
1025
+ return
1026
+ }
1027
+ try {
1028
+ await room.renameConversation(req.params.id, title)
1029
+ res.json({ ok: true })
1030
+ } catch (err) {
1031
+ res.status(404).json({ error: err instanceof Error ? err.message : String(err) })
1032
+ }
1033
+ })
1034
+
1035
+ app.delete("/api/conversations/:id", async (req, res) => {
1036
+ try {
1037
+ await room.deleteConversation(req.params.id)
1038
+ res.status(204).end()
1039
+ } catch (err) {
1040
+ res.status(409).json({ error: err instanceof Error ? err.message : String(err) })
1041
+ }
1042
+ })
1043
+
1044
+ const settingsPayload = (r: Room) => ({
1045
+ chaining: r.getChaining(),
1046
+ routingMode: r.getRoutingMode(),
1047
+ defaultAgent: r.getDefaultAgent(),
1048
+ fallbackAgent: r.getFallbackAgent(),
1049
+ maxChainHops: r.getMaxChainHops(),
1050
+ circuitBreaker: r.getCircuitBreaker(),
1051
+ defaultThinkingLevel: r.getDefaultThinkingLevel(),
1052
+ allowCloud: r.getAllowCloud(),
1053
+ compactionReserveTokens: r.getCompactionReserveTokens(),
1054
+ maxRooms: config.maxRooms,
1055
+ pendingRoute: r.getPendingRoute(),
1056
+ })
1057
+
1058
+ const parseRouteDecision = (body: Record<string, unknown>): RouteDecision | null => {
1059
+ const action = body?.action
1060
+ if (action !== "approve" && action !== "redirect" && action !== "drop") return null
1061
+ const targetIds = Array.isArray(body?.targetIds) ? body.targetIds.map(String) : undefined
1062
+ return { action, targetIds }
1063
+ }
1064
+
1065
+ app.get("/api/settings", (_req, res) => {
1066
+ res.json(settingsPayload(room))
1067
+ })
1068
+
1069
+ app.patch("/api/settings", (req, res) => {
1070
+ const body = req.body ?? {}
1071
+ if ("chaining" in body) {
1072
+ if (typeof body.chaining !== "boolean") {
1073
+ res.status(400).json({ error: "`chaining` must be a boolean" })
1074
+ return
1075
+ }
1076
+ room.setChaining(body.chaining)
1077
+ }
1078
+ if ("defaultAgent" in body) {
1079
+ const da = body.defaultAgent
1080
+ if (da !== null && typeof da !== "string") {
1081
+ res.status(400).json({ error: "`defaultAgent` must be a string id or null" })
1082
+ return
1083
+ }
1084
+ try {
1085
+ room.setDefaultAgent(da)
1086
+ } catch (err) {
1087
+ res.status(404).json({ error: err instanceof Error ? err.message : String(err) })
1088
+ return
1089
+ }
1090
+ }
1091
+ if ("fallbackAgent" in body) {
1092
+ const fa = body.fallbackAgent
1093
+ if (fa !== null && typeof fa !== "string") {
1094
+ res.status(400).json({ error: "`fallbackAgent` must be a string id or null" })
1095
+ return
1096
+ }
1097
+ try {
1098
+ room.setFallbackAgent(fa)
1099
+ } catch (err) {
1100
+ res.status(404).json({ error: err instanceof Error ? err.message : String(err) })
1101
+ return
1102
+ }
1103
+ }
1104
+ if ("maxChainHops" in body) {
1105
+ const n = body.maxChainHops
1106
+ if (typeof n !== "number" || n < 1 || n > 100) {
1107
+ res.status(400).json({ error: "`maxChainHops` must be a number between 1 and 100" })
1108
+ return
1109
+ }
1110
+ room.setMaxChainHops(n)
1111
+ }
1112
+ if ("routingMode" in body) {
1113
+ const m = body.routingMode
1114
+ if (m !== "auto" && m !== "semi" && m !== "manual") {
1115
+ res.status(400).json({ error: "`routingMode` must be 'auto', 'semi', or 'manual'" })
1116
+ return
1117
+ }
1118
+ room.setRoutingMode(m)
1119
+ }
1120
+ if ("circuitBreaker" in body) {
1121
+ if (typeof body.circuitBreaker !== "boolean") {
1122
+ res.status(400).json({ error: "`circuitBreaker` must be a boolean" })
1123
+ return
1124
+ }
1125
+ room.setCircuitBreaker(body.circuitBreaker)
1126
+ }
1127
+ if ("defaultThinkingLevel" in body) {
1128
+ const validLevels = ["off", "minimal", "low", "medium", "high", "xhigh"]
1129
+ if (!validLevels.includes(body.defaultThinkingLevel)) {
1130
+ res.status(400).json({ error: "`defaultThinkingLevel` must be one of: " + validLevels.join(", ") })
1131
+ return
1132
+ }
1133
+ room.setDefaultThinkingLevel(body.defaultThinkingLevel)
1134
+ }
1135
+ if ("allowCloud" in body) {
1136
+ if (typeof body.allowCloud !== "boolean") {
1137
+ res.status(400).json({ error: "`allowCloud` must be a boolean" })
1138
+ return
1139
+ }
1140
+ room.setAllowCloud(body.allowCloud)
1141
+ }
1142
+ if ("compactionReserveTokens" in body) {
1143
+ const v = Number(body.compactionReserveTokens)
1144
+ if (!Number.isFinite(v) || v < 5000 || v > 100000) {
1145
+ res.status(400).json({ error: "`compactionReserveTokens` must be an integer between 5000 and 100000" })
1146
+ return
1147
+ }
1148
+ room.setCompactionReserveTokens(v)
1149
+ }
1150
+ res.json(settingsPayload(room))
1151
+ })
1152
+
1153
+ app.post("/api/route", (req, res) => {
1154
+ const decision = parseRouteDecision(req.body ?? {})
1155
+ if (!decision) {
1156
+ res.status(400).json({ error: "`action` must be 'approve', 'redirect', or 'drop'" })
1157
+ return
1158
+ }
1159
+ room.resolveRoute(decision)
1160
+ res.status(202).json({ accepted: true })
1161
+ })
1162
+
1163
+ // Post a message to the room. Returns immediately; results stream over SSE.
1164
+ // Rate limited to prevent agent loops from flooding the queue.
1165
+ app.post("/api/messages", rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false }), async (req, res) => {
1166
+ const text = String(req.body?.text ?? "").trim()
1167
+ if (!text) {
1168
+ res.status(400).json({ error: "`text` is required" })
1169
+ return
1170
+ }
1171
+ // Save images and resolve to workspace-relative paths.
1172
+ const images = await saveIncomingImages(req.body?.images)
1173
+ room.submit(text, images.length > 0 ? images : undefined)
1174
+ res.status(202).json({ accepted: true })
1175
+ })
1176
+
1177
+ // Compact a specific agent's session context.
1178
+ app.post("/api/participants/:id/compact", async (req, res) => {
1179
+ const { id } = req.params
1180
+ const p = registry.get(id)
1181
+ if (!p) {
1182
+ res.status(404).json({ error: `unknown participant "${id}"` })
1183
+ return
1184
+ }
1185
+ if (room.isBusy()) {
1186
+ res.status(409).json({ error: "a turn is running — press Stop before compacting" })
1187
+ return
1188
+ }
1189
+ try {
1190
+ const result = await p.compact()
1191
+ res.json(result)
1192
+ } catch (err) {
1193
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
1194
+ }
1195
+ })
1196
+
1197
+ // Export agent's session as self-contained HTML.
1198
+ app.get("/api/participants/:id/export", async (req, res) => {
1199
+ const { id } = req.params
1200
+ const p = registry.get(id)
1201
+ if (!p) {
1202
+ res.status(404).json({ error: `unknown participant "${id}"` })
1203
+ return
1204
+ }
1205
+ try {
1206
+ const filePath = await p.exportToHtml()
1207
+ const html = readFileSync(filePath, "utf-8")
1208
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5)
1209
+ const filename = `${id}-${timestamp}.html`
1210
+ res.setHeader("Content-Type", "text/html; charset=utf-8")
1211
+ res.setHeader("Content-Disposition", `attachment; filename="${filename}"`)
1212
+ res.send(html)
1213
+ } catch (err) {
1214
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
1215
+ }
1216
+ })
1217
+
1218
+ // Export agent's session as JSONL (one JSON object per line).
1219
+ app.get("/api/participants/:id/export-jsonl", (req, res) => {
1220
+ const { id } = req.params
1221
+ const p = registry.get(id)
1222
+ if (!p) {
1223
+ res.status(404).json({ error: `unknown participant "${id}"` })
1224
+ return
1225
+ }
1226
+ try {
1227
+ const filePath = p.exportToJsonl()
1228
+ const jsonl = readFileSync(filePath, "utf-8")
1229
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5)
1230
+ const filename = `${id}-${timestamp}.jsonl`
1231
+ res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8")
1232
+ res.setHeader("Content-Disposition", `attachment; filename="${filename}"`)
1233
+ res.send(jsonl)
1234
+ } catch (err) {
1235
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
1236
+ }
1237
+ })
1238
+
1239
+ app.post("/api/abort", async (_req, res) => {
1240
+ const aborted = await room.abortCurrent()
1241
+ res.json({ aborted })
1242
+ })
1243
+
1244
+ // Steer a running agent mid-turn.
1245
+ app.post("/api/messages/steer", async (req, res) => {
1246
+ const { text, target } = req.body
1247
+ if (!text || !target) {
1248
+ res.status(400).json({ error: "`text` and `target` are required" })
1249
+ return
1250
+ }
1251
+ try {
1252
+ await room.steer(target, String(text).trim())
1253
+ res.json({ ok: true, target, text: String(text).trim() })
1254
+ } catch (err) {
1255
+ const msg = err instanceof Error ? err.message : String(err)
1256
+ if (msg.includes("not running") || msg.includes("cannot steer")) {
1257
+ res.status(409).json({ error: msg })
1258
+ } else if (msg.includes("unknown participant")) {
1259
+ res.status(404).json({ error: msg })
1260
+ } else {
1261
+ res.status(500).json({ error: msg })
1262
+ }
1263
+ }
1264
+ })
1265
+
1266
+ // ── Room management API ─────────────────────────────────────────────────
1267
+
1268
+ /** Resolve the room for this request — falls back to "default" if roomId absent. */
1269
+ function roomOf(req: any): Room {
1270
+ const id = (req.params?.roomId as string | undefined) ?? "default"
1271
+ const r = roomManager.getRoom(id)
1272
+ if (!r) throw new Error(`room "${id}" not found`)
1273
+ return r
1274
+ }
1275
+
1276
+ /** Middleware: verify room exists before handing off to a route handler. */
1277
+ function requireRoom(req: any, res: any, next: any): void {
1278
+ try { roomOf(req); next() } catch { res.status(404).json({ error: `room "${req.params?.roomId}" not found` }) }
1279
+ }
1280
+
1281
+ // Room CRUD — process-global (not room-scoped)
1282
+ app.get("/api/rooms", (_req, res) => {
1283
+ res.json(roomManager.listRooms())
1284
+ })
1285
+
1286
+ // MUST precede "/api/rooms/:roomId" — otherwise Express binds "resumable" as a
1287
+ // roomId. Lists rooms with on-disk data that aren't currently open (resume
1288
+ // candidates), including legacy orphans created before meta.json existed.
1289
+ app.get("/api/rooms/resumable", async (_req, res) => {
1290
+ res.json(await roomManager.listResumableRooms())
1291
+ })
1292
+
1293
+ app.get("/api/rooms/:roomId", (req, res) => {
1294
+ const details = roomManager.getRoomDetails(req.params.roomId)
1295
+ if (!details) {
1296
+ res.status(404).json({ error: `room "${req.params.roomId}" not found` })
1297
+ return
1298
+ }
1299
+ res.json(details)
1300
+ })
1301
+
1302
+ app.patch("/api/rooms/:roomId", (req, res) => {
1303
+ const { roomId } = req.params
1304
+ const name = String(req.body?.name ?? "").trim()
1305
+ if (!name) {
1306
+ res.status(400).json({ error: "`name` is required" })
1307
+ return
1308
+ }
1309
+ const renamed = roomManager.renameRoom(roomId, name)
1310
+ if (!renamed) {
1311
+ res.status(404).json({ error: `room "${roomId}" not found` })
1312
+ return
1313
+ }
1314
+ hub.broadcast("room", { type: "renamed", roomId, name })
1315
+ res.json({ roomId, name })
1316
+ })
1317
+
1318
+ app.post("/api/rooms", async (req, res) => {
1319
+ try {
1320
+ const summary = await provisionRoom({
1321
+ roomId: req.body?.roomId ? String(req.body.roomId) : undefined,
1322
+ name: String(req.body?.name ?? "").trim(),
1323
+ preset: req.body?.preset ? String(req.body.preset).trim() : undefined,
1324
+ goal: req.body?.goal ? String(req.body.goal).trim() : undefined,
1325
+ workspaceDir: req.body?.workspaceDir ? String(req.body.workspaceDir).trim() : undefined,
1326
+ goalMode: req.body?.goalMode === "eval" ? "eval" : undefined,
1327
+ goalEvaluator: req.body?.goalEvaluator ? String(req.body.goalEvaluator).trim() : undefined,
1328
+ maxGoalIterations: typeof req.body?.maxGoalIterations === "number" ? req.body.maxGoalIterations : undefined,
1329
+ })
1330
+ res.status(201).json(summary)
1331
+ } catch (err) {
1332
+ if (err instanceof RoomProvisionError) {
1333
+ const body: { error: string; details?: unknown } = { error: err.message }
1334
+ if (err.details !== undefined) body.details = err.details
1335
+ res.status(err.status).json(body)
1336
+ return
1337
+ }
1338
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
1339
+ }
1340
+ })
1341
+
1342
+ app.delete("/api/rooms/:roomId", async (req, res) => {
1343
+ const { roomId } = req.params
1344
+ if (roomId === "default") {
1345
+ res.status(400).json({ error: "cannot delete the default room" })
1346
+ return
1347
+ }
1348
+ // destroyRoom unmounts the room's sshfs mount (if any) before removal.
1349
+ const removed = await roomManager.destroyRoom(roomId)
1350
+ if (!removed) {
1351
+ res.status(404).json({ error: `room "${roomId}" not found` })
1352
+ return
1353
+ }
1354
+ hub.broadcast("room", { type: "destroyed", roomId })
1355
+ res.status(204).end()
1356
+ })
1357
+
1358
+ // Resume a room that exists on disk but isn't live (destroyed/closed). Reuses
1359
+ // provisionRoom with the durable name/scope from meta.json; the room's init()
1360
+ // reloads its saved transcript + roster. Registered as a top-level route (not
1361
+ // via the room-scoped router, whose requireRoom guard would 404 a non-live room).
1362
+ app.post("/api/rooms/:roomId/resume", async (req, res) => {
1363
+ const { roomId } = req.params
1364
+ if (roomId === "default") {
1365
+ res.status(400).json({ error: "the default room is always open" })
1366
+ return
1367
+ }
1368
+ if (roomManager.getRoom(roomId)) {
1369
+ res.status(409).json({ error: `room "${roomId}" is already open` })
1370
+ return
1371
+ }
1372
+ const target = (await roomManager.listResumableRooms()).find((r) => r.roomId === roomId)
1373
+ if (!target) {
1374
+ res.status(404).json({ error: `no resumable data for room "${roomId}"` })
1375
+ return
1376
+ }
1377
+ try {
1378
+ const summary = await provisionRoom({
1379
+ roomId,
1380
+ name: target.name,
1381
+ workspaceDir: target.workspaceDir,
1382
+ })
1383
+ res.status(200).json(summary)
1384
+ } catch (err) {
1385
+ if (err instanceof RoomProvisionError) {
1386
+ const body: { error: string; details?: unknown } = { error: err.message }
1387
+ if (err.details !== undefined) body.details = err.details
1388
+ res.status(err.status).json(body)
1389
+ return
1390
+ }
1391
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
1392
+ }
1393
+ })
1394
+
1395
+ // ── Room-scoped router (/api/rooms/:roomId/*) ─────────────────────────────
1396
+ // Mirrors the legacy /api/* routes but resolves room dynamically via roomOf().
1397
+ // Existing /api/* routes are unchanged — backward compat is preserved.
1398
+
1399
+ const roomRouter = Router({ mergeParams: true })
1400
+
1401
+ roomRouter.get("/participants", (req, res) => {
1402
+ res.json(roomOf(req).getRegistry().roster())
1403
+ })
1404
+
1405
+ roomRouter.post("/participants", async (req, res) => {
1406
+ const reg = roomOf(req).getRegistry()
1407
+ try {
1408
+ const persona = parsePersona(req.body ?? {})
1409
+ if (reg.has(persona.id)) {
1410
+ res.status(409).json({ error: `participant "${persona.id}" already exists` })
1411
+ return
1412
+ }
1413
+ await reg.create(persona)
1414
+ res.status(201).json(reg.roster().find((r) => r.id === persona.id))
1415
+ } catch (err) {
1416
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) })
1417
+ }
1418
+ })
1419
+
1420
+ roomRouter.post("/participants/from-template", async (req, res) => {
1421
+ try {
1422
+ const item = await addFromTemplate(roomOf(req), String(req.body?.templateId ?? ""))
1423
+ res.status(201).json(item)
1424
+ } catch (err) {
1425
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) })
1426
+ }
1427
+ })
1428
+
1429
+ roomRouter.post("/participants/reorder", (req, res) => {
1430
+ const order = req.body?.order
1431
+ if (!Array.isArray(order) || !order.every((x: unknown) => typeof x === "string")) {
1432
+ res.status(400).json({ error: "`order` must be an array of participant ids" })
1433
+ return
1434
+ }
1435
+ const reg = roomOf(req).getRegistry()
1436
+ reg.reorder(order as string[])
1437
+ res.json(reg.roster())
1438
+ })
1439
+
1440
+ roomRouter.get("/participants/:id", (req, res) => {
1441
+ const p = roomOf(req).getRegistry().get(req.params.id)
1442
+ if (!p) {
1443
+ res.status(404).json({ error: `unknown participant "${req.params.id}"` })
1444
+ return
1445
+ }
1446
+ res.json({ ...p.persona, availableThinkingLevels: p.getAvailableThinkingLevels() })
1447
+ })
1448
+
1449
+ roomRouter.patch("/participants/:id", async (req, res) => {
1450
+ const r = roomOf(req)
1451
+ const reg = r.getRegistry()
1452
+ const { id } = req.params
1453
+ if (!reg.has(id)) {
1454
+ res.status(404).json({ error: `unknown participant "${id}"` })
1455
+ return
1456
+ }
1457
+ const body = req.body ?? {}
1458
+ const patch: Record<string, unknown> = {}
1459
+ if (typeof body.name === "string" && body.name.trim()) patch.name = body.name.trim()
1460
+ if (typeof body.systemPrompt === "string" && body.systemPrompt.trim())
1461
+ patch.systemPrompt = body.systemPrompt.trim()
1462
+ if (typeof body.color === "string") patch.color = body.color
1463
+ if (typeof body.icon === "string") patch.icon = body.icon
1464
+ if (Array.isArray(body.tools))
1465
+ patch.tools = body.tools.map(String).filter((t: string) => VALID_TOOLS.has(t))
1466
+ const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh"])
1467
+ if ("thinkingLevel" in body) {
1468
+ const tv = body.thinkingLevel
1469
+ if (tv === null || tv === "") {
1470
+ patch.thinkingLevel = undefined
1471
+ } else if (typeof tv === "string" && VALID_THINKING.has(tv)) {
1472
+ patch.thinkingLevel = tv
1473
+ } else {
1474
+ res.status(400).json({ error: `invalid thinkingLevel "${String(tv)}" — must be one of: off, minimal, low, medium, high, xhigh` })
1475
+ return
1476
+ }
1477
+ }
1478
+ if ("model" in body) {
1479
+ const mv = body.model
1480
+ if (mv === null || mv === "") {
1481
+ patch.model = undefined
1482
+ } else if (typeof mv === "string" && reg.isAllowedModel(mv)) {
1483
+ patch.model = mv
1484
+ } else {
1485
+ res.status(400).json({
1486
+ error: r.getAllowCloud()
1487
+ ? `unknown model "${String(mv)}"`
1488
+ : `model "${String(mv)}" unavailable — cloud is disabled (toggle in Settings)`,
1489
+ })
1490
+ return
1491
+ }
1492
+ }
1493
+ if ("compactionInstructions" in body) {
1494
+ const ci = body.compactionInstructions
1495
+ if (ci === null || ci === "") {
1496
+ patch.compactionInstructions = undefined
1497
+ } else if (typeof ci === "string" && ci.length <= 500) {
1498
+ patch.compactionInstructions = ci
1499
+ } else if (typeof ci === "string") {
1500
+ res.status(400).json({ error: `compactionInstructions too long (${ci.length} chars, max 500)` })
1501
+ return
1502
+ } else {
1503
+ res.status(400).json({ error: "compactionInstructions must be a string" })
1504
+ return
1505
+ }
1506
+ }
1507
+ try {
1508
+ if (typeof body.active === "boolean") reg.setActive(id, body.active)
1509
+ if (typeof body.parallel === "boolean") reg.setParallel(id, body.parallel)
1510
+ if (Object.keys(patch).length > 0) {
1511
+ if (Object.keys(patch).length === 1 && "thinkingLevel" in patch && patch.thinkingLevel !== undefined) {
1512
+ await reg.setThinkingLevel(id, patch.thinkingLevel as "off" | "minimal" | "low" | "medium" | "high" | "xhigh")
1513
+ } else {
1514
+ if (r.isBusy()) {
1515
+ res.status(409).json({ error: "a turn is running — press Stop before editing an agent" })
1516
+ return
1517
+ }
1518
+ await reg.update(id, patch)
1519
+ }
1520
+ }
1521
+ res.json(reg.roster().find((ri) => ri.id === id))
1522
+ } catch (err) {
1523
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) })
1524
+ }
1525
+ })
1526
+
1527
+ roomRouter.delete("/participants/:id", (req, res) => {
1528
+ const reg = roomOf(req).getRegistry()
1529
+ const { id } = req.params
1530
+ if (!reg.has(id)) {
1531
+ res.status(404).json({ error: `unknown participant "${id}"` })
1532
+ return
1533
+ }
1534
+ reg.kick(id)
1535
+ res.status(204).end()
1536
+ })
1537
+
1538
+ roomRouter.get("/transcript", (req, res) => {
1539
+ res.json(roomOf(req).getTranscript())
1540
+ })
1541
+
1542
+ // Room-scoped workspace listing: initial snapshot for the WorkspacePanel.
1543
+ // Live updates arrive over SSE; this serves the first paint per room.
1544
+ roomRouter.get("/workspace", async (req, res) => {
1545
+ res.json(await roomOf(req).getWorkspaceListing())
1546
+ })
1547
+
1548
+ roomRouter.get("/settings", (req, res) => {
1549
+ const r = roomOf(req)
1550
+ res.json(settingsPayload(r))
1551
+ })
1552
+
1553
+ roomRouter.patch("/settings", (req, res) => {
1554
+ const r = roomOf(req)
1555
+ const body = req.body ?? {}
1556
+ if ("chaining" in body) {
1557
+ if (typeof body.chaining !== "boolean") {
1558
+ res.status(400).json({ error: "`chaining` must be a boolean" })
1559
+ return
1560
+ }
1561
+ r.setChaining(body.chaining)
1562
+ }
1563
+ if ("defaultAgent" in body) {
1564
+ const da = body.defaultAgent
1565
+ if (da !== null && typeof da !== "string") {
1566
+ res.status(400).json({ error: "`defaultAgent` must be a string id or null" })
1567
+ return
1568
+ }
1569
+ try { r.setDefaultAgent(da) } catch (err) {
1570
+ res.status(404).json({ error: err instanceof Error ? err.message : String(err) })
1571
+ return
1572
+ }
1573
+ }
1574
+ if ("fallbackAgent" in body) {
1575
+ const fa = body.fallbackAgent
1576
+ if (fa !== null && typeof fa !== "string") {
1577
+ res.status(400).json({ error: "`fallbackAgent` must be a string id or null" })
1578
+ return
1579
+ }
1580
+ try { r.setFallbackAgent(fa) } catch (err) {
1581
+ res.status(404).json({ error: err instanceof Error ? err.message : String(err) })
1582
+ return
1583
+ }
1584
+ }
1585
+ if ("maxChainHops" in body) {
1586
+ const n = body.maxChainHops
1587
+ if (typeof n !== "number" || n < 1 || n > 100) {
1588
+ res.status(400).json({ error: "`maxChainHops` must be a number between 1 and 100" })
1589
+ return
1590
+ }
1591
+ r.setMaxChainHops(n)
1592
+ }
1593
+ if ("routingMode" in body) {
1594
+ const m = body.routingMode
1595
+ if (m !== "auto" && m !== "semi" && m !== "manual") {
1596
+ res.status(400).json({ error: "`routingMode` must be 'auto', 'semi', or 'manual'" })
1597
+ return
1598
+ }
1599
+ r.setRoutingMode(m)
1600
+ }
1601
+ if ("circuitBreaker" in body) {
1602
+ if (typeof body.circuitBreaker !== "boolean") {
1603
+ res.status(400).json({ error: "`circuitBreaker` must be a boolean" })
1604
+ return
1605
+ }
1606
+ r.setCircuitBreaker(body.circuitBreaker)
1607
+ }
1608
+ if ("defaultThinkingLevel" in body) {
1609
+ const validLevels = ["off", "minimal", "low", "medium", "high", "xhigh"]
1610
+ if (!validLevels.includes(body.defaultThinkingLevel)) {
1611
+ res.status(400).json({ error: "`defaultThinkingLevel` must be one of: " + validLevels.join(", ") })
1612
+ return
1613
+ }
1614
+ r.setDefaultThinkingLevel(body.defaultThinkingLevel)
1615
+ }
1616
+ if ("allowCloud" in body) {
1617
+ if (typeof body.allowCloud !== "boolean") {
1618
+ res.status(400).json({ error: "`allowCloud` must be a boolean" })
1619
+ return
1620
+ }
1621
+ r.setAllowCloud(body.allowCloud)
1622
+ }
1623
+ if ("compactionReserveTokens" in body) {
1624
+ const v = Number(body.compactionReserveTokens)
1625
+ if (!Number.isFinite(v) || v < 5000 || v > 100000) {
1626
+ res.status(400).json({ error: "`compactionReserveTokens` must be an integer between 5000 and 100000" })
1627
+ return
1628
+ }
1629
+ r.setCompactionReserveTokens(v)
1630
+ }
1631
+ res.json(settingsPayload(r))
1632
+ })
1633
+
1634
+ roomRouter.post("/route", (req, res) => {
1635
+ const decision = parseRouteDecision(req.body ?? {})
1636
+ if (!decision) {
1637
+ res.status(400).json({ error: "`action` must be 'approve', 'redirect', or 'drop'" })
1638
+ return
1639
+ }
1640
+ roomOf(req).resolveRoute(decision)
1641
+ res.status(202).json({ accepted: true })
1642
+ })
1643
+
1644
+ // Room-scoped preset actions — target the room in the URL, not the default.
1645
+ roomRouter.post("/presets", async (req, res) => {
1646
+ const name = String(req.body?.name ?? "").trim().replace(/[^a-zA-Z0-9_-]/g, "")
1647
+ await doSavePreset(roomOf(req), name, res)
1648
+ })
1649
+ roomRouter.post("/presets/:name/load", (req, res) => doLoadPreset(roomOf(req), req.params.name, res))
1650
+ roomRouter.post("/presets/:name/apply", (req, res) => doApplyPreset(roomOf(req), req.params.name, res))
1651
+
1652
+ roomRouter.post("/messages", rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false }), async (req, res) => {
1653
+ const text = String(req.body?.text ?? "").trim()
1654
+ if (!text) {
1655
+ res.status(400).json({ error: "`text` is required" })
1656
+ return
1657
+ }
1658
+ const images = await saveIncomingImages(req.body?.images)
1659
+ roomOf(req).submit(text, images.length > 0 ? images : undefined)
1660
+ res.status(202).json({ accepted: true })
1661
+ })
1662
+
1663
+ roomRouter.post("/messages/steer", async (req, res) => {
1664
+ const { text, target } = req.body
1665
+ if (!text || !target) {
1666
+ res.status(400).json({ error: "`text` and `target` are required" })
1667
+ return
1668
+ }
1669
+ try {
1670
+ await roomOf(req).steer(target, String(text).trim())
1671
+ res.json({ ok: true, target, text: String(text).trim() })
1672
+ } catch (err) {
1673
+ const msg = err instanceof Error ? err.message : String(err)
1674
+ if (msg.includes("not running") || msg.includes("cannot steer")) {
1675
+ res.status(409).json({ error: msg })
1676
+ } else if (msg.includes("unknown participant")) {
1677
+ res.status(404).json({ error: msg })
1678
+ } else {
1679
+ res.status(500).json({ error: msg })
1680
+ }
1681
+ }
1682
+ })
1683
+
1684
+ roomRouter.post("/abort", async (req, res) => {
1685
+ const aborted = await roomOf(req).abortCurrent()
1686
+ res.json({ aborted })
1687
+ })
1688
+
1689
+ roomRouter.post("/participants/:id/compact", async (req, res) => {
1690
+ const r = roomOf(req)
1691
+ const p = r.getRegistry().get(req.params.id)
1692
+ if (!p) {
1693
+ res.status(404).json({ error: `unknown participant "${req.params.id}"` })
1694
+ return
1695
+ }
1696
+ if (r.isBusy()) {
1697
+ res.status(409).json({ error: "a turn is running — press Stop before compacting" })
1698
+ return
1699
+ }
1700
+ try {
1701
+ const result = await p.compact()
1702
+ res.json(result)
1703
+ } catch (err) {
1704
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
1705
+ }
1706
+ })
1707
+
1708
+ roomRouter.get("/participants/:id/export", async (req, res) => {
1709
+ const p = roomOf(req).getRegistry().get(req.params.id)
1710
+ if (!p) {
1711
+ res.status(404).json({ error: `unknown participant "${req.params.id}"` })
1712
+ return
1713
+ }
1714
+ try {
1715
+ const filePath = await p.exportToHtml()
1716
+ const html = readFileSync(filePath, "utf-8")
1717
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5)
1718
+ const filename = `${req.params.id}-${timestamp}.html`
1719
+ res.setHeader("Content-Type", "text/html; charset=utf-8")
1720
+ res.setHeader("Content-Disposition", `attachment; filename="${filename}"`)
1721
+ res.send(html)
1722
+ } catch (err) {
1723
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
1724
+ }
1725
+ })
1726
+
1727
+ roomRouter.get("/participants/:id/export-jsonl", (req, res) => {
1728
+ const p = roomOf(req).getRegistry().get(req.params.id)
1729
+ if (!p) {
1730
+ res.status(404).json({ error: `unknown participant "${req.params.id}"` })
1731
+ return
1732
+ }
1733
+ try {
1734
+ const filePath = p.exportToJsonl()
1735
+ const jsonl = readFileSync(filePath, "utf-8")
1736
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5)
1737
+ const filename = `${req.params.id}-${timestamp}.jsonl`
1738
+ res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8")
1739
+ res.setHeader("Content-Disposition", `attachment; filename="${filename}"`)
1740
+ res.send(jsonl)
1741
+ } catch (err) {
1742
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) })
1743
+ }
1744
+ })
1745
+
1746
+ // Per-room SSE stream — filtered to only this room's events via the roomId
1747
+ // passed to addClient(). Every room-scoped broadcast carries its roomId param
1748
+ // (object and array payloads alike), so the hub delivers only this room's
1749
+ // events here, plus global events that carry no roomId (room lifecycle, etc.).
1750
+ // The initial roster/transcript snapshot is written directly to this response.
1751
+ roomRouter.get("/events", (req, res) => {
1752
+ const r = roomOf(req)
1753
+ hub.addClient(res, r.roomId) // register with room filter
1754
+ // Deliver initial state directly to this client (bypasses broadcast filter)
1755
+ const roster = r.getRegistry().roster()
1756
+ res.write(`event: roster\ndata: ${JSON.stringify(roster)}\n\n`)
1757
+ res.write(`event: transcript\ndata: ${JSON.stringify(r.getTranscript())}\n\n`)
1758
+ })
1759
+
1760
+ roomRouter.get("/conversations", async (req, res) => {
1761
+ res.json(await roomOf(req).getConversations())
1762
+ })
1763
+
1764
+ roomRouter.post("/conversations", async (req, res) => {
1765
+ try {
1766
+ const title = req.body?.title ? String(req.body.title) : undefined
1767
+ res.status(201).json(await roomOf(req).newConversation(title))
1768
+ } catch (err) {
1769
+ res.status(409).json({ error: err instanceof Error ? err.message : String(err) })
1770
+ }
1771
+ })
1772
+
1773
+ roomRouter.post("/conversations/:id/load", async (req, res) => {
1774
+ try {
1775
+ await roomOf(req).switchConversation(req.params.id)
1776
+ res.json({ ok: true })
1777
+ } catch (err) {
1778
+ const msg = err instanceof Error ? err.message : String(err)
1779
+ res.status(msg.includes("unknown") ? 404 : 409).json({ error: msg })
1780
+ }
1781
+ })
1782
+
1783
+ roomRouter.patch("/conversations/:id", async (req, res) => {
1784
+ const title = String(req.body?.title ?? "").trim()
1785
+ if (!title) {
1786
+ res.status(400).json({ error: "`title` is required" })
1787
+ return
1788
+ }
1789
+ try {
1790
+ await roomOf(req).renameConversation(req.params.id, title)
1791
+ res.json({ ok: true })
1792
+ } catch (err) {
1793
+ res.status(404).json({ error: err instanceof Error ? err.message : String(err) })
1794
+ }
1795
+ })
1796
+
1797
+ roomRouter.delete("/conversations/:id", async (req, res) => {
1798
+ try {
1799
+ await roomOf(req).deleteConversation(req.params.id)
1800
+ res.status(204).end()
1801
+ } catch (err) {
1802
+ res.status(409).json({ error: err instanceof Error ? err.message : String(err) })
1803
+ }
1804
+ })
1805
+
1806
+ // Mount the room-scoped router. CRUD routes above must come before this.
1807
+ app.use("/api/rooms/:roomId", requireRoom, roomRouter)
1808
+
1809
+ // Serve the built web UI when present (web/dist relative to the package
1810
+ // root, not the cwd — `npx pipeline-moe serve` runs from anywhere). In dev
1811
+ // the Vite server on :5310 is used instead and this directory is absent.
1812
+ const webDist = resolve(packageRoot(), "web", "dist")
1813
+ try {
1814
+ readFileSync(join(webDist, "index.html"))
1815
+ app.use(express.static(webDist))
1816
+ // SPA fallback: any non-API GET serves the app shell.
1817
+ app.get(/^\/(?!api\/).*/, (_req, res) => {
1818
+ res.sendFile(join(webDist, "index.html"))
1819
+ })
1820
+ console.log(`[server] serving web UI from ${webDist}`)
1821
+ } catch {
1822
+ // no built web UI — API only
1823
+ }
1824
+
1825
+ const server = app.listen(config.port, "127.0.0.1", () => {
1826
+ console.log(`[server] Pipeline-MoE listening on http://localhost:${config.port}`)
1827
+ console.log(`[server] workspace: ${config.workspaceDir}`)
1828
+ })
1829
+
1830
+ const shutdown = async () => {
1831
+ console.log("\n[server] shutting down…")
1832
+ registry.disposeAll()
1833
+ // Unmount any sshfs-scoped rooms so a normal shutdown leaves no orphaned
1834
+ // FUSE mounts. Best-effort — never block shutdown on a failed unmount.
1835
+ await roomManager.cleanupAllMounts().catch(() => {})
1836
+ server.close(() => process.exit(0))
1837
+ setTimeout(() => process.exit(0), 2000).unref()
1838
+ }
1839
+ process.on("SIGINT", shutdown)
1840
+ process.on("SIGTERM", shutdown)
1841
+ }
1842
+
1843
+ main().catch((err) => {
1844
+ console.error("[fatal]", err)
1845
+ process.exit(1)
1846
+ })