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.
- package/.env.example +26 -0
- package/LICENSE +21 -0
- package/README.md +408 -0
- package/bin/pipeline-moe.mjs +40 -0
- package/package.json +68 -0
- package/presets/2106BUILD.json +163 -0
- package/presets/CHEAPBUILD.json +97 -0
- package/presets/FREEROOM.json +96 -0
- package/presets/Versa.json +145 -0
- package/presets/cloud-main.json +76 -0
- package/presets/cloud-sprint.json +70 -0
- package/presets/local-default.json +152 -0
- package/presets/main.json +147 -0
- package/presets/mainmix.json +145 -0
- package/src/circuit-breaker.ts +141 -0
- package/src/config.ts +46 -0
- package/src/custom-tools/arxiv-search.ts +186 -0
- package/src/custom-tools/check-room.ts +45 -0
- package/src/custom-tools/destroy-room.ts +45 -0
- package/src/custom-tools/index.ts +75 -0
- package/src/custom-tools/spawn-room.ts +99 -0
- package/src/custom-tools/stop-room.ts +50 -0
- package/src/custom-tools/web-read.ts +104 -0
- package/src/custom-tools/web-search.ts +144 -0
- package/src/custom-tools/youcom-search.ts +230 -0
- package/src/custom-tools/youtube-transcript.ts +124 -0
- package/src/local-model-lock.ts +52 -0
- package/src/model.ts +131 -0
- package/src/orchestrator.ts +59 -0
- package/src/participant.ts +423 -0
- package/src/path-guard.ts +13 -0
- package/src/personas.ts +480 -0
- package/src/receipts.ts +120 -0
- package/src/registry.ts +317 -0
- package/src/room-manager.ts +505 -0
- package/src/room.ts +1657 -0
- package/src/sandbox-tools.ts +141 -0
- package/src/server.ts +1846 -0
- package/src/sse.ts +86 -0
- package/src/sshfs.ts +139 -0
- package/src/store.ts +79 -0
- package/src/types.ts +131 -0
- package/src/validation.ts +36 -0
- package/tsconfig.json +15 -0
- package/web/dist/assets/index-CmMGhMKG.css +1 -0
- package/web/dist/assets/index-LAGqbZII.js +41 -0
- package/web/dist/index.html +13 -0
- 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
|
+
})
|