loopat 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 (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +194 -0
  3. package/bin/loopat.mjs +65 -0
  4. package/package.json +52 -0
  5. package/server/package.json +22 -0
  6. package/server/src/api-tokens.ts +161 -0
  7. package/server/src/api-v1-openapi.ts +363 -0
  8. package/server/src/api-v1.ts +681 -0
  9. package/server/src/auth.ts +309 -0
  10. package/server/src/bootstrap.ts +113 -0
  11. package/server/src/chat.ts +390 -0
  12. package/server/src/claude-binary.ts +68 -0
  13. package/server/src/compose.ts +474 -0
  14. package/server/src/config.ts +783 -0
  15. package/server/src/files.ts +173 -0
  16. package/server/src/git-crypt-key.ts +36 -0
  17. package/server/src/git-host.ts +104 -0
  18. package/server/src/github.ts +161 -0
  19. package/server/src/index.ts +3204 -0
  20. package/server/src/kanban.ts +810 -0
  21. package/server/src/loop-stats.ts +225 -0
  22. package/server/src/loop-status.ts +67 -0
  23. package/server/src/loops.ts +1832 -0
  24. package/server/src/mcp-oauth.ts +516 -0
  25. package/server/src/onboarding.ts +105 -0
  26. package/server/src/paths.ts +190 -0
  27. package/server/src/personal-keys.ts +60 -0
  28. package/server/src/plugin-installer.ts +287 -0
  29. package/server/src/podman.ts +1216 -0
  30. package/server/src/presets.ts +30 -0
  31. package/server/src/profiles.ts +177 -0
  32. package/server/src/providers.ts +45 -0
  33. package/server/src/serve.ts +275 -0
  34. package/server/src/session.ts +1496 -0
  35. package/server/src/system-prompt.ts +90 -0
  36. package/server/src/term.ts +211 -0
  37. package/server/src/tiers.ts +762 -0
  38. package/server/src/vaults.ts +189 -0
  39. package/server/src/workspace.ts +501 -0
  40. package/server/templates/.claude-plugin/marketplace.json +13 -0
  41. package/server/templates/CLAUDE.md +78 -0
  42. package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
  43. package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
  44. package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
  45. package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
  46. package/server/templates/sandbox/Containerfile +113 -0
  47. package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
  48. package/web/dist/assets/Editor-DMS25Vve.js +1 -0
  49. package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
  50. package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
  51. package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
  52. package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
  53. package/web/dist/assets/index-DM5eO-Tv.js +163 -0
  54. package/web/dist/assets/index-DxIFezwv.css +1 -0
  55. package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
  56. package/web/dist/favicon.svg +1 -0
  57. package/web/dist/index.html +14 -0
  58. package/web/dist/logo.png +0 -0
@@ -0,0 +1,3204 @@
1
+ import { Hono } from "hono"
2
+ import { cors } from "hono/cors"
3
+ import { createBunWebSocket } from "hono/bun"
4
+ import { existsSync } from "node:fs"
5
+ import { execSync, execFile } from "node:child_process"
6
+ import { promisify } from "node:util"
7
+ import { listLoops, createLoop, getLoop, loopExists, patchLoopMeta, backfillAllMounts, ensureWorkspaceDirs, provisionUserPersonal, importPersonalFromRepo, setupPersonalViaProvider, listPersonalReposViaProvider, authenticateViaProvider, providerTokenHelp, isPersonalFresh, ensureUiNotesWorktree, syncUiNotes, inspectPersonalDirty, syncPersonalToRemote, deletePersonalVault, pullPersonalFromRemote, pushPersonalToRemote, ensureContextMounts, effectiveDriver, isDriver, distillLoop, inspectRepoSync, pullRepoFromRemote, pushRepoToRemote } from "./loops"
8
+ import { getEphemeralHostPort } from "./podman"
9
+ import { getOnboardingStatus, startOnboardingLoop, markOnboardingDone } from "./onboarding"
10
+ import { startMcpAuth, completeMcpAuth, probeOAuthSupport, evictOAuthProbe, parseBearerEnvName, type OAuthSupport } from "./mcp-oauth"
11
+ import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults"
12
+ import {
13
+ initChat,
14
+ listChannels,
15
+ createChannel,
16
+ deleteChannel,
17
+ getOrCreateDm,
18
+ getConv,
19
+ userCanAccess,
20
+ listConversationsForUser,
21
+ listMessages,
22
+ listThread,
23
+ postMessage,
24
+ markRead,
25
+ snapshotThreadToJsonl,
26
+ } from "./chat"
27
+ import { loopContextChatDir } from "./paths"
28
+ import { join as pathJoin, dirname } from "node:path"
29
+ import { ensurePersonalKeypair, getPublicKey } from "./personal-keys"
30
+ // `destroySession` here clashes with auth's session-token destroyer; alias to
31
+ // keep both callable without import-order-dependent shadowing.
32
+ import { getSession, destroySession as destroyLoopSession, restartSession, getActivitySnapshot } from "./session"
33
+ import { listDir, listDirRecursive, readWorkdirFile, writeWorkdirFile, deleteWorkdirFile, createWorkdirFolder } from "./files"
34
+ import { vaultList, vaultFlatList, vaultRead, vaultWrite, vaultCreateFile, vaultCreateFolder, vaultDelete, vaultBacklinks, listRepos, readRepoDetail, pullRepo, addRepo, listTopics, type VaultId } from "./workspace"
35
+ // sandboxes module removed — no /api/sandboxes/* routes in the profile model.
36
+ // Use /api/profiles + /api/personal/default-profiles instead.
37
+ import { attachTerm, detachTerm, writeTerm, resizeTerm, killTerm } from "./term"
38
+ import {
39
+ LOOPAT_HOME,
40
+ LOOPAT_INSTALL_DIR,
41
+ WORKSPACE,
42
+ loopContextKnowledge,
43
+ loopContextNotes,
44
+ loopContextPersonal,
45
+ loopContextRepos,
46
+ loopWorkdir,
47
+ loopHistoryPath,
48
+ loopChatHistoryPath,
49
+ workspaceKnowledgeDir,
50
+ workspaceNotesDir,
51
+ workspaceRepoDir,
52
+ workspaceReposDir,
53
+ loopsDir,
54
+ } from "./paths"
55
+ import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, type ProviderConfig, type ModelEntry } from "./config"
56
+ import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard } from "./kanban"
57
+ import { printBootstrapBanner } from "./bootstrap"
58
+ import {
59
+ createUser,
60
+ findUser,
61
+ setPersonalRepo,
62
+ verifyPassword,
63
+ createSession,
64
+ destroySession,
65
+ setSessionCookie,
66
+ clearSessionCookie,
67
+ getRequestUserId,
68
+ requireAuth,
69
+ requireAdmin,
70
+ COOKIE_NAME,
71
+ isValidUsername,
72
+ listUsers,
73
+ activateUser,
74
+ setUserRole,
75
+ deleteUser,
76
+ } from "./auth"
77
+ import { getCookie } from "hono/cookie"
78
+
79
+ const execFileP = promisify(execFile)
80
+
81
+ const { upgradeWebSocket, websocket } = createBunWebSocket()
82
+
83
+ // ── Kanban real-time hub ──
84
+
85
+ type KanbanSubscriber = { ws: any; userId: string }
86
+ const kanbanSubscribers = new Set<KanbanSubscriber>()
87
+
88
+ function kanbanBroadcast(msg: object) {
89
+ const payload = JSON.stringify(msg)
90
+ for (const sub of kanbanSubscribers) {
91
+ try { sub.ws.send(payload) } catch {}
92
+ }
93
+ }
94
+
95
+ function kanbanNotify() {
96
+ kanbanBroadcast({ type: "kanban_update" })
97
+ }
98
+
99
+ type Variables = { userId: string }
100
+ export const app = new Hono<{ Variables: Variables }>()
101
+
102
+ app.use("/api/*", cors({ origin: (o) => o ?? "*", credentials: true }))
103
+
104
+ // public routes
105
+ app.get("/api/health", (c) => c.json({ ok: true, loopatHome: LOOPAT_HOME, workspace: WORKSPACE }))
106
+
107
+ app.get("/api/version", (c) => {
108
+ let branch = "unknown", commit = "unknown"
109
+ try { branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim() } catch {}
110
+ try { commit = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim() } catch {}
111
+ return c.json({ branch, commit })
112
+ })
113
+
114
+ // Loop API v1 — see docs/api-v1.md. Public surface for bot frameworks + the
115
+ // web app's chat experience. All other web features stay on internal WS/REST.
116
+ import { buildApiV1 } from "./api-v1"
117
+ app.route("/api/v1", buildApiV1())
118
+
119
+ // ── workspace serve config ──
120
+
121
+ function getLocalIp(): string {
122
+ const nets = networkInterfaces()
123
+ for (const name of Object.keys(nets)) {
124
+ for (const net of nets[name] ?? []) {
125
+ if (!net.internal && net.family === "IPv4") return net.address
126
+ }
127
+ }
128
+ return "127.0.0.1"
129
+ }
130
+
131
+ app.get("/api/serve/config", requireAdmin, async (c) => {
132
+ const cfg = await loadConfig()
133
+ const domain = cfg.serveDomain ?? "nip.io"
134
+ const ip = getLocalIp()
135
+ const isNip = domain === "nip.io"
136
+ return c.json({
137
+ // Standard serve
138
+ serveEnabled: cfg.serveEnabled ?? true,
139
+ domain,
140
+ ip,
141
+ baseUrl: isNip ? `.${ip}.${domain}` : `.${domain}`,
142
+ withPort: cfg.serveWithPort ?? false,
143
+ https: cfg.serveHttps ?? false,
144
+ displayPort: cfg.serveDisplayPort ?? 7788,
145
+ // Dynamic port
146
+ serveDynamicEnabled: cfg.serveDynamicEnabled ?? false,
147
+ serveDynamicDomain: cfg.serveDynamicDomain ?? "",
148
+ serveDynamicPortRange: cfg.serveDynamicPortRange ?? "10000-20000",
149
+ serveDynamicUdpEnabled: cfg.serveDynamicUdpEnabled ?? false,
150
+ serveDynamicStaticEnabled: cfg.serveDynamicStaticEnabled ?? false,
151
+ // Ephemeral port: kernel-assigned host port per loop, changes on
152
+ // every loop restart. No port-proxy involved.
153
+ serveEphemeralEnabled: cfg.serveEphemeralEnabled ?? false,
154
+ serveEphemeralDomain: cfg.serveEphemeralDomain ?? "",
155
+ })
156
+ })
157
+
158
+ app.put("/api/serve/config", requireAdmin, async (c) => {
159
+ const body = await c.req.json().catch(() => ({}))
160
+ const patch: Record<string, unknown> = {}
161
+ if (typeof body.domain === "string" && body.domain.trim()) patch.serveDomain = body.domain.trim()
162
+ if (typeof body.withPort === "boolean") patch.serveWithPort = body.withPort
163
+ if (typeof body.https === "boolean") patch.serveHttps = body.https
164
+ if (typeof body.displayPort === "number") patch.serveDisplayPort = body.displayPort
165
+ if (typeof body.serveEnabled === "boolean") patch.serveEnabled = body.serveEnabled
166
+ if (typeof body.serveDynamicEnabled === "boolean") patch.serveDynamicEnabled = body.serveDynamicEnabled
167
+ if (typeof body.serveDynamicDomain === "string") patch.serveDynamicDomain = body.serveDynamicDomain.trim()
168
+ if (typeof body.serveDynamicPortRange === "string") patch.serveDynamicPortRange = body.serveDynamicPortRange.trim()
169
+ if (typeof body.serveDynamicUdpEnabled === "boolean") patch.serveDynamicUdpEnabled = body.serveDynamicUdpEnabled
170
+ if (typeof body.serveDynamicStaticEnabled === "boolean") patch.serveDynamicStaticEnabled = body.serveDynamicStaticEnabled
171
+ if (typeof body.serveEphemeralEnabled === "boolean") patch.serveEphemeralEnabled = body.serveEphemeralEnabled
172
+ if (typeof body.serveEphemeralDomain === "string") patch.serveEphemeralDomain = body.serveEphemeralDomain.trim()
173
+ if (Object.keys(patch).length === 0) return c.json({ error: "no fields to update" }, 400)
174
+ await saveWorkspaceConfig(patch)
175
+ return c.json({ ok: true })
176
+ })
177
+
178
+ app.get("/api/serve/alias-check", requireAuth, async (c) => {
179
+ const alias = (c.req.query("alias") ?? "").trim().toLowerCase()
180
+ const loopId = (c.req.query("loopId") ?? "").trim()
181
+ if (!alias) return c.json({ available: false, reason: "alias required" })
182
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(alias)) {
183
+ return c.json({ available: false, reason: "Only lowercase letters, numbers, and hyphens allowed" })
184
+ }
185
+ const allLoops = await listLoops()
186
+ for (const loop of allLoops) {
187
+ if (loop.id === loopId) continue
188
+ if (loop.id.slice(0, 8) === alias || loop.shareAlias === alias) {
189
+ return c.json({ available: false, reason: "Already in use" })
190
+ }
191
+ }
192
+ return c.json({ available: true })
193
+ })
194
+
195
+ // Return a random unused port from the dynamic port range, or null if none available.
196
+ app.get("/api/serve/available-port", requireAuth, async (c) => {
197
+ const cfg = await loadConfig()
198
+ const range = cfg.serveDynamicPortRange || "10000-20000"
199
+ const [lo, hi] = range.split("-").map(Number)
200
+ if (!lo || !hi || lo >= hi) return c.json({ port: null, error: "invalid port range" })
201
+
202
+ // Port-proxy maps the entire range — binding test would always fail.
203
+ // Just pick a port not already claimed by another ENABLED loop.
204
+ const used = new Set<number>()
205
+ try {
206
+ const all = await listLoops()
207
+ for (const loop of all) {
208
+ if (loop.shareEnabled && loop.shareExternalPort) used.add(loop.shareExternalPort)
209
+ }
210
+ } catch {}
211
+
212
+ for (let i = 0; i < 100; i++) {
213
+ const port = lo + Math.floor(Math.random() * (hi - lo + 1))
214
+ if (!used.has(port)) return c.json({ port })
215
+ }
216
+ return c.json({ port: null, error: "no available port in range" })
217
+ })
218
+
219
+ // Check if a specific port is available for use by a loop.
220
+ app.get("/api/serve/check-port", requireAuth, async (c) => {
221
+ const port = parseInt(c.req.query("port") ?? "")
222
+ const loopId = (c.req.query("loopId") ?? "").trim()
223
+ if (!port || port < 1 || port > 65535) return c.json({ available: false, reason: "Invalid port" })
224
+
225
+ const cfg = await loadConfig()
226
+ const range = cfg.serveDynamicPortRange || "10000-20000"
227
+ const [lo, hi] = range.split("-").map(Number)
228
+ if (port < lo || port > hi) return c.json({ available: false, reason: `Port outside configured range (${range})` })
229
+
230
+ // Check if another ENABLED loop already claims this port
231
+ try {
232
+ const all = await listLoops()
233
+ for (const loop of all) {
234
+ if (loop.id === loopId) continue
235
+ if (loop.shareEnabled && loop.shareExternalPort === port) {
236
+ return c.json({ available: false, reason: `Port ${port} is already used by another loop` })
237
+ }
238
+ }
239
+ } catch {}
240
+
241
+ return c.json({ available: true })
242
+ })
243
+
244
+ // ── providers (auth required) ──
245
+ // Merges personal + workspace configs. Personal providers take precedence
246
+ // (they carry per-user apiKeys via secrets/). Source field indicates origin.
247
+ app.get("/api/providers", requireAuth, async (c) => {
248
+ const wCfg = await loadConfig()
249
+ const providers: Record<string, { models: ModelEntry[]; baseUrl: string; source: "personal" | "workspace"; enabled: boolean; hasKey: boolean }> = {}
250
+ if (wCfg.providers) {
251
+ for (const [name, p] of Object.entries(wCfg.providers)) {
252
+ const hasKey = typeof p.apiKey === "string" && p.apiKey.length > 0
253
+ providers[name] = { models: p.models, baseUrl: p.baseUrl, source: "workspace", enabled: hasKey ? p.enabled : false, hasKey }
254
+ }
255
+ }
256
+ // Overlay personal providers (they take precedence)
257
+ let active = wCfg.default ?? ""
258
+ const userId = c.get("userId") as string
259
+ try {
260
+ const pCfg = await loadPersonalConfig(userId)
261
+ for (const [name, p] of Object.entries(pCfg.providers)) {
262
+ const hasKey = typeof p.apiKey === "string" && p.apiKey.length > 0
263
+ // Only overlay if the user actually configured this provider (has a key).
264
+ // Template/preset providers without a key should not shadow workspace config.
265
+ if (hasKey) {
266
+ providers[name] = { models: p.models, baseUrl: p.baseUrl, source: "personal", enabled: p.enabled !== false, hasKey }
267
+ }
268
+ }
269
+ active = pCfg.default || active
270
+ } catch {}
271
+ return c.json({ providers, default: active })
272
+ })
273
+
274
+ // Test a provider + model connection by making a minimal Messages API call.
275
+ // Accepts either a plain apiKey, or a provider name + source to resolve the
276
+ // key server-side (so tests work for stored/encrypted keys without re-typing).
277
+ app.post("/api/providers/test", requireAuth, async (c) => {
278
+ const body = await c.req.json().catch(() => ({}))
279
+ const { baseUrl, apiKey: rawApiKey, model, provider, source } = body
280
+ if (typeof baseUrl !== "string" || !baseUrl) return c.json({ ok: false, error: "baseUrl required" }, 400)
281
+ if (typeof model !== "string" || !model) return c.json({ ok: false, error: "model required" }, 400)
282
+
283
+ let apiKey = typeof rawApiKey === "string" ? rawApiKey.trim() : ""
284
+ // Resolve key server-side when a stored (encrypted) key is being tested
285
+ if (!apiKey && typeof provider === "string" && provider) {
286
+ if (source === "personal") {
287
+ const userId = c.get("userId") as string
288
+ try {
289
+ const pCfg = await loadPersonalConfig(userId)
290
+ apiKey = pCfg.providers[provider]?.apiKey ?? ""
291
+ } catch {}
292
+ } else if (source === "workspace") {
293
+ try {
294
+ const wCfg = await loadConfig()
295
+ apiKey = (wCfg.providers?.[provider] as any)?.apiKey ?? ""
296
+ } catch {}
297
+ }
298
+ }
299
+ if (!apiKey) return c.json({ ok: false, error: "no API key — enter one or store it first" }, 400)
300
+
301
+ try {
302
+ const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/messages`, {
303
+ method: "POST",
304
+ headers: {
305
+ "Content-Type": "application/json",
306
+ "x-api-key": apiKey,
307
+ "anthropic-version": "2023-06-01",
308
+ },
309
+ body: JSON.stringify({
310
+ model,
311
+ max_tokens: 1,
312
+ messages: [{ role: "user", content: "." }],
313
+ }),
314
+ })
315
+ if (!response.ok) {
316
+ const text = await response.text().catch(() => "")
317
+ return c.json({ ok: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` })
318
+ }
319
+ return c.json({ ok: true })
320
+ } catch (e: any) {
321
+ return c.json({ ok: false, error: e?.message ?? "connection failed" })
322
+ }
323
+ })
324
+
325
+ // ── auth (public) ──
326
+ app.post("/api/auth/register", async (c) => {
327
+ const body = await c.req.json().catch(() => ({}))
328
+ const username = typeof body.username === "string" ? body.username.trim().toLowerCase() : ""
329
+ const password = typeof body.password === "string" ? body.password : ""
330
+ const personalRepo = typeof body.personalRepo === "string" && body.personalRepo.trim()
331
+ ? body.personalRepo.trim()
332
+ : undefined
333
+ if (!isValidUsername(username)) return c.json({ error: "invalid username" }, 400)
334
+ if (!password) return c.json({ error: "password required" }, 400)
335
+ try {
336
+ const user = await createUser({ id: username, password, personalRepo })
337
+ // Scaffold personal/<user>/ (empty git init + memory stub) and generate a
338
+ // loopat-managed deploy keypair. NO clone here — server has no creds to
339
+ // pull a private repo. The UI shows publicKey + asks user to register it
340
+ // as a deploy key on `personalRepo`, then calls /api/personal/import.
341
+ const { publicKey } = await provisionUserPersonal(user.id)
342
+ // Only auto-login active accounts (the first-ever user). Pending accounts
343
+ // must wait for an admin to activate before they can log in.
344
+ if (user.status === "active") {
345
+ const token = createSession(user.id)
346
+ setSessionCookie(c, token)
347
+ }
348
+ return c.json({
349
+ user: { id: user.id, role: user.role, status: user.status },
350
+ publicKey,
351
+ personalRepo: user.personalRepo ?? null,
352
+ needsImport: user.status === "active" && !!user.personalRepo && !!publicKey,
353
+ })
354
+ } catch (e: any) {
355
+ return c.json({ error: e?.message ?? "register failed" }, 400)
356
+ }
357
+ })
358
+
359
+ app.post("/api/auth/login", async (c) => {
360
+ const body = await c.req.json().catch(() => ({}))
361
+ const username = typeof body.username === "string" ? body.username.trim().toLowerCase() : ""
362
+ const password = typeof body.password === "string" ? body.password : ""
363
+ if (!username || !password) return c.json({ error: "username + password required" }, 400)
364
+ const user = await findUser(username)
365
+ if (!user) return c.json({ error: "invalid credentials" }, 401)
366
+ const ok = await verifyPassword(password, user.salt, user.hash)
367
+ if (!ok) return c.json({ error: "invalid credentials" }, 401)
368
+ if (user.status !== "active") {
369
+ return c.json({ error: "account pending activation by an admin", status: user.status }, 403)
370
+ }
371
+ const token = createSession(user.id)
372
+ setSessionCookie(c, token)
373
+ return c.json({ user: { id: user.id, role: user.role, status: user.status } })
374
+ })
375
+
376
+ app.post("/api/auth/logout", async (c) => {
377
+ const token = getCookie(c, COOKIE_NAME)
378
+ if (token) destroySession(token)
379
+ clearSessionCookie(c)
380
+ return c.json({ ok: true })
381
+ })
382
+
383
+ app.get("/api/auth/me", async (c) => {
384
+ const userId = getRequestUserId(c)
385
+ if (!userId) return c.json({ error: "unauthorized" }, 401)
386
+ const user = await findUser(userId)
387
+ if (!user) return c.json({ error: "unauthorized" }, 401)
388
+ return c.json({ user: { id: user.id, role: user.role, status: user.status } })
389
+ })
390
+
391
+ // ── admin (requireAdmin) ──
392
+
393
+ app.get("/api/admin/users", requireAdmin, async (c) => {
394
+ const users = await listUsers()
395
+ return c.json({ users })
396
+ })
397
+
398
+ app.post("/api/admin/users/:id/activate", requireAdmin, async (c) => {
399
+ const id = c.req.param("id") ?? ""
400
+ const updated = await activateUser(id)
401
+ if (!updated) return c.json({ error: "not found" }, 404)
402
+ return c.json({ user: { id: updated.id, role: updated.role, status: updated.status } })
403
+ })
404
+
405
+ app.post("/api/admin/users/:id/role", requireAdmin, async (c) => {
406
+ const id = c.req.param("id") ?? ""
407
+ const body = await c.req.json().catch(() => ({}))
408
+ const role = body.role
409
+ if (role !== "admin" && role !== "member") return c.json({ error: "role must be admin or member" }, 400)
410
+ try {
411
+ const updated = await setUserRole(id, role)
412
+ if (!updated) return c.json({ error: "not found" }, 404)
413
+ return c.json({ user: { id: updated.id, role: updated.role, status: updated.status } })
414
+ } catch (e: any) {
415
+ return c.json({ error: e?.message ?? "role change failed" }, 400)
416
+ }
417
+ })
418
+
419
+ app.delete("/api/admin/users/:id", requireAdmin, async (c) => {
420
+ const id = c.req.param("id") ?? ""
421
+ const me = c.get("userId") as string
422
+ if (id === me) return c.json({ error: "cannot delete yourself" }, 400)
423
+ try {
424
+ const ok = await deleteUser(id)
425
+ if (!ok) return c.json({ error: "not found" }, 404)
426
+ return c.json({ ok: true })
427
+ } catch (e: any) {
428
+ return c.json({ error: e?.message ?? "delete failed" }, 400)
429
+ }
430
+ })
431
+
432
+ // ── profile CRUD (admin) ──
433
+
434
+ app.get("/api/admin/profiles", requireAdmin, async (c) => {
435
+ const { listProfilesRich } = await import("./tiers")
436
+ return c.json({ profiles: await listProfilesRich() })
437
+ })
438
+
439
+ app.post("/api/admin/profiles", requireAdmin, async (c) => {
440
+ const body = await c.req.json().catch(() => ({}))
441
+ const name = typeof body.name === "string" ? body.name.trim() : ""
442
+ if (!name) return c.json({ error: "name required" }, 400)
443
+ const { createProfile } = await import("./tiers")
444
+ const r = await createProfile(name)
445
+ if (!r.ok) return c.json({ error: r.error }, 400)
446
+ return c.json({ ok: true })
447
+ })
448
+
449
+ app.get("/api/admin/profiles/:name", requireAdmin, async (c) => {
450
+ const name = c.req.param("name") ?? ""
451
+ const { getProfile } = await import("./tiers")
452
+ const p = await getProfile(name)
453
+ if (!p) return c.json({ error: "not found" }, 404)
454
+ return c.json(p)
455
+ })
456
+
457
+ app.put("/api/admin/profiles/:name", requireAdmin, async (c) => {
458
+ const name = c.req.param("name") ?? ""
459
+ const body = await c.req.json().catch(() => ({}))
460
+ const { updateProfile } = await import("./tiers")
461
+ const r = await updateProfile(name, { settings: body.settings, claudeMd: body.claudeMd })
462
+ if (!r.ok) return c.json({ error: r.error }, 400)
463
+ return c.json({ ok: true })
464
+ })
465
+
466
+ app.delete("/api/admin/profiles/:name", requireAdmin, async (c) => {
467
+ const name = c.req.param("name") ?? ""
468
+ const { deleteProfile } = await import("./tiers")
469
+ const r = await deleteProfile(name)
470
+ if (!r.ok) return c.json({ error: r.error }, 400)
471
+ return c.json({ ok: true })
472
+ })
473
+
474
+ // ── admin presets ──
475
+
476
+ import { DEFAULT_PROVIDER_PRESETS, DEFAULT_MISE_TOOL_PRESETS } from "./presets"
477
+
478
+ app.get("/api/admin/presets", requireAdmin, async (c) => {
479
+ const cfg = await loadConfig()
480
+ const presets = cfg.presets ?? {
481
+ providerPresets: DEFAULT_PROVIDER_PRESETS,
482
+ miseToolPresets: DEFAULT_MISE_TOOL_PRESETS,
483
+ }
484
+ return c.json(presets)
485
+ })
486
+
487
+ app.put("/api/admin/presets", requireAdmin, async (c) => {
488
+ const body = await c.req.json().catch(() => ({}))
489
+ if (body.providerPresets !== undefined && !Array.isArray(body.providerPresets)) {
490
+ return c.json({ error: "providerPresets must be an array" }, 400)
491
+ }
492
+ if (body.miseToolPresets !== undefined && !Array.isArray(body.miseToolPresets)) {
493
+ return c.json({ error: "miseToolPresets must be an array" }, 400)
494
+ }
495
+ try {
496
+ await saveWorkspaceConfig({ presets: body })
497
+ return c.json({ ok: true })
498
+ } catch (e: any) {
499
+ return c.json({ error: e?.message ?? "save failed" }, 500)
500
+ }
501
+ })
502
+
503
+ // ── admin platform (system info + git pull) ──
504
+
505
+ /**
506
+ * Snapshot of server state for the admin dashboard at /admin/system. Polled
507
+ * every few seconds while the page is open. Active = WS attached OR SDK
508
+ * streaming a reply OR a user message landed in the last 60s. "Active users"
509
+ * is the unique-driver count across those loops.
510
+ */
511
+ app.get("/api/admin/system", requireAdmin, async (c) => {
512
+ // version + how far behind origin we are
513
+ let branch = "unknown", commit = "unknown"
514
+ try { branch = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim() } catch {}
515
+ try { commit = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "rev-parse", "HEAD"])).stdout.trim() } catch {}
516
+ let behindBy = 0, latestCommit: string | null = null, latestMessage: string | null = null
517
+ try {
518
+ // Don't `fetch` here — too slow for a 5s poll. Use whatever the local
519
+ // origin/main ref already knows; admin clicks "Check" to refresh it.
520
+ const remote = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "rev-parse", "@{u}"])).stdout.trim()
521
+ if (commit && remote && commit !== remote) {
522
+ const log = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "log", "--oneline", `${commit}..${remote}`])).stdout.trim()
523
+ behindBy = log ? log.split("\n").length : 0
524
+ latestCommit = remote
525
+ latestMessage = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "log", "-1", "--pretty=%s", remote])).stdout.trim()
526
+ }
527
+ } catch {}
528
+
529
+ const { stat: fsStat } = await import("node:fs/promises")
530
+ const snap = getActivitySnapshot()
531
+ const now = Date.now()
532
+ const activeLoops: Array<{ id: string; title: string; driver: string; wsCount: number; generating: boolean; lastMsgAgeSec: number }> = []
533
+ let totalWs = 0
534
+ let totalGenerating = 0
535
+ for (const s of snap) {
536
+ totalWs += s.wsCount
537
+ if (s.generating) totalGenerating++
538
+ let lastMsgAgeSec = Number.POSITIVE_INFINITY
539
+ try {
540
+ const st = await fsStat(loopHistoryPath(s.id))
541
+ lastMsgAgeSec = Math.floor((now - st.mtimeMs) / 1000)
542
+ } catch {}
543
+ const active = s.wsCount > 0 || s.generating || lastMsgAgeSec < 60
544
+ if (!active) continue
545
+ const meta = await getLoop(s.id)
546
+ if (!meta) continue
547
+ activeLoops.push({
548
+ id: s.id,
549
+ title: meta.title,
550
+ driver: meta.driver ?? meta.createdBy,
551
+ wsCount: s.wsCount,
552
+ generating: s.generating,
553
+ lastMsgAgeSec: Number.isFinite(lastMsgAgeSec) ? lastMsgAgeSec : -1,
554
+ })
555
+ }
556
+ const activeUsers = new Set(activeLoops.map((l) => l.driver)).size
557
+ return c.json({
558
+ version: { branch, commit, behindBy, latestCommit, latestMessage },
559
+ activity: {
560
+ activeLoops: activeLoops.length,
561
+ activeUsers,
562
+ totalWs,
563
+ totalGenerating,
564
+ loops: activeLoops,
565
+ },
566
+ })
567
+ })
568
+
569
+ /** Run git fetch — refreshes origin/main so the dashboard's behindBy is current. */
570
+ app.post("/api/admin/system/check", requireAdmin, async (c) => {
571
+ try {
572
+ const r = await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "fetch", "--quiet"])
573
+ return c.json({ ok: true, message: r.stderr.trim() || "ok" })
574
+ } catch (e: any) {
575
+ return c.json({ ok: false, error: e?.stderr?.toString().trim() || e?.message || "fetch failed" }, 500)
576
+ }
577
+ })
578
+
579
+ /**
580
+ * git pull --ff-only. Does NOT restart the server — bun --hot is expected to
581
+ * pick up code changes. Schema/dep changes need a real restart (ssh in, run
582
+ * scripts/stop.sh && scripts/start.sh). Failures surface stderr verbatim so
583
+ * the admin can see exactly what to fix.
584
+ */
585
+ app.post("/api/admin/system/pull", requireAdmin, async (c) => {
586
+ let oldHead = "", newHead = "", message = ""
587
+ try {
588
+ oldHead = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "rev-parse", "HEAD"])).stdout.trim()
589
+ const r = await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "pull", "--ff-only"])
590
+ newHead = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "rev-parse", "HEAD"])).stdout.trim()
591
+ message = (r.stdout || r.stderr || "").trim() || "ok"
592
+ } catch (e: any) {
593
+ return c.json({
594
+ ok: false,
595
+ error: e?.stderr?.toString().trim() || e?.stdout?.toString().trim() || e?.message || "pull failed",
596
+ oldHead, newHead,
597
+ }, 500)
598
+ }
599
+ return c.json({ ok: true, pulled: oldHead !== newHead, oldHead, newHead, message })
600
+ })
601
+
602
+ // ── settings (auth required) ──
603
+
604
+ app.get("/api/settings/personal", requireAuth, async (c) => {
605
+ const userId = c.get("userId") as string
606
+ const cfg = await loadPersonalConfig(userId)
607
+ // Recompute token usage from persisted message histories (modelUsage in result messages)
608
+ const tokenUsage = await recomputeTokenUsage(userId)
609
+ const providers: Record<string, { models: ModelEntry[]; baseUrl: string; hasKey: boolean; enabled: boolean; maxContextTokens?: number }> = {}
610
+ for (const [name, p] of Object.entries(cfg.providers)) {
611
+ providers[name] = {
612
+ models: p.models,
613
+ baseUrl: p.baseUrl,
614
+ hasKey: !!p.apiKey,
615
+ enabled: p.enabled,
616
+ ...(p.maxContextTokens ? { maxContextTokens: p.maxContextTokens } : {}),
617
+ }
618
+ }
619
+ return c.json({
620
+ providers,
621
+ default: cfg.default,
622
+ tokenUsage,
623
+ })
624
+ })
625
+
626
+ app.put("/api/settings/personal", requireAuth, async (c) => {
627
+ const userId = c.get("userId") as string
628
+ const body = await c.req.json().catch(() => ({}))
629
+ try {
630
+ await savePersonalConfig(userId, {
631
+ default: typeof body.default === "string" ? body.default : undefined,
632
+ providers: body.providers,
633
+ })
634
+ return c.json({ ok: true })
635
+ } catch (e: any) {
636
+ return c.json({ error: e?.message ?? "save failed" }, 500)
637
+ }
638
+ })
639
+
640
+ // ── disk-shape settings (for the rich Settings page) ──
641
+ // `/api/settings/personal/disk` returns the raw personal/<user>/.loopat/
642
+ // config.json. Provider apiKey strings may contain `${VAR}` references; for
643
+ // each provider we report whether the referenced vault env file exists.
644
+ // The resolved (secret) values are NEVER included — the UI sees structure
645
+ // and existence only.
646
+
647
+ app.get("/api/settings/personal/disk", requireAuth, async (c) => {
648
+ const userId = c.get("userId") as string
649
+ const disk = await readPersonalDiskRaw(userId)
650
+ const refExists: Record<string, { kind: string; exists: boolean; varName?: string }> = {}
651
+ if (disk.providers) {
652
+ for (const [name, val] of Object.entries(disk.providers)) {
653
+ if (name === "default" || !val || typeof val !== "object") continue
654
+ const apiKey = (val as any).apiKey
655
+ if (apiKey !== undefined) {
656
+ const d = describeApiKeyRef(apiKey, userId)
657
+ refExists[`providers.${name}.apiKey`] = {
658
+ kind: d.kind,
659
+ exists: d.exists,
660
+ ...(d.varName ? { varName: d.varName } : {}),
661
+ }
662
+ }
663
+ }
664
+ }
665
+ return c.json({ disk, refExists })
666
+ })
667
+
668
+ app.put("/api/settings/personal/disk", requireAuth, async (c) => {
669
+ const userId = c.get("userId") as string
670
+ const body = await c.req.json().catch(() => ({}))
671
+ const r = await savePersonalDisk(userId, body)
672
+ if (!r.ok) return c.json({ error: r.error }, 400)
673
+ return c.json({ ok: true })
674
+ })
675
+
676
+ // Write a value to a vault env file. Used by the Settings UI when the user
677
+ // types a new apiKey / token. Body: `{ name, value, vault? }` — `name` is
678
+ // the env var name (i.e. the contents of `${...}` in config.json apiKey ref);
679
+ // `vault` defaults to "default".
680
+ app.post("/api/settings/personal/value", requireAuth, async (c) => {
681
+ const userId = c.get("userId") as string
682
+ const body = await c.req.json().catch(() => ({}))
683
+ const name = typeof body.name === "string" ? body.name : ""
684
+ const value = typeof body.value === "string" ? body.value : ""
685
+ const vault = typeof body.vault === "string" && body.vault ? body.vault : "default"
686
+ if (!VAULT_RE.test(vault)) return c.json({ error: "invalid vault" }, 400)
687
+ if (!name) return c.json({ error: "name required" }, 400)
688
+ const r = await writeVaultEnv(userId, vault, name, value)
689
+ if (!r.ok) return c.json({ error: r.error }, 400)
690
+ return c.json({ ok: true })
691
+ })
692
+
693
+ // ── onboarding (auth required) ──
694
+ // Welcome-card state machine for new users. See server/src/onboarding.ts.
695
+
696
+ app.get("/api/onboarding", requireAuth, async (c) => {
697
+ const userId = c.get("userId") as string
698
+ const status = await getOnboardingStatus(userId)
699
+ // If state says "started" but the loop has since been deleted, fall back to
700
+ // "fresh" so the user can re-start instead of staring at a dead link.
701
+ if (status.state === "started" && status.loopId) {
702
+ if (!(await loopExists(status.loopId))) {
703
+ return c.json({ state: "fresh" })
704
+ }
705
+ }
706
+ return c.json(status)
707
+ })
708
+
709
+ app.post("/api/onboarding/start", requireAuth, async (c) => {
710
+ const userId = c.get("userId") as string
711
+ try {
712
+ const r = await startOnboardingLoop(userId)
713
+ return c.json({ ok: true, loopId: r.loopId })
714
+ } catch (e: any) {
715
+ return c.json({ error: e?.message ?? "start failed" }, 500)
716
+ }
717
+ })
718
+
719
+ app.post("/api/onboarding/done", requireAuth, async (c) => {
720
+ const userId = c.get("userId") as string
721
+ await markOnboardingDone(userId)
722
+ return c.json({ ok: true })
723
+ })
724
+
725
+ // ── MCP OAuth (auth required) ──
726
+ // loopat owns the OAuth dance entirely: discovery + DCR + auth code + PKCE
727
+ // + token exchange happen server-side. The resulting access token is written
728
+ // to the user's personal default vault as a plain env file named by the
729
+ // server's `Authorization: Bearer ${VAR}` header — i.e. the same env every
730
+ // CC spawn already substitutes into request headers. MCP tokens are thus
731
+ // indistinguishable from any other vault env (git-crypt encrypted on disk,
732
+ // auto-injected at sandbox spawn).
733
+
734
+ const VAULT_RE = /^[a-zA-Z0-9_-]+$/
735
+ const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/
736
+ // Server names can have spaces, dots etc. ("Google Drive", "Solve Intelligence").
737
+ // Allow common printable chars; reject path/shell metas.
738
+ const SERVER_NAME_RE = /^[A-Za-z0-9 ._-]{1,64}$/
739
+
740
+ /** Best-effort recovery of the public URL the user's browser is hitting us
741
+ * on. Order: LOOPAT_PUBLIC_URL env → X-Forwarded-* headers → request Host
742
+ * header. The OAuth `redirect_uri` is built from this and must match what
743
+ * we register with DCR. */
744
+ function publicBaseUrl(c: any): string {
745
+ if (process.env.LOOPAT_PUBLIC_URL) return process.env.LOOPAT_PUBLIC_URL.replace(/\/+$/, "")
746
+ const xfHost = c.req.header("x-forwarded-host")
747
+ const xfProto = c.req.header("x-forwarded-proto")
748
+ const host = xfHost ?? c.req.header("host")
749
+ const proto = xfProto ?? (c.req.url.startsWith("https") ? "https" : "http")
750
+ return `${proto}://${host}`
751
+ }
752
+
753
+ // List the MCP servers visible to a loop. Single source = the loop's merged
754
+ // `.claude/settings.json` (composed by compose.ts from team/profile/personal
755
+ // settings + plugin-shipped defaults). Each server reports whether its
756
+ // `Authorization: Bearer ${VAR}` env is set in the user's personal default
757
+ // vault — that's the `authed` flag. `authed` does NOT validate the token
758
+ // (no expiry check, no probe), it only means "the env file exists & non-empty".
759
+ app.get("/api/mcp-servers", requireAuth, async (c) => {
760
+ const userId = c.get("userId") as string
761
+ const loopId = c.req.query("loopId")
762
+
763
+ let mcpServers: Record<string, any> = {}
764
+ if (loopId) {
765
+ const { loopClaudeDir } = await import("./paths")
766
+ const { readFile: rf } = await import("node:fs/promises")
767
+ const settingsPath = pathJoin(loopClaudeDir(loopId), "settings.json")
768
+ if (existsSync(settingsPath)) {
769
+ try {
770
+ const j = JSON.parse(await rf(settingsPath, "utf8"))
771
+ mcpServers = (j?.mcpServers ?? {}) as Record<string, any>
772
+ } catch (e: any) {
773
+ console.warn(`[mcp] loop ${loopId} settings unreadable: ${e?.message ?? e}`)
774
+ }
775
+ }
776
+ }
777
+
778
+ const envs = await loadVaultEnvs(userId, DEFAULT_VAULT)
779
+
780
+ const servers = await Promise.all(
781
+ Object.entries(mcpServers).map(async ([name, srv]) => {
782
+ const type = (srv?.type ?? "stdio") as string
783
+ const url = (srv as any)?.url as string | undefined
784
+ const authTokenEnv = parseBearerEnvName(srv)
785
+ const authed = authTokenEnv ? !!envs[authTokenEnv] : false
786
+ let oauthSupport: OAuthSupport | undefined
787
+ if (url && (type === "http" || type === "sse")) {
788
+ try {
789
+ oauthSupport = await probeOAuthSupport(url)
790
+ } catch {
791
+ oauthSupport = "unreachable"
792
+ }
793
+ }
794
+ return { name, type, url, authTokenEnv, authed, oauthSupport }
795
+ }),
796
+ )
797
+
798
+ return c.json({ servers })
799
+ })
800
+
801
+ // Force re-probe of OAuth support. POST with no body clears entire cache;
802
+ // body {url: ...} evicts just that URL. Useful after admin fixes a server
803
+ // URL or adds a previously-unreachable server.
804
+ app.post("/api/mcp-servers/reprobe", requireAuth, async (c) => {
805
+ const body = await c.req.json().catch(() => ({}))
806
+ const url = typeof body.url === "string" ? body.url : undefined
807
+ evictOAuthProbe(url)
808
+ return c.json({ ok: true })
809
+ })
810
+
811
+ app.post("/api/mcp-auth/start", requireAuth, async (c) => {
812
+ const userId = c.get("userId") as string
813
+ const body = await c.req.json().catch(() => ({}))
814
+ const serverName = typeof body.serverName === "string" ? body.serverName.trim() : ""
815
+ const loopId = typeof body.loopId === "string" ? body.loopId.trim() : ""
816
+ if (!serverName || !SERVER_NAME_RE.test(serverName)) return c.json({ error: "invalid serverName" }, 400)
817
+ if (!loopId) return c.json({ error: "loopId required" }, 400)
818
+ const r = await startMcpAuth({
819
+ user: userId,
820
+ serverName,
821
+ loopId,
822
+ publicBaseUrl: publicBaseUrl(c),
823
+ })
824
+ if (!r.ok) return c.json({ error: r.error }, 400)
825
+ return c.json({ authorizationUrl: r.authorizationUrl })
826
+ })
827
+
828
+ // OAuth provider redirects the browser here after the user authorizes. We
829
+ // finish the token exchange and bounce back to the Settings UI with the
830
+ // outcome in the query string — no JSON, browser-friendly redirect.
831
+ app.get("/api/mcp-auth/callback", async (c) => {
832
+ const state = c.req.query("state") ?? ""
833
+ const code = c.req.query("code") ?? ""
834
+ const errParam = c.req.query("error")
835
+ if (errParam) {
836
+ return c.redirect(`/?mcp_auth=error&reason=${encodeURIComponent(errParam)}`)
837
+ }
838
+ if (!state || !code) {
839
+ return c.redirect(`/?mcp_auth=error&reason=missing_state_or_code`)
840
+ }
841
+ const r = await completeMcpAuth({ state, code })
842
+ if (!r.ok) {
843
+ return c.redirect(`/?mcp_auth=error&reason=${encodeURIComponent(r.error)}`)
844
+ }
845
+ return c.redirect(`/?mcp_auth=ok&server=${encodeURIComponent(r.serverName)}`)
846
+ })
847
+
848
+ // Restart the in-memory LoopSession for a loop (interrupt the running
849
+ // query(), so the next user message re-spawns CC and re-injects mcpServers
850
+ // + vault tokens + provider env). Conversation history is preserved via
851
+ // the SDK's --continue. Auth: must be the loop's createdBy.
852
+ app.post("/api/loops/:id/restart-session", requireAuth, async (c) => {
853
+ const userId = c.get("userId") as string
854
+ const id = c.req.param("id")
855
+ const meta = await getLoop(id)
856
+ if (!meta) return c.json({ error: "loop not found" }, 404)
857
+ if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409)
858
+ if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403)
859
+ const restarted = restartSession(id)
860
+ return c.json({ ok: true, restarted })
861
+ })
862
+
863
+ // "Forget" an MCP token = delete the env file in the user's personal default
864
+ // vault. The UI passes the env name from /api/mcp-servers' `authTokenEnv`.
865
+ // This endpoint deliberately accepts any valid env name (not MCP-specific):
866
+ // MCP tokens are indistinguishable from other vault envs in the new design.
867
+ app.delete("/api/envs/:name", requireAuth, async (c) => {
868
+ const userId = c.get("userId") as string
869
+ const name = c.req.param("name")
870
+ if (!ENV_NAME_RE.test(name)) return c.json({ error: "invalid env name" }, 400)
871
+ await deleteVaultEnv(userId, DEFAULT_VAULT, name)
872
+ return c.json({ ok: true })
873
+ })
874
+
875
+ // ── tier settings API (composition model) ──
876
+
877
+ app.get("/api/tiers", requireAuth, async (c) => {
878
+ const userId = c.get("userId") as string
879
+ const me = await findUser(userId)
880
+ const isAdmin = me?.role === "admin"
881
+ const { getTiers } = await import("./tiers")
882
+ return c.json(await getTiers(userId, isAdmin))
883
+ })
884
+
885
+ app.get("/api/tiers/:tier/settings", requireAuth, async (c) => {
886
+ const userId = c.get("userId") as string
887
+ const tierId = c.req.param("tier") ?? ""
888
+ const { getTierSettings } = await import("./tiers")
889
+ return c.json(await getTierSettings(tierId, userId))
890
+ })
891
+
892
+ app.put("/api/tiers/:tier/settings", requireAuth, async (c) => {
893
+ const userId = c.get("userId") as string
894
+ const tierId = c.req.param("tier") ?? ""
895
+ const me = await findUser(userId)
896
+ const isAdmin = me?.role === "admin"
897
+ // Only admin can write team/profile tiers
898
+ if ((tierId === "team" || tierId.startsWith("profile:")) && !isAdmin) {
899
+ return c.json({ error: "admin required for team/profile tiers" }, 403)
900
+ }
901
+ const body = await c.req.json().catch(() => ({}))
902
+ const { saveTierSettings } = await import("./tiers")
903
+ const r = await saveTierSettings(tierId, body, userId)
904
+ if (!r.ok) return c.json({ error: r.error }, 400)
905
+ return c.json({ ok: true })
906
+ })
907
+
908
+ app.get("/api/tiers/:tier/mise-config", requireAuth, async (c) => {
909
+ const userId = c.get("userId") as string
910
+ const tierId = c.req.param("tier") ?? ""
911
+ const { getTierMiseConfig } = await import("./tiers")
912
+ return c.json(await getTierMiseConfig(tierId, userId))
913
+ })
914
+
915
+ app.put("/api/tiers/:tier/mise-config", requireAuth, async (c) => {
916
+ const userId = c.get("userId") as string
917
+ const tierId = c.req.param("tier") ?? ""
918
+ const me = await findUser(userId)
919
+ const isAdmin = me?.role === "admin"
920
+ if ((tierId === "team" || tierId.startsWith("profile:")) && !isAdmin) {
921
+ return c.json({ error: "admin required for team/profile tiers" }, 403)
922
+ }
923
+ const body = await c.req.json().catch(() => ({}))
924
+ const content = typeof body.content === "string" ? body.content : ""
925
+ const { saveTierMiseConfig } = await import("./tiers")
926
+ const r = await saveTierMiseConfig(tierId, content, userId)
927
+ if (!r.ok) return c.json({ error: r.error }, 400)
928
+ return c.json({ ok: true })
929
+ })
930
+
931
+ app.get("/api/plugins/available", requireAuth, async (c) => {
932
+ const { listAvailablePlugins } = await import("./tiers")
933
+ return c.json({ plugins: await listAvailablePlugins() })
934
+ })
935
+
936
+ app.get("/api/plugins/browse", requireAuth, async (c) => {
937
+ const { browseMarketplacePlugins } = await import("./tiers")
938
+ return c.json({ plugins: await browseMarketplacePlugins() })
939
+ })
940
+
941
+ app.get("/api/marketplaces", requireAuth, async (c) => {
942
+ const { listMarketplaces } = await import("./tiers")
943
+ return c.json({ marketplaces: await listMarketplaces() })
944
+ })
945
+
946
+ app.post("/api/plugins/refresh", requireAuth, async (c) => {
947
+ const userId = c.get("userId") as string
948
+ const { refreshMarketplaces } = await import("./tiers")
949
+ const r = await refreshMarketplaces(userId)
950
+ if (!r.ok) return c.json({ error: r.error }, 500)
951
+ return c.json({ ok: true, added: r.added })
952
+ })
953
+
954
+ app.get("/api/settings/workspace", requireAuth, requireAdmin, async (c) => {
955
+ const cfg = await loadConfig()
956
+ const providers: Record<string, { models: ModelEntry[]; baseUrl: string; hasKey: boolean; enabled: boolean }> = {}
957
+ if (cfg.providers) {
958
+ for (const [name, p] of Object.entries(cfg.providers)) {
959
+ providers[name] = { models: p.models, baseUrl: p.baseUrl, hasKey: !!(p as any).apiKey, enabled: p.enabled }
960
+ }
961
+ }
962
+ const tokenUsage = await recomputeWorkspaceTokenUsage()
963
+ return c.json({
964
+ providers,
965
+ default: cfg.default ?? "",
966
+ tokenUsage,
967
+ })
968
+ })
969
+
970
+ app.put("/api/settings/workspace", requireAuth, requireAdmin, async (c) => {
971
+ const body = await c.req.json().catch(() => ({}))
972
+ try {
973
+ await saveWorkspaceConfig({
974
+ providers: body.providers,
975
+ default: typeof body.default === "string" ? body.default : undefined,
976
+ })
977
+ return c.json({ ok: true })
978
+ } catch (e: any) {
979
+ return c.json({ error: e?.message ?? "save failed" }, 500)
980
+ }
981
+ })
982
+
983
+ app.get("/api/settings/token-usage/daily", requireAuth, async (c) => {
984
+ const userId = c.get("userId") as string
985
+ const daily = await recomputeDailyTokenUsage(userId)
986
+ return c.json(daily)
987
+ })
988
+
989
+ app.get("/api/settings/token-usage/loops", requireAuth, async (c) => {
990
+ const userId = c.get("userId") as string
991
+ const loops = await recomputeLoopTokenUsage(userId)
992
+ return c.json(loops)
993
+ })
994
+
995
+ // ── token usage recompute helpers ──
996
+
997
+ import { readFile, writeFile, appendFile, mkdir } from "node:fs/promises"
998
+
999
+ type TokenUsageEntry = { inputTokens: number; outputTokens: number; cacheReadInputTokens: number; cacheCreationInputTokens: number }
1000
+
1001
+ function newEntry(): TokenUsageEntry {
1002
+ return { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }
1003
+ }
1004
+
1005
+ function addUsage(e: TokenUsageEntry, mu: any) {
1006
+ e.inputTokens += mu.inputTokens ?? 0
1007
+ e.outputTokens += mu.outputTokens ?? 0
1008
+ e.cacheReadInputTokens += mu.cacheReadInputTokens ?? 0
1009
+ e.cacheCreationInputTokens += mu.cacheCreationInputTokens ?? 0
1010
+ }
1011
+
1012
+ async function recomputeTokenUsage(userId: string): Promise<Record<string, TokenUsageEntry>> {
1013
+ const usage: Record<string, TokenUsageEntry> = {}
1014
+ try {
1015
+ const allLoops = await listLoops()
1016
+ const userLoops = allLoops.filter((l) => l.createdBy === userId)
1017
+ for (const loop of userLoops) {
1018
+ const hp = loopHistoryPath(loop.id)
1019
+ if (!existsSync(hp)) continue
1020
+ let raw: string
1021
+ try { raw = await readFile(hp, "utf8") } catch { continue }
1022
+ for (const line of raw.split("\n")) {
1023
+ if (!line) continue
1024
+ try {
1025
+ const msg = JSON.parse(line)
1026
+ if (msg.type === "result" && msg.modelUsage) {
1027
+ for (const [model, u] of Object.entries(msg.modelUsage)) {
1028
+ const mu = u as any
1029
+ const entry = usage[model] ?? newEntry()
1030
+ addUsage(entry, mu)
1031
+ usage[model] = entry
1032
+ }
1033
+ }
1034
+ } catch {}
1035
+ }
1036
+ }
1037
+ } catch {}
1038
+ return usage
1039
+ }
1040
+
1041
+ async function recomputeWorkspaceTokenUsage(): Promise<Record<string, TokenUsageEntry>> {
1042
+ const usage: Record<string, TokenUsageEntry> = {}
1043
+ try {
1044
+ const allLoops = await listLoops()
1045
+ for (const loop of allLoops) {
1046
+ const hp = loopHistoryPath(loop.id)
1047
+ if (!existsSync(hp)) continue
1048
+ let raw: string
1049
+ try { raw = await readFile(hp, "utf8") } catch { continue }
1050
+ for (const line of raw.split("\n")) {
1051
+ if (!line) continue
1052
+ try {
1053
+ const msg = JSON.parse(line)
1054
+ if (msg.type === "result" && msg.modelUsage) {
1055
+ for (const [model, u] of Object.entries(msg.modelUsage)) {
1056
+ const mu = u as any
1057
+ const entry = usage[model] ?? newEntry()
1058
+ addUsage(entry, mu)
1059
+ usage[model] = entry
1060
+ }
1061
+ }
1062
+ } catch {}
1063
+ }
1064
+ }
1065
+ } catch {}
1066
+ return usage
1067
+ }
1068
+
1069
+ async function recomputeDailyTokenUsage(userId: string): Promise<Record<string, Record<string, TokenUsageEntry>>> {
1070
+ // daily[model][date] = { inputTokens, outputTokens, ... }
1071
+ const daily: Record<string, Record<string, TokenUsageEntry>> = {}
1072
+ try {
1073
+ const allLoops = await listLoops()
1074
+ const userLoops = allLoops.filter((l) => l.createdBy === userId)
1075
+ for (const loop of userLoops) {
1076
+ const hp = loopHistoryPath(loop.id)
1077
+ if (!existsSync(hp)) continue
1078
+ let raw: string
1079
+ try { raw = await readFile(hp, "utf8") } catch { continue }
1080
+ // Fallback date for historical messages without _ts: loop creation date
1081
+ const fallbackDate = (loop.createdAt ?? new Date().toISOString()).slice(0, 10)
1082
+ let currentDate = fallbackDate
1083
+ for (const line of raw.split("\n")) {
1084
+ if (!line) continue
1085
+ try {
1086
+ const msg = JSON.parse(line)
1087
+ // Track date: explicit _ts wins, clear-boundary ts updates the sliding window
1088
+ if (msg.type === "clear-boundary" && typeof msg.ts === "string") {
1089
+ currentDate = msg.ts.slice(0, 10)
1090
+ }
1091
+ const ts = typeof msg._ts === "string" ? msg._ts : null
1092
+ const date = ts ? ts.slice(0, 10) : currentDate
1093
+ if (msg.type === "result" && msg.modelUsage) {
1094
+ for (const [model, u] of Object.entries(msg.modelUsage)) {
1095
+ const mu = u as any
1096
+ daily[model] ??= {}
1097
+ const entry = daily[model][date] ?? newEntry()
1098
+ addUsage(entry, mu)
1099
+ daily[model][date] = entry
1100
+ }
1101
+ }
1102
+ } catch {}
1103
+ }
1104
+ }
1105
+ } catch {}
1106
+ return daily
1107
+ }
1108
+
1109
+ async function recomputeLoopTokenUsage(userId: string): Promise<Array<{
1110
+ loopId: string
1111
+ title: string
1112
+ model: string
1113
+ inputTokens: number
1114
+ outputTokens: number
1115
+ cacheReadInputTokens: number
1116
+ cacheCreationInputTokens: number
1117
+ lastActivity: string
1118
+ }>> {
1119
+ const loops: Array<{
1120
+ loopId: string
1121
+ title: string
1122
+ model: string
1123
+ inputTokens: number
1124
+ outputTokens: number
1125
+ cacheReadInputTokens: number
1126
+ cacheCreationInputTokens: number
1127
+ lastActivity: string
1128
+ }> = []
1129
+ try {
1130
+ const allLoops = await listLoops()
1131
+ const userLoops = allLoops.filter((l) => l.createdBy === userId)
1132
+ for (const loop of userLoops) {
1133
+ const hp = loopHistoryPath(loop.id)
1134
+ if (!existsSync(hp)) continue
1135
+ let raw: string
1136
+ try { raw = await readFile(hp, "utf8") } catch { continue }
1137
+ let inputTokens = 0
1138
+ let outputTokens = 0
1139
+ let cacheReadInputTokens = 0
1140
+ let cacheCreationInputTokens = 0
1141
+ const models = new Set<string>()
1142
+ let lastActivity = loop.createdAt ?? ""
1143
+ for (const line of raw.split("\n")) {
1144
+ if (!line) continue
1145
+ try {
1146
+ const msg = JSON.parse(line)
1147
+ if (msg._ts) lastActivity = msg._ts
1148
+ if (msg.type === "result" && msg.modelUsage) {
1149
+ for (const [, mu] of Object.entries(msg.modelUsage as Record<string, any>)) {
1150
+ const m = mu as any
1151
+ inputTokens += m.inputTokens ?? 0
1152
+ outputTokens += m.outputTokens ?? 0
1153
+ cacheReadInputTokens += m.cacheReadInputTokens ?? 0
1154
+ cacheCreationInputTokens += m.cacheCreationInputTokens ?? 0
1155
+ }
1156
+ for (const model of Object.keys(msg.modelUsage)) {
1157
+ models.add(model)
1158
+ }
1159
+ }
1160
+ } catch {}
1161
+ }
1162
+ if (inputTokens > 0 || outputTokens > 0) {
1163
+ loops.push({
1164
+ loopId: loop.id,
1165
+ title: loop.title || "Untitled",
1166
+ model: Array.from(models).join(", "),
1167
+ inputTokens,
1168
+ outputTokens,
1169
+ cacheReadInputTokens,
1170
+ cacheCreationInputTokens,
1171
+ lastActivity,
1172
+ })
1173
+ }
1174
+ }
1175
+ } catch {}
1176
+ // Sort by last activity descending
1177
+ loops.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity))
1178
+ return loops
1179
+ }
1180
+
1181
+ // ── personal repo bootstrap (deploy-key flow) ──
1182
+ //
1183
+ // Two-step:
1184
+ // 1. POST /api/auth/register → user created, personal/<id>/ scaffolded with
1185
+ // `git init` + ed25519 deploy keypair. Response carries `publicKey`.
1186
+ // 2. User registers publicKey as a deploy key (write access) on their
1187
+ // personalRepo, then calls POST /api/personal/import to clone the repo
1188
+ // using the managed private key. Cloned content replaces the empty
1189
+ // scaffold; the keypair is preserved.
1190
+ //
1191
+ // GET /api/personal/status reports current state so the UI can render
1192
+ // "needs import" banner + retry button.
1193
+
1194
+ app.get("/api/personal/status", requireAuth, async (c) => {
1195
+ const userId = c.get("userId") as string
1196
+ const user = await findUser(userId)
1197
+ if (!user) return c.json({ error: "user missing" }, 500)
1198
+ // If the user never went through register-with-personalRepo (or ssh-keygen
1199
+ // was unavailable then), the keypair may be missing — try once now so this
1200
+ // endpoint can serve as the lazy-init for the deploy-key flow.
1201
+ let publicKey = await getPublicKey(userId)
1202
+ if (!publicKey) {
1203
+ const r = await ensurePersonalKeypair(userId)
1204
+ publicKey = r.publicKey
1205
+ }
1206
+ const imported = !(await isPersonalFresh(userId))
1207
+ const wcfg = await loadConfig()
1208
+ const providerId = wcfg.gitHost?.provider ?? "github"
1209
+ return c.json({
1210
+ userId,
1211
+ personalRepo: user.personalRepo ?? null,
1212
+ publicKey,
1213
+ imported,
1214
+ gitHost: {
1215
+ provider: providerId,
1216
+ baseUrl: wcfg.gitHost?.baseUrl ?? null,
1217
+ defaultRepo: wcfg.gitHost?.defaultRepo ?? "loopat-personal",
1218
+ tokenHelp: await providerTokenHelp(providerId),
1219
+ },
1220
+ })
1221
+ })
1222
+
1223
+ // Export the user's git-crypt key (base64). Behind a fresh password check
1224
+ // to prevent walk-up attacks on an unattended browser. The key decrypts
1225
+ // .loopat/vaults/** on any host that holds it, so we don't want a stolen
1226
+ // session cookie to be enough to lift it.
1227
+ app.post("/api/personal/crypt-key", requireAuth, async (c) => {
1228
+ const userId = c.get("userId") as string
1229
+ const body = await c.req.json().catch(() => ({}))
1230
+ const password = typeof body.password === "string" ? body.password : ""
1231
+ if (!password) return c.json({ error: "password required" }, 400)
1232
+ const user = await findUser(userId)
1233
+ if (!user) return c.json({ error: "user missing" }, 500)
1234
+ const ok = await verifyPassword(password, user.salt, user.hash)
1235
+ if (!ok) return c.json({ error: "wrong password" }, 403)
1236
+ const { gitCryptKeyExists, getGitCryptKey } = await import("./git-crypt-key")
1237
+ if (!(await gitCryptKeyExists(userId))) {
1238
+ return c.json({ error: "no crypt key on this host" }, 404)
1239
+ }
1240
+ try {
1241
+ const buf = await getGitCryptKey(userId)
1242
+ return c.json({ cryptKey: buf.toString("base64") })
1243
+ } catch (e: any) {
1244
+ return c.json({ error: `failed to read key: ${e?.message ?? e}` }, 500)
1245
+ }
1246
+ })
1247
+
1248
+ app.post("/api/personal/import", requireAuth, async (c) => {
1249
+ const userId = c.get("userId") as string
1250
+ const user = await findUser(userId)
1251
+ if (!user) return c.json({ error: "user missing" }, 500)
1252
+ const body = await c.req.json().catch(() => ({}))
1253
+ const provided = typeof body.repoUrl === "string" && body.repoUrl.trim() ? body.repoUrl.trim() : ""
1254
+ const repoUrl = provided || user.personalRepo
1255
+ if (!repoUrl) return c.json({ error: "no personalRepo on file and none provided" }, 400)
1256
+ const cryptKey = typeof body.cryptKey === "string" && body.cryptKey.trim() ? body.cryptKey.trim() : undefined
1257
+ // If the user typed a fresh URL (had none on file, or changed it), persist
1258
+ // before attempting clone — keeps users.json + personal/ consistent.
1259
+ if (provided && provided !== user.personalRepo) {
1260
+ await setPersonalRepo(userId, provided)
1261
+ }
1262
+ const r = await importPersonalFromRepo(userId, repoUrl, cryptKey)
1263
+ if (!r.ok) {
1264
+ // 422 = data condition prevents proceeding (secrets leaked — user must
1265
+ // fix locally first, no amount of input here helps).
1266
+ if (r.secretsExposed) {
1267
+ return c.json({ error: r.error, secretsExposed: true, exposedFiles: r.exposedFiles ?? [] }, 422)
1268
+ }
1269
+ // 422 = repo isn't a clean slate; user must point at a fresh repo or use
1270
+ // Recovery (BYOK). UI surfaces the Recovery hint in this case.
1271
+ if (r.notClean) {
1272
+ return c.json({ error: r.error, notClean: true }, 422)
1273
+ }
1274
+ if (r.needsCryptKey) return c.json({ error: r.error, needsCryptKey: true }, 409)
1275
+ return c.json({ error: r.error }, 400)
1276
+ }
1277
+ // On auto-init, `cryptKey` is returned exactly once for the user to back
1278
+ // up. Subsequent /api/personal/status calls do NOT expose it.
1279
+ return c.json({ ok: true, autoInitialized: !!r.autoInitialized, cryptKey: r.cryptKey ?? null })
1280
+ })
1281
+
1282
+ // POST /api/personal/github — onboard personal via a GitHub PAT (host-side
1283
+ // only): create the repo, register the deploy key, clone + git-crypt. The PAT
1284
+ // never enters a sandbox; runtime git uses the deploy key / vault. See
1285
+ // docs/identity.md (integration contract).
1286
+ app.post("/api/personal/github", requireAuth, async (c) => {
1287
+ const userId = c.get("userId") as string
1288
+ const user = await findUser(userId)
1289
+ if (!user) return c.json({ error: "user missing" }, 500)
1290
+ const body = await c.req.json().catch(() => ({}))
1291
+ const token = typeof body.token === "string" ? body.token.trim() : ""
1292
+ if (!token) return c.json({ error: "github token required" }, 400)
1293
+ const wcfg = await loadConfig()
1294
+ const repoName = (typeof body.repoName === "string" && body.repoName.trim()) || wcfg.gitHost?.defaultRepo || "loopat-personal"
1295
+ const baseUrl = (typeof body.baseUrl === "string" && body.baseUrl.trim() ? body.baseUrl.trim() : undefined) ?? wcfg.gitHost?.baseUrl
1296
+ const cryptKey = typeof body.cryptKey === "string" && body.cryptKey.trim() ? body.cryptKey.trim() : undefined
1297
+ const provider = (typeof body.provider === "string" && body.provider.trim() ? body.provider.trim() : undefined) ?? wcfg.gitHost?.provider ?? "github"
1298
+ const r = await setupPersonalViaProvider({ userId, provider, token, baseUrl, repoName, cryptKey })
1299
+ if (!r.ok) {
1300
+ if (r.needsCryptKey) return c.json({ error: r.error, needsCryptKey: true }, 409)
1301
+ return c.json({ error: r.error }, 400)
1302
+ }
1303
+ await setPersonalRepo(userId, r.repoUrl)
1304
+ return c.json({ ok: true, repo: r.repo, created: r.created, autoInitialized: !!r.autoInitialized, cryptKey: r.cryptKey ?? null })
1305
+ })
1306
+
1307
+ // POST /api/personal/repos — list the user's repos for the onboarding picker.
1308
+ // "personal"-named repos come first. Empty when the provider can't list.
1309
+ app.post("/api/personal/repos", requireAuth, async (c) => {
1310
+ const body = await c.req.json().catch(() => ({}))
1311
+ const token = typeof body.token === "string" ? body.token.trim() : ""
1312
+ if (!token) return c.json({ ok: false, repos: [], error: "token required" })
1313
+ const wcfg = await loadConfig()
1314
+ const provider = (typeof body.provider === "string" && body.provider.trim() ? body.provider.trim() : undefined) ?? wcfg.gitHost?.provider ?? "github"
1315
+ const baseUrl = (typeof body.baseUrl === "string" && body.baseUrl.trim() ? body.baseUrl.trim() : undefined) ?? wcfg.gitHost?.baseUrl
1316
+ try {
1317
+ // Validate the token first so a bad token surfaces as an error in the token
1318
+ // step, instead of an empty (misleading "no repos") picker.
1319
+ const auth = await authenticateViaProvider({ provider, token, baseUrl })
1320
+ if (!auth.ok) return c.json({ ok: false, repos: [], error: auth.error })
1321
+ const repos = await listPersonalReposViaProvider({ provider, token, baseUrl })
1322
+ return c.json({ ok: true, repos, login: auth.login })
1323
+ } catch (e: any) {
1324
+ return c.json({ ok: false, repos: [], error: e?.message ?? String(e) })
1325
+ }
1326
+ })
1327
+
1328
+ // Destroy personal/<user>/ AND the saved git-crypt key. Two-step from the
1329
+ // client's POV: first call (no `force`) verifies the password, inspects the
1330
+ // repo, attempts a sync if dirty, and either deletes (clean / sync ok) or
1331
+ // returns 409 with a data-loss preview. Second call (force=true, same
1332
+ // password) skips the sync and just deletes.
1333
+ app.post("/api/personal/delete", requireAuth, async (c) => {
1334
+ const userId = c.get("userId") as string
1335
+ const body = await c.req.json().catch(() => ({}))
1336
+ const password = typeof body.password === "string" ? body.password : ""
1337
+ const force = body.force === true
1338
+ if (!password) return c.json({ error: "password required" }, 400)
1339
+ const user = await findUser(userId)
1340
+ if (!user) return c.json({ error: "user missing" }, 500)
1341
+ const ok = await verifyPassword(password, user.salt, user.hash)
1342
+ if (!ok) return c.json({ error: "wrong password" }, 403)
1343
+
1344
+ const status = await inspectPersonalDirty(userId)
1345
+ const dirty = status.uncommitted > 0 || status.unpushed > 0
1346
+
1347
+ if (!force && dirty) {
1348
+ // Try to sync first. If it works, we can delete with no data loss.
1349
+ const sync = await syncPersonalToRemote(userId)
1350
+ if (!sync.ok) {
1351
+ return c.json(
1352
+ {
1353
+ error: "personal/ has unsynced changes and sync failed",
1354
+ syncFailed: true,
1355
+ syncError: sync.error,
1356
+ uncommitted: status.uncommitted,
1357
+ unpushed: status.unpushed,
1358
+ hasRemote: status.hasRemote,
1359
+ },
1360
+ 409,
1361
+ )
1362
+ }
1363
+ }
1364
+
1365
+ const del = await deletePersonalVault(userId)
1366
+ if (!del.ok) return c.json({ error: del.error }, 500)
1367
+ return c.json({
1368
+ ok: true,
1369
+ synced: !force && dirty,
1370
+ dataLost: force && dirty,
1371
+ })
1372
+ })
1373
+
1374
+ // Pull from remote. Stashes local changes, fetches, merges, then pops stash.
1375
+ // If `force: true` is passed in the body, discards all local changes instead
1376
+ // of stashing (for recovering from stash failures).
1377
+ app.post("/api/personal/pull", requireAuth, async (c) => {
1378
+ const userId = c.get("userId") as string
1379
+ const body = await c.req.json().catch(() => ({}))
1380
+ const r = await pullPersonalFromRemote(userId, { force: !!body.force })
1381
+ if (!r.ok) {
1382
+ const status: Record<string, unknown> = { error: r.error }
1383
+ if (r.conflict) { status.conflict = true; status.files = r.files }
1384
+ if (r.needsStash) status.needsStash = true
1385
+ return c.json(status, r.conflict ? 409 : 400)
1386
+ }
1387
+ return c.json({ ok: true, message: r.message })
1388
+ })
1389
+
1390
+ // Push to remote. Commits, rebases onto origin (held back on real conflict),
1391
+ // then ff-pushes.
1392
+ app.post("/api/personal/push", requireAuth, async (c) => {
1393
+ const userId = c.get("userId") as string
1394
+ const r = await pushPersonalToRemote(userId)
1395
+ if (!r.ok) {
1396
+ const status: Record<string, unknown> = { error: r.error }
1397
+ if (r.conflict) { status.conflict = true; status.files = r.files }
1398
+ if (r.needsPull) status.needsPull = true
1399
+ return c.json(status, (r.conflict || r.needsPull) ? 409 : 400)
1400
+ }
1401
+ return c.json({ ok: true, message: r.message })
1402
+ })
1403
+
1404
+ // ── Workspace repo sync (knowledge / notes / repos/<name>) ──
1405
+ // All ff-only. Any authenticated user may sync. Push to repos/<name> is
1406
+ // not supported — code flows through PRs upstream, never from primary.
1407
+
1408
+ function syncDirFor(resource: string, name?: string): string | null {
1409
+ if (resource === "knowledge") return workspaceKnowledgeDir()
1410
+ if (resource === "notes") return workspaceNotesDir()
1411
+ if (resource === "repos" && name) {
1412
+ const dir = workspaceRepoDir(name)
1413
+ return existsSync(dir) ? dir : null
1414
+ }
1415
+ return null
1416
+ }
1417
+
1418
+ app.get("/api/sync/knowledge/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceKnowledgeDir())))
1419
+ app.post("/api/sync/knowledge/pull", requireAuth, async (c) => {
1420
+ const r = await pullRepoFromRemote(workspaceKnowledgeDir())
1421
+ return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1422
+ })
1423
+ app.post("/api/sync/knowledge/push", requireAuth, async (c) => {
1424
+ const r = await pushRepoToRemote(workspaceKnowledgeDir())
1425
+ return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1426
+ })
1427
+
1428
+ app.get("/api/sync/notes/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceNotesDir())))
1429
+ app.post("/api/sync/notes/pull", requireAuth, async (c) => {
1430
+ const r = await pullRepoFromRemote(workspaceNotesDir())
1431
+ return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1432
+ })
1433
+ app.post("/api/sync/notes/push", requireAuth, async (c) => {
1434
+ const r = await pushRepoToRemote(workspaceNotesDir())
1435
+ return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1436
+ })
1437
+
1438
+ app.get("/api/sync/repos", requireAuth, async (c) => {
1439
+ // List repos available for sync (just names of subdirs of workspaceReposDir).
1440
+ try {
1441
+ const { readdir } = await import("node:fs/promises")
1442
+ const entries = await readdir(workspaceReposDir())
1443
+ const repos: string[] = []
1444
+ for (const e of entries) {
1445
+ if (e.startsWith(".")) continue
1446
+ if (existsSync(workspaceRepoDir(e) + "/.git")) repos.push(e)
1447
+ }
1448
+ return c.json({ repos })
1449
+ } catch {
1450
+ return c.json({ repos: [] })
1451
+ }
1452
+ })
1453
+ app.get("/api/sync/repos/:name/status", requireAuth, async (c) => {
1454
+ const dir = syncDirFor("repos", c.req.param("name"))
1455
+ if (!dir) return c.json({ error: "repo not found" }, 404)
1456
+ return c.json(await inspectRepoSync(dir))
1457
+ })
1458
+ app.post("/api/sync/repos/:name/pull", requireAuth, async (c) => {
1459
+ const dir = syncDirFor("repos", c.req.param("name"))
1460
+ if (!dir) return c.json({ error: "repo not found" }, 404)
1461
+ const r = await pullRepoFromRemote(dir)
1462
+ return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1463
+ })
1464
+
1465
+ // All /api/* routes below require auth, EXCEPT the two endpoints used by the
1466
+ // public share view (GET /api/loops/:id and WS /ws/loop/:id), which allow
1467
+ // anonymous read iff meta.public === true. There is no anonymous workspace
1468
+ // access at all.
1469
+
1470
+ app.get("/api/loops", requireAuth, async (c) => {
1471
+ // ?archived=true → only archived; ?archived=all → both; default → hide archived
1472
+ const filter = c.req.query("archived") ?? ""
1473
+ const all = await listLoops()
1474
+ let loops = all
1475
+ if (filter === "true") loops = all.filter((m) => m.archived === true)
1476
+ else if (filter === "all") loops = all
1477
+ else loops = all.filter((m) => m.archived !== true)
1478
+ return c.json({ loops })
1479
+ })
1480
+
1481
+ // List vaults this user has on disk. Each entry is the name a loop can put
1482
+ // in `meta.config.vault` to bind that vault's contents into the sandbox.
1483
+ // When the user hasn't created any vaults yet, the legacy `secrets/` dir
1484
+ // shows up as the implicit "default" vault.
1485
+ app.get("/api/vaults", requireAuth, async (c) => {
1486
+ const userId = c.get("userId") as string
1487
+ const { listVaults } = await import("./vaults")
1488
+ return c.json({ vaults: listVaults(userId) })
1489
+ })
1490
+
1491
+ // Return the current user's `default_profiles` from
1492
+ // personal/<u>/.loopat/config.json. Used by NewLoopDialog to pre-check the
1493
+ // user's typical setup. Returns [] if config absent / field missing.
1494
+ app.get("/api/personal/default-profiles", requireAuth, async (c) => {
1495
+ const userId = c.get("userId") as string
1496
+ const { existsSync } = await import("node:fs")
1497
+ const { readFile } = await import("node:fs/promises")
1498
+ const { personalLoopatConfigPath } = await import("./paths")
1499
+ const path = personalLoopatConfigPath(userId)
1500
+ if (!existsSync(path)) return c.json({ default_profiles: [] })
1501
+ try {
1502
+ const j = JSON.parse(await readFile(path, "utf8"))
1503
+ const arr = Array.isArray(j?.default_profiles)
1504
+ ? j.default_profiles.filter((x: unknown): x is string => typeof x === "string")
1505
+ : []
1506
+ return c.json({ default_profiles: arr })
1507
+ } catch {
1508
+ return c.json({ default_profiles: [] })
1509
+ }
1510
+ })
1511
+
1512
+ app.put("/api/personal/default-profiles", requireAuth, async (c) => {
1513
+ const userId = c.get("userId") as string
1514
+ const { existsSync } = await import("node:fs")
1515
+ const { readFile, writeFile, mkdir: mk } = await import("node:fs/promises")
1516
+ const { dirname } = await import("node:path")
1517
+ const { personalLoopatConfigPath } = await import("./paths")
1518
+ const body = await c.req.json().catch(() => ({}))
1519
+ if (!Array.isArray(body.default_profiles)) return c.json({ error: "default_profiles must be an array" }, 400)
1520
+ const path = personalLoopatConfigPath(userId)
1521
+ let cfg: Record<string, any> = {}
1522
+ if (existsSync(path)) {
1523
+ try { cfg = JSON.parse(await readFile(path, "utf8")) } catch { cfg = {} }
1524
+ }
1525
+ cfg.default_profiles = body.default_profiles.filter((x: unknown): x is string => typeof x === "string")
1526
+ try {
1527
+ await mk(dirname(path), { recursive: true })
1528
+ await writeFile(path, JSON.stringify(cfg, null, 2) + "\n")
1529
+ return c.json({ ok: true })
1530
+ } catch (e: any) {
1531
+ return c.json({ error: e?.message ?? "save failed" }, 500)
1532
+ }
1533
+ })
1534
+
1535
+ // List available profiles in `<LOOPAT_HOME>/context/profiles/`. Each profile
1536
+ // is a directory with a profile.json. Returns name + description so the UI
1537
+ // can render a multi-select. Base profile is included if present — UI may
1538
+ // choose to render it as "always on" / non-toggleable.
1539
+ /**
1540
+ * Compute totals for a hypothetical loop with the given profile selection.
1541
+ * Team layer is always implicit. Used by NewLoopDialog to show "23 plugins ·
1542
+ * 7 skills · ..." preview before the user creates the loop.
1543
+ *
1544
+ * Reads .claude/ dirs of team + selected profiles + each enabled plugin
1545
+ * (host-installed cache OR local marketplace source).
1546
+ */
1547
+ app.get("/api/loop-stats", requireAuth, async (c) => {
1548
+ const { computeLoopStats } = await import("./loop-stats")
1549
+ const profilesParam = c.req.query("profiles") ?? ""
1550
+ const profiles = profilesParam.split(",").map((s) => s.trim()).filter(Boolean)
1551
+ const stats = await computeLoopStats(profiles)
1552
+ return c.json(stats)
1553
+ })
1554
+
1555
+ app.get("/api/profiles", requireAuth, async (c) => {
1556
+ // Profile = a subdir of `.loopat/profiles/` with a `.claude/` inside.
1557
+ // No loopat-invented metadata file: `description` is pulled from the
1558
+ // profile's CLAUDE.md frontmatter `description:` field (preferred) or
1559
+ // its first heading (legacy fallback) — see extractProfileDescription.
1560
+ const { listProfiles } = await import("./profiles")
1561
+ const { extractProfileDescription } = await import("./tiers")
1562
+ const { workspaceProfileClaudeMdPath } = await import("./paths")
1563
+ const { existsSync } = await import("node:fs")
1564
+ const { readFile } = await import("node:fs/promises")
1565
+ const names = await listProfiles()
1566
+ const profiles = await Promise.all(names.map(async (name) => {
1567
+ const mdPath = workspaceProfileClaudeMdPath(name)
1568
+ const md = existsSync(mdPath) ? await readFile(mdPath, "utf8").catch(() => null) : null
1569
+ return { name, description: extractProfileDescription(md) ?? undefined }
1570
+ }))
1571
+ return c.json({ profiles })
1572
+ })
1573
+
1574
+ app.post("/api/loops", requireAuth, async (c) => {
1575
+ const userId = c.get("userId") as string
1576
+ const body = await c.req.json().catch(() => ({}))
1577
+ const title = typeof body.title === "string" ? body.title : "untitled"
1578
+ const repo = typeof body.repo === "string" && body.repo.trim() ? body.repo.trim() : undefined
1579
+ // Profile model: `profiles` body field is an array of profile names (loaded
1580
+ // from `<LOOPAT_HOME>/context/profiles/<name>/`). The old `sandbox` body
1581
+ // field is silently ignored — UI's NewLoopDialog still sends it for
1582
+ // backward compat but it has no effect on the spawn flow.
1583
+ const profiles: string[] | undefined = Array.isArray(body.profiles)
1584
+ ? body.profiles.filter((s: unknown): s is string => typeof s === "string" && s.trim().length > 0)
1585
+ : undefined
1586
+ const vault = typeof body.vault === "string" && body.vault.trim() ? body.vault.trim() : undefined
1587
+ const knowledgeRw = body.knowledge_rw === true
1588
+ const mountAllLoops = body.mount_all_loops === true
1589
+ if (mountAllLoops) {
1590
+ // Cross-loop view exposes every other loop's chats / workdir / meta.
1591
+ // Admin-only — checked server-side so a non-admin can't just POST the
1592
+ // flag by hand. (UI never offers the toggle outside the admin menu.)
1593
+ const me = await findUser(userId)
1594
+ if (!me || me.role !== "admin") {
1595
+ return c.json({ error: "mount_all_loops requires admin role" }, 403)
1596
+ }
1597
+ }
1598
+ try {
1599
+ const meta = await createLoop({ title, repo, createdBy: userId, profiles, vault, knowledgeRw, mountAllLoops })
1600
+ return c.json(meta)
1601
+ } catch (e: any) {
1602
+ return c.json({ error: e?.message ?? "create failed" }, 400)
1603
+ }
1604
+ })
1605
+
1606
+ // Distill: spawn a child loop seeded with the source's conversation snapshot
1607
+ // and a distill-kind project-tier CLAUDE.md. Any authenticated user may call;
1608
+ // the source is read-only here.
1609
+ app.post("/api/loops/:id/distill", requireAuth, async (c) => {
1610
+ const sourceId = c.req.param("id") ?? ""
1611
+ const userId = c.get("userId") as string
1612
+ try {
1613
+ const child = await distillLoop(sourceId, userId)
1614
+ return c.json(child)
1615
+ } catch (e: any) {
1616
+ const msg = e?.message ?? "distill failed"
1617
+ const code = /not found/i.test(msg) ? 404 : 400
1618
+ return c.json({ error: msg }, code)
1619
+ }
1620
+ })
1621
+
1622
+ app.post("/api/loops/:id/viewed", requireAuth, async (c) => {
1623
+ const id = c.req.param("id")
1624
+ markLoopViewed(id)
1625
+ // Broadcast immediately so UI updates without refresh
1626
+ const entry = getLoopStatus()[id]
1627
+ if (entry) {
1628
+ const update = { [id]: entry }
1629
+ for (const [ws, ids] of statusWatchers) {
1630
+ if (ids.has(id)) {
1631
+ try { ws.send(JSON.stringify({ type: "update", data: update })) } catch {}
1632
+ }
1633
+ }
1634
+ }
1635
+ return c.json({ ok: true })
1636
+ })
1637
+
1638
+ // Public-or-auth: anonymous visitors get meta only when the loop is public.
1639
+ app.get("/api/loops/:id", async (c) => {
1640
+ const id = c.req.param("id") ?? ""
1641
+ const meta = await getLoop(id)
1642
+ if (!meta) return c.json({ error: "not found" }, 404)
1643
+ if (!meta.public && !getRequestUserId(c)) {
1644
+ return c.json({ error: "unauthorized" }, 401)
1645
+ }
1646
+ return c.json(meta)
1647
+ })
1648
+
1649
+ // Archive / unarchive. Only the loop owner (createdBy) may flip the flag.
1650
+ app.patch("/api/loops/:id", requireAuth, async (c) => {
1651
+ const id = c.req.param("id") ?? ""
1652
+ const userId = c.get("userId") as string
1653
+ const meta = await getLoop(id)
1654
+ if (!meta) return c.json({ error: "not found" }, 404)
1655
+ if (meta.createdBy !== userId) return c.json({ error: "forbidden" }, 403)
1656
+ const body = await c.req.json().catch(() => ({}))
1657
+ const patch: Partial<typeof meta> = {}
1658
+ if (typeof body.archived === "boolean") {
1659
+ patch.archived = body.archived
1660
+ patch.archivedAt = body.archived ? new Date().toISOString() : undefined
1661
+ }
1662
+ if (typeof body.public === "boolean") {
1663
+ patch.public = body.public
1664
+ patch.publicAt = body.public ? new Date().toISOString() : undefined
1665
+ }
1666
+ if (typeof body.title === "string") {
1667
+ const t = body.title.trim()
1668
+ if (!t) return c.json({ error: "title cannot be empty" }, 400)
1669
+ if (t.length > 200) return c.json({ error: "title too long (max 200)" }, 400)
1670
+ patch.title = t
1671
+ }
1672
+ // Share config fields
1673
+ if (typeof body.shareEnabled === "boolean") patch.shareEnabled = body.shareEnabled
1674
+ if (body.shareMode === "static" || body.shareMode === "port" || body.shareMode === "ephemeral") patch.shareMode = body.shareMode
1675
+ if (typeof body.shareAlias === "string") patch.shareAlias = body.shareAlias.trim() || undefined
1676
+ if (typeof body.sharePort === "number") patch.sharePort = body.sharePort
1677
+ if (typeof body.shareExternalPort === "number") patch.shareExternalPort = body.shareExternalPort
1678
+ if (body.shareProtocol === "tcp" || body.shareProtocol === "udp" || body.shareProtocol === "static") patch.shareProtocol = body.shareProtocol
1679
+ if (Object.keys(patch).length === 0) return c.json({ error: "no allowed fields" }, 400)
1680
+ const previous = await getLoop(id) // before patch (for old mode comparison)
1681
+ const updated = await patchLoopMeta(id, patch)
1682
+ const shareTouched =
1683
+ "shareEnabled" in patch || "shareExternalPort" in patch || "sharePort" in patch ||
1684
+ "shareProtocol" in patch || "shareMode" in patch
1685
+ if (shareTouched) {
1686
+ // Touch trigger for port-proxy (direct mode hot-reloads from inotify).
1687
+ try {
1688
+ const trigger = pathJoin(loopsDir(), ".port-proxy-trigger")
1689
+ await writeFile(trigger, String(Date.now())) // write ts to guarantee inotify Modify event
1690
+ } catch {}
1691
+ // Ephemeral mode: -p flags live on the loop container's create-args,
1692
+ // so the container itself must be recreated. Kill attached SDK + PTY;
1693
+ // the next attach calls ensureContainer, sees config-hash drift, and
1694
+ // recreates with the new `-p :<sharePort>` (kernel picks new host port).
1695
+ const wasEphemeral = previous && previous.shareEnabled && previous.shareMode === "ephemeral"
1696
+ const isEphemeral = updated && updated.shareEnabled && updated.shareMode === "ephemeral"
1697
+ const portChanged = previous && updated && previous.sharePort !== updated.sharePort
1698
+ const protoChanged = previous && updated && previous.shareProtocol !== updated.shareProtocol
1699
+ if (wasEphemeral || isEphemeral || (isEphemeral && (portChanged || protoChanged))) {
1700
+ destroyLoopSession(id)
1701
+ killTerm(id)
1702
+ }
1703
+ }
1704
+ // On archive: tear down the Claude SDK process and terminal PTY so no
1705
+ // orphaned processes linger. Un-archive is fine — next connect re-spawns.
1706
+ if (body.archived === true) {
1707
+ destroyLoopSession(id)
1708
+ killTerm(id)
1709
+ }
1710
+ return c.json(updated)
1711
+ })
1712
+
1713
+ // Request For Drive: current driver releases control. Sandbox + terminal are
1714
+ // torn down (on-disk history kept), and `rfdRequestedAt` is set so any other
1715
+ // authenticated user can claim the loop via POST /:id/drive.
1716
+ app.post("/api/loops/:id/request-drive", requireAuth, async (c) => {
1717
+ const id = c.req.param("id") ?? ""
1718
+ const userId = c.get("userId") as string
1719
+ const meta = await getLoop(id)
1720
+ if (!meta) return c.json({ error: "not found" }, 404)
1721
+ if (!isDriver(meta, userId)) return c.json({ error: "forbidden" }, 403)
1722
+ if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409)
1723
+ if (meta.rfdRequestedAt) return c.json({ error: "already requested for drive" }, 409)
1724
+ const updated = await patchLoopMeta(id, {
1725
+ rfdRequestedAt: new Date().toISOString(),
1726
+ rfdRequestedBy: userId,
1727
+ })
1728
+ // Tear down what's running. .claude/, messages.jsonl, sandbox snapshot all
1729
+ // stay — the next driver resumes via --continue when they send a message.
1730
+ destroyLoopSession(id)
1731
+ killTerm(id)
1732
+ return c.json(updated)
1733
+ })
1734
+
1735
+ // Drive: any authenticated user takes over an RFD'd loop. Lazy spawn — the
1736
+ // sandbox respawns under the new driver's personal config on the next user
1737
+ // message (ensureStarted picks up effectiveDriver). `pendingDriverNote` lets
1738
+ // the model know about the handoff on the first message.
1739
+ app.post("/api/loops/:id/drive", requireAuth, async (c) => {
1740
+ const id = c.req.param("id") ?? ""
1741
+ const userId = c.get("userId") as string
1742
+ const meta = await getLoop(id)
1743
+ if (!meta) return c.json({ error: "not found" }, 404)
1744
+ if (!meta.rfdRequestedAt) return c.json({ error: "not up for drive" }, 409)
1745
+ if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409)
1746
+ const now = new Date().toISOString()
1747
+ const previous = effectiveDriver(meta)
1748
+ const history = [...(meta.driverHistory ?? []), { driver: userId, since: now }]
1749
+ const updated = await patchLoopMeta(id, {
1750
+ driver: userId,
1751
+ driverHistory: history,
1752
+ rfdRequestedAt: undefined,
1753
+ rfdRequestedBy: undefined,
1754
+ pendingDriverNote: { from: previous, to: userId, at: now },
1755
+ })
1756
+ // Repoint the personal mount before the next spawn. Idempotent.
1757
+ try { await ensureContextMounts(id, userId) } catch (e: any) {
1758
+ console.warn(`[loopat] /drive: ensureContextMounts failed for ${id}: ${e?.message ?? e}`)
1759
+ }
1760
+ return c.json(updated)
1761
+ })
1762
+
1763
+ // Strip thinking blocks from the SDK jsonl history (used before switching
1764
+ // to a provider that can't validate the existing thinking signatures).
1765
+ app.post("/api/loops/:id/strip-thinking", requireAuth, async (c) => {
1766
+ const id = c.req.param("id") ?? ""
1767
+ const userId = c.get("userId") as string
1768
+ const meta = await getLoop(id)
1769
+ if (!meta) return c.json({ error: "not found" }, 404)
1770
+ if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409)
1771
+ if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409)
1772
+ if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403)
1773
+ const session = getSession(id)
1774
+ const r = await session.stripThinkingBlocks()
1775
+ return c.json(r)
1776
+ })
1777
+
1778
+ /**
1779
+ * Read the live host port for an ephemeral-mode share. Returns null when
1780
+ * the container is down, not in ephemeral mode, or the mapping hasn't
1781
+ * been observed yet (e.g. container is still starting). The UI polls
1782
+ * this endpoint while the dialog is open so a fresh restart's new port
1783
+ * appears without the user reloading.
1784
+ */
1785
+ app.get("/api/loops/:id/share/current-port", requireAuth, async (c) => {
1786
+ const id = c.req.param("id") ?? ""
1787
+ const meta = await getLoop(id)
1788
+ if (!meta) return c.json({ error: "not found" }, 404)
1789
+ if (!meta.shareEnabled || meta.shareMode !== "ephemeral" || !meta.sharePort) {
1790
+ return c.json({ port: null })
1791
+ }
1792
+ const proto: "tcp" | "udp" = meta.shareProtocol === "udp" ? "udp" : "tcp"
1793
+ const port = await getEphemeralHostPort(id, meta.sharePort, proto)
1794
+ return c.json({ port, internalPort: meta.sharePort, protocol: proto })
1795
+ })
1796
+
1797
+ app.get("/api/loops/:id/context", requireAuth, async (c) => {
1798
+ const id = c.req.param("id") ?? ""
1799
+ if (!(await loopExists(id))) return c.json({ error: "not found" }, 404)
1800
+ const mounts: { name: string; path: string }[] = []
1801
+ if (existsSync(loopContextKnowledge(id))) mounts.push({ name: "knowledge", path: "context/knowledge" })
1802
+ if (existsSync(loopContextNotes(id))) mounts.push({ name: "notes", path: "context/notes" })
1803
+ if (existsSync(loopContextPersonal(id))) mounts.push({ name: "personal", path: "context/personal" })
1804
+ if (existsSync(loopContextRepos(id))) mounts.push({ name: "repos", path: "context/repos" })
1805
+ return c.json({ mounts })
1806
+ })
1807
+
1808
+ app.get("/api/loops/:id/files", requireAuth, async (c) => {
1809
+ const id = c.req.param("id") ?? ""
1810
+ if (!(await loopExists(id))) return c.json({ error: "not found" }, 404)
1811
+ const path = c.req.query("path") ?? ""
1812
+ return c.json({ entries: await listDir(id, path) })
1813
+ })
1814
+
1815
+ // Recursive file tree — single call returns all files under a path.
1816
+ // The frontend FilePicker uses this instead of recursively calling /files.
1817
+ app.get("/api/loops/:id/files/tree", requireAuth, async (c) => {
1818
+ const id = c.req.param("id") ?? ""
1819
+ if (!(await loopExists(id))) return c.json({ error: "not found" }, 404)
1820
+ const path = c.req.query("path") ?? ""
1821
+ return c.json({ entries: await listDirRecursive(id, path) })
1822
+ })
1823
+
1824
+ app.get("/api/loops/:id/file", requireAuth, async (c) => {
1825
+ const id = c.req.param("id") ?? ""
1826
+ if (!(await loopExists(id))) return c.json({ error: "not found" }, 404)
1827
+ const path = c.req.query("path") ?? ""
1828
+ if (!path) return c.json({ error: "path required" }, 400)
1829
+ const r = await readWorkdirFile(id, path)
1830
+ if (!r) return c.json({ error: "not a file or unreadable" }, 404)
1831
+ return c.json(r)
1832
+ })
1833
+
1834
+ app.put("/api/loops/:id/file", requireAuth, async (c) => {
1835
+ const id = c.req.param("id") ?? ""
1836
+ const userId = c.get("userId") as string
1837
+ const meta = await getLoop(id)
1838
+ if (!meta) return c.json({ error: "not found" }, 404)
1839
+ if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409)
1840
+ if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409)
1841
+ if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403)
1842
+ const path = c.req.query("path") ?? ""
1843
+ if (!path) return c.json({ error: "path required" }, 400)
1844
+ const body = await c.req.json().catch(() => ({}))
1845
+ if (typeof body.content !== "string") return c.json({ error: "content required" }, 400)
1846
+ const ok = await writeWorkdirFile(id, path, body.content)
1847
+ if (!ok) return c.json({ error: "write failed" }, 500)
1848
+ return c.json({ ok: true })
1849
+ })
1850
+
1851
+ app.post("/api/loops/:id/upload", requireAuth, async (c) => {
1852
+ const id = c.req.param("id") ?? ""
1853
+ const userId = c.get("userId") as string
1854
+ const meta = await getLoop(id)
1855
+ if (!meta) return c.json({ error: "not found" }, 404)
1856
+ if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409)
1857
+ if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409)
1858
+ if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403)
1859
+ const formData = await c.req.formData()
1860
+ const file = formData.get("file")
1861
+ if (!(file instanceof File)) return c.json({ error: "file required" }, 400)
1862
+ const dir = loopWorkdir(id)
1863
+ const filePath = join(dir, file.name)
1864
+ try {
1865
+ const buf = await file.arrayBuffer()
1866
+ await Bun.write(filePath, new Uint8Array(buf))
1867
+ return c.json({ ok: true, path: file.name })
1868
+ } catch (e: any) {
1869
+ return c.json({ error: e?.message ?? "upload failed" }, 500)
1870
+ }
1871
+ })
1872
+
1873
+ app.delete("/api/loops/:id/file", requireAuth, async (c) => {
1874
+ const id = c.req.param("id") ?? ""
1875
+ const userId = c.get("userId") as string
1876
+ const meta = await getLoop(id)
1877
+ if (!meta) return c.json({ error: "not found" }, 404)
1878
+ if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409)
1879
+ if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409)
1880
+ if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403)
1881
+ const path = c.req.query("path") ?? ""
1882
+ if (!path) return c.json({ error: "path required" }, 400)
1883
+ const ok = await deleteWorkdirFile(id, path)
1884
+ if (!ok) return c.json({ error: "delete failed" }, 500)
1885
+ return c.json({ ok: true })
1886
+ })
1887
+
1888
+ app.post("/api/loops/:id/folder", requireAuth, async (c) => {
1889
+ const id = c.req.param("id") ?? ""
1890
+ const userId = c.get("userId") as string
1891
+ const meta = await getLoop(id)
1892
+ if (!meta) return c.json({ error: "not found" }, 404)
1893
+ if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409)
1894
+ if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409)
1895
+ if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403)
1896
+ const body = await c.req.json().catch(() => ({}))
1897
+ if (typeof body.path !== "string" || !body.path) return c.json({ error: "path required" }, 400)
1898
+ const ok = await createWorkdirFolder(id, body.path)
1899
+ if (!ok) return c.json({ error: "mkdir failed" }, 500)
1900
+ return c.json({ ok: true })
1901
+ })
1902
+
1903
+ // ── chat history ──
1904
+ app.get("/api/loops/:id/chat-history", requireAuth, async (c) => {
1905
+ const id = c.req.param("id") ?? ""
1906
+ const meta = await getLoop(id)
1907
+ if (!meta) return c.json({ error: "not found" }, 404)
1908
+ const path = loopChatHistoryPath(id)
1909
+ if (!existsSync(path)) return c.json([])
1910
+ const raw = await Bun.file(path).text()
1911
+ const lines = raw.split("\n").filter(Boolean)
1912
+ const entries = lines.map((l) => {
1913
+ try { return JSON.parse(l) } catch { return null }
1914
+ }).filter((e): e is NonNullable<typeof e> => e !== null)
1915
+ return c.json(entries)
1916
+ })
1917
+
1918
+ app.post("/api/loops/:id/chat-history", requireAuth, async (c) => {
1919
+ const id = c.req.param("id") ?? ""
1920
+ const userId = c.get("userId") as string
1921
+ const meta = await getLoop(id)
1922
+ if (!meta) return c.json({ error: "not found" }, 404)
1923
+ if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409)
1924
+ if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409)
1925
+ if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403)
1926
+ const body = await c.req.json().catch(() => ({}))
1927
+ if (typeof body.text !== "string" || !body.text.trim()) return c.json({ error: "text required" }, 400)
1928
+ const path = loopChatHistoryPath(id)
1929
+ await mkdir(dirname(path), { recursive: true })
1930
+ await appendFile(path, JSON.stringify({ text: body.text.trim(), ts: Date.now() }) + "\n")
1931
+ return c.json({ ok: true })
1932
+ })
1933
+
1934
+ // ── git operations (workdir) ──
1935
+
1936
+ type GitFileInfo = {
1937
+ path: string
1938
+ status: "A" | "M" | "D" | "R" | "?"
1939
+ additions: number
1940
+ deletions: number
1941
+ isBinary: boolean
1942
+ }
1943
+
1944
+ type GitCommit = {
1945
+ hash: string
1946
+ shortHash: string
1947
+ subject: string
1948
+ author: string
1949
+ date: string
1950
+ parentHashes: string[]
1951
+ branch: string | null
1952
+ branches: string[]
1953
+ tags: string[]
1954
+ }
1955
+
1956
+ async function getGitStatus(loopId: string): Promise<{ unstaged: GitFileInfo[]; staged: GitFileInfo[] }> {
1957
+ const dir = loopWorkdir(loopId)
1958
+ if (!existsSync(join(dir, ".git"))) return { unstaged: [], staged: [] }
1959
+
1960
+ const execOpts = { encoding: "utf8" as const, timeout: 10_000 }
1961
+ const unstaged: GitFileInfo[] = []
1962
+ const staged: GitFileInfo[] = []
1963
+
1964
+ // Parse git status --porcelain for file statuses
1965
+ let porcelain = ""
1966
+ try {
1967
+ porcelain = (await execFileP("git", ["-C", dir, "status", "--porcelain"], execOpts)).stdout.trim()
1968
+ } catch { return { unstaged: [], staged: [] } }
1969
+
1970
+ // Get numstat for unstaged and staged changes
1971
+ let unstagedNumstat = ""
1972
+ let stagedNumstat = ""
1973
+ try { unstagedNumstat = (await execFileP("git", ["-C", dir, "diff", "--numstat"], execOpts)).stdout.trim() } catch {}
1974
+ try { stagedNumstat = (await execFileP("git", ["-C", dir, "diff", "--cached", "--numstat"], execOpts)).stdout.trim() } catch {}
1975
+
1976
+ // Parse numstat into map: path -> { additions, deletions, isBinary }
1977
+ const numstatMap = new Map<string, { additions: number; deletions: number; isBinary: boolean }>()
1978
+ for (const line of [...stagedNumstat.split("\n"), ...unstagedNumstat.split("\n")]) {
1979
+ const parts = line.split("\t")
1980
+ if (parts.length < 3) continue
1981
+ const adds = parseInt(parts[0], 10)
1982
+ const dels = parseInt(parts[1], 10)
1983
+ const isBinary = isNaN(adds) || isNaN(dels)
1984
+ const p = parts[2]
1985
+ // Only set if not already present or if the new one has more info
1986
+ if (!numstatMap.has(p) || (!isBinary && numstatMap.get(p)!.isBinary)) {
1987
+ numstatMap.set(p, { additions: isNaN(adds) ? 0 : adds, deletions: isNaN(dels) ? 0 : dels, isBinary })
1988
+ }
1989
+ }
1990
+
1991
+ for (const line of porcelain.split("\n")) {
1992
+ if (!line || line.length < 4) continue
1993
+ const xy = line.slice(0, 2)
1994
+ // Robust path extraction: skip 2-char status, then trim leading whitespace
1995
+ // Handles both ` M README.md` and `?? hello.html` formats reliably
1996
+ let rest = line.slice(2).trimStart()
1997
+ if (!rest) continue
1998
+ // git quotes paths containing spaces/special chars: ` M "my file.txt"`
1999
+ if (rest.startsWith('"') && rest.endsWith('"')) {
2000
+ rest = rest.slice(1, -1)
2001
+ }
2002
+ // Handle renamed files: `R old.txt -> new.txt` — take the new name after `-> `
2003
+ if (xy[0] === 'R' || xy[1] === 'R') {
2004
+ const arrowIdx = rest.indexOf(' -> ')
2005
+ if (arrowIdx >= 0) rest = rest.slice(arrowIdx + 4)
2006
+ }
2007
+ const p = rest
2008
+
2009
+ const stat = numstatMap.get(p) ?? { additions: 0, deletions: 0, isBinary: false }
2010
+
2011
+ // Index status (staged)
2012
+ if (xy[0] !== " " && xy[0] !== "?") {
2013
+ const code = xy[0] as GitFileInfo["status"]
2014
+ staged.push({ path: p, status: code, ...stat })
2015
+ }
2016
+ // Worktree status (unstaged)
2017
+ if (xy[1] !== " " && xy[1] !== "!") {
2018
+ const code = xy[1] === "?" ? "?" : xy[1] as GitFileInfo["status"]
2019
+ unstaged.push({ path: p, status: code, ...stat })
2020
+ }
2021
+ }
2022
+
2023
+ return { unstaged, staged }
2024
+ }
2025
+
2026
+ app.get("/api/loops/:id/git-status", async (c) => {
2027
+ const id = c.req.param("id") ?? ""
2028
+ if (!(await loopExists(id))) return c.json({ error: "not found" }, 404)
2029
+ return c.json(await getGitStatus(id))
2030
+ })
2031
+
2032
+ app.get("/api/loops/:id/git-diff", async (c) => {
2033
+ const id = c.req.param("id") ?? ""
2034
+ if (!(await loopExists(id))) return c.json({ error: "not found" }, 404)
2035
+ const path = c.req.query("path") ?? ""
2036
+ if (!path) return c.json({ error: "path required" }, 400)
2037
+ const staged = c.req.query("staged") === "1"
2038
+ const dir = loopWorkdir(id)
2039
+ if (!existsSync(join(dir, ".git"))) return c.json({ error: "not a git repo" }, 400)
2040
+ try {
2041
+ const args = ["-C", dir, "diff", "--", path]
2042
+ if (staged) args.splice(3, 0, "--cached")
2043
+ let diff = (await execFileP("git", args, { encoding: "utf8", timeout: 10_000 })).stdout.trim()
2044
+ // Untracked files have nothing in the index to diff against — fall back to
2045
+ // --no-index /dev/null to show the full file content as additions.
2046
+ if (!diff && !staged) {
2047
+ diff = (await execFileP("git", ["-C", dir, "diff", "--no-index", "/dev/null", path], { encoding: "utf8", timeout: 10_000 })).stdout.trim()
2048
+ }
2049
+ return c.json({ diff })
2050
+ } catch (e: any) {
2051
+ return c.json({ error: e?.message ?? "diff failed" }, 500)
2052
+ }
2053
+ })
2054
+
2055
+ app.post("/api/loops/:id/git-stage", requireAuth, async (c) => {
2056
+ const id = c.req.param("id") ?? ""
2057
+ const userId = c.get("userId") as string
2058
+ const meta = await getLoop(id)
2059
+ if (!meta) return c.json({ error: "not found" }, 404)
2060
+ if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409)
2061
+ if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409)
2062
+ if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403)
2063
+ const body = await c.req.json().catch(() => ({}))
2064
+ const files: string[] = Array.isArray(body.files) ? body.files : []
2065
+ const unstage = body.unstage === true
2066
+ if (files.length === 0) return c.json({ error: "files required" }, 400)
2067
+ const dir = loopWorkdir(id)
2068
+ if (!existsSync(join(dir, ".git"))) return c.json({ error: "not a git repo" }, 400)
2069
+ try {
2070
+ const args = unstage ? ["-C", dir, "reset", "HEAD", "--", ...files] : ["-C", dir, "add", "--", ...files]
2071
+ await execFileP("git", args, { encoding: "utf8", timeout: 10_000 })
2072
+ return c.json({ ok: true })
2073
+ } catch (e: any) {
2074
+ return c.json({ error: e?.message ?? "stage failed" }, 500)
2075
+ }
2076
+ })
2077
+
2078
+ app.post("/api/loops/:id/git-commit", requireAuth, async (c) => {
2079
+ const id = c.req.param("id") ?? ""
2080
+ const userId = c.get("userId") as string
2081
+ const meta = await getLoop(id)
2082
+ if (!meta) return c.json({ error: "not found" }, 404)
2083
+ if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409)
2084
+ if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409)
2085
+ if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403)
2086
+ const body = await c.req.json().catch(() => ({}))
2087
+ const message = typeof body.message === "string" && body.message.trim() ? body.message.trim() : ""
2088
+ if (!message) return c.json({ error: "commit message required" }, 400)
2089
+ const dir = loopWorkdir(id)
2090
+ if (!existsSync(join(dir, ".git"))) return c.json({ error: "not a git repo" }, 400)
2091
+ try {
2092
+ await execFileP("git", ["-C", dir, "commit", "-m", message], { encoding: "utf8", timeout: 10_000 })
2093
+ return c.json({ ok: true })
2094
+ } catch (e: any) {
2095
+ return c.json({ error: e?.message ?? "commit failed" }, 500)
2096
+ }
2097
+ })
2098
+
2099
+ app.get("/api/loops/:id/git-log", async (c) => {
2100
+ const id = c.req.param("id") ?? ""
2101
+ if (!(await loopExists(id))) return c.json({ error: "not found" }, 404)
2102
+ const limit = parseInt(c.req.query("limit") ?? "50", 10)
2103
+ const dir = loopWorkdir(id)
2104
+ if (!existsSync(join(dir, ".git"))) return c.json({ commits: [] })
2105
+ try {
2106
+ const format = "%H%n%h%n%s%n%an%n%ai%n%P%n%D"
2107
+ const raw = (await execFileP("git", ["-C", dir, "log", `--format=${format}`, "-n", String(Math.min(limit, 200))], { encoding: "utf8", timeout: 10_000 })).stdout.trim()
2108
+ const commits: GitCommit[] = []
2109
+ const lines = raw.split("\n")
2110
+ for (let i = 0; i + 6 < lines.length || i < lines.length; i += 7) {
2111
+ if (i + 6 >= lines.length) break
2112
+ const refs = lines[i + 6]
2113
+ const branchMatch = refs.match(/HEAD -> ([^,\]]+)/)
2114
+ const branches = refs.split(",").map(s => s.trim()).filter(s => s && !s.startsWith("HEAD") && !s.startsWith("tag:"))
2115
+ const tagMatches = refs.match(/tag: ([^,\)]+)/g)
2116
+ const tags = tagMatches ? tagMatches.map(t => t.replace("tag: ", "").trim()) : []
2117
+ commits.push({
2118
+ hash: lines[i],
2119
+ shortHash: lines[i + 1],
2120
+ subject: lines[i + 2],
2121
+ author: lines[i + 3],
2122
+ date: lines[i + 4],
2123
+ parentHashes: lines[i + 5].split(" ").filter(Boolean),
2124
+ branch: branchMatch?.[1] ?? null,
2125
+ branches,
2126
+ tags,
2127
+ })
2128
+ }
2129
+ return c.json({ commits })
2130
+ } catch {
2131
+ return c.json({ commits: [] })
2132
+ }
2133
+ })
2134
+
2135
+ app.post("/api/loops/:id/git-discard", requireAuth, async (c) => {
2136
+ const id = c.req.param("id") ?? ""
2137
+ const userId = c.get("userId") as string
2138
+ const meta = await getLoop(id)
2139
+ if (!meta) return c.json({ error: "not found" }, 404)
2140
+ if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409)
2141
+ if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409)
2142
+ if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403)
2143
+ const body = await c.req.json().catch(() => ({}))
2144
+ const file: string = typeof body.file === "string" ? body.file : ""
2145
+ if (!file) return c.json({ error: "file required" }, 400)
2146
+ const dir = loopWorkdir(id)
2147
+ if (!existsSync(join(dir, ".git"))) return c.json({ error: "not a git repo" }, 400)
2148
+ try {
2149
+ // First check if the file is tracked. Untracked files can't be
2150
+ // checked out — remove them instead.
2151
+ const tracked = (await execFileP("git", ["-C", dir, "ls-files", "--error-unmatch", file], { encoding: "utf8", timeout: 5_000 }).catch(() => null)) !== null
2152
+ if (tracked) {
2153
+ await execFileP("git", ["-C", dir, "checkout", "--", file], { encoding: "utf8", timeout: 10_000 })
2154
+ } else {
2155
+ await execFileP("rm", ["-f", join(dir, file)], { encoding: "utf8", timeout: 5_000 })
2156
+ }
2157
+ return c.json({ ok: true })
2158
+ } catch (e: any) {
2159
+ return c.json({ error: e?.message ?? "discard failed" }, 500)
2160
+ }
2161
+ })
2162
+
2163
+ // Workspace vault APIs (Context tab)
2164
+ const VAULTS = new Set(["knowledge", "notes", "personal", "repos"])
2165
+
2166
+ // notes is edited through a per-user UI-loop worktree (a no-AI UI loop); make
2167
+ // sure it exists (opened from origin/main) before any read/write resolves it.
2168
+ async function ensureVaultReady(vault: string, user: string): Promise<void> {
2169
+ if (vault === "notes") await ensureUiNotesWorktree(user)
2170
+ }
2171
+
2172
+ app.get("/api/workspace/files", requireAuth, async (c) => {
2173
+ const vault = c.req.query("vault") ?? ""
2174
+ if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400)
2175
+ const userId = c.get("userId") as string
2176
+ const path = c.req.query("path") ?? ""
2177
+ await ensureVaultReady(vault, userId)
2178
+ if (c.req.query("flat") === "1") {
2179
+ return c.json({ entries: await vaultFlatList(vault as VaultId, userId) })
2180
+ }
2181
+ return c.json({ entries: await vaultList(vault as VaultId, path, userId) })
2182
+ })
2183
+
2184
+ app.get("/api/workspace/file", requireAuth, async (c) => {
2185
+ const vault = c.req.query("vault") ?? ""
2186
+ if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400)
2187
+ const userId = c.get("userId") as string
2188
+ const path = c.req.query("path") ?? ""
2189
+ if (!path) return c.json({ error: "path required" }, 400)
2190
+ await ensureVaultReady(vault, userId)
2191
+ const r = await vaultRead(vault as VaultId, path, userId)
2192
+ if (!r) return c.json({ error: "not a file" }, 404)
2193
+ return c.json(r)
2194
+ })
2195
+
2196
+ app.put("/api/workspace/file", requireAuth, async (c) => {
2197
+ const userId = c.get("userId") as string
2198
+ const vault = c.req.query("vault") ?? ""
2199
+ if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400)
2200
+ const path = c.req.query("path") ?? ""
2201
+ if (!path) return c.json({ error: "path required" }, 400)
2202
+ const body = await c.req.json().catch(() => ({}))
2203
+ if (typeof body.content !== "string") return c.json({ error: "content required" }, 400)
2204
+ await ensureVaultReady(vault, userId)
2205
+ const r = await vaultWrite(vault as VaultId, path, body.content, userId)
2206
+ if (!r.ok) return c.json({ error: r.error }, 500)
2207
+ return c.json(r)
2208
+ })
2209
+
2210
+ app.post("/api/workspace/file", requireAuth, async (c) => {
2211
+ const userId = c.get("userId") as string
2212
+ const vault = c.req.query("vault") ?? ""
2213
+ if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400)
2214
+ const body = await c.req.json().catch(() => ({}))
2215
+ if (typeof body.path !== "string" || !body.path) return c.json({ error: "path required" }, 400)
2216
+ await ensureVaultReady(vault, userId)
2217
+ const r = await vaultCreateFile(vault as VaultId, body.path, userId)
2218
+ if (!r.ok) return c.json({ error: r.error }, r.error === "exists" ? 409 : 500)
2219
+ return c.json({ ok: true })
2220
+ })
2221
+
2222
+ // Save = land this user's notes edits on origin/main (the no-AI UI loop).
2223
+ // Explicit: the user clicks save. ff-only + rebase; a real conflict is held back.
2224
+ app.post("/api/notes/save", requireAuth, async (c) => {
2225
+ const userId = c.get("userId") as string
2226
+ const r = await syncUiNotes(userId)
2227
+ if (!r.ok) {
2228
+ const status: Record<string, unknown> = { error: r.error }
2229
+ if (r.conflict) { status.conflict = true; status.files = r.files }
2230
+ if (r.needsPull) status.needsPull = true
2231
+ return c.json(status, (r.conflict || r.needsPull) ? 409 : 400)
2232
+ }
2233
+ return c.json({ ok: true, message: r.message })
2234
+ })
2235
+
2236
+ app.delete("/api/workspace/file", requireAuth, async (c) => {
2237
+ const userId = c.get("userId") as string
2238
+ const vault = c.req.query("vault") ?? ""
2239
+ if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400)
2240
+ const path = c.req.query("path") ?? ""
2241
+ if (!path) return c.json({ error: "path required" }, 400)
2242
+ const r = await vaultDelete(vault as VaultId, path, userId)
2243
+ if (!r.ok) return c.json({ error: r.error }, 500)
2244
+ return c.json({ ok: true })
2245
+ })
2246
+
2247
+ app.post("/api/workspace/folder", requireAuth, async (c) => {
2248
+ const userId = c.get("userId") as string
2249
+ const vault = c.req.query("vault") ?? ""
2250
+ if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400)
2251
+ const body = await c.req.json().catch(() => ({}))
2252
+ if (typeof body.path !== "string" || !body.path) return c.json({ error: "path required" }, 400)
2253
+ const r = await vaultCreateFolder(vault as VaultId, body.path, userId)
2254
+ if (!r.ok) return c.json({ error: r.error }, r.error === "exists" ? 409 : 500)
2255
+ return c.json({ ok: true })
2256
+ })
2257
+
2258
+ app.get("/api/workspace/backlinks", requireAuth, async (c) => {
2259
+ const vault = c.req.query("vault") ?? ""
2260
+ if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400)
2261
+ const userId = c.get("userId") as string
2262
+ const path = c.req.query("path") ?? ""
2263
+ if (!path) return c.json({ backlinks: [] })
2264
+ return c.json({ backlinks: await vaultBacklinks(vault as VaultId, path, userId) })
2265
+ })
2266
+
2267
+ app.get("/api/workspace/repos", requireAuth, async (c) => {
2268
+ return c.json({ repos: await listRepos() })
2269
+ })
2270
+
2271
+ // Register a new repo. Body: { name, source } where source is a git URL
2272
+ // (cloned) or a local path (symlinked).
2273
+ app.post("/api/workspace/repos", requireAuth, async (c) => {
2274
+ const body = await c.req.json().catch(() => ({}))
2275
+ const source = typeof body.source === "string" ? body.source : ""
2276
+ const name = typeof body.name === "string" ? body.name : ""
2277
+ const r = await addRepo({ name, source })
2278
+ if (!r.ok) return c.json({ error: r.error }, 400)
2279
+ return c.json({ ok: true, name: r.name, kind: r.kind })
2280
+ })
2281
+
2282
+ // /api/sandboxes/* routes removed — sandbox concept replaced by profiles
2283
+ // (post-2026-05). Profile management uses /api/profiles + per-profile
2284
+ // .claude/settings.json edited via CC's own commands (cd .loopat/profiles/<n>
2285
+ // && claude plugin install --scope=project ...).
2286
+
2287
+ app.get("/api/workspace/repo/:name", requireAuth, async (c) => {
2288
+ const name = c.req.param("name") ?? ""
2289
+ const detail = await readRepoDetail(name)
2290
+ if (!detail) return c.json({ error: "not found" }, 404)
2291
+ // recent loops on this repo
2292
+ const loops = await listLoops()
2293
+ const recent = loops.filter((l) => (l as any).repo === name).slice(0, 8)
2294
+ return c.json({ ...detail, recentLoops: recent })
2295
+ })
2296
+
2297
+ // `git pull --ff-only` in the repo. Fast-forward only — diverged branches
2298
+ // surface as an error so the user resolves them in their own checkout.
2299
+ app.post("/api/workspace/repo/:name/pull", requireAuth, async (c) => {
2300
+ const name = c.req.param("name") ?? ""
2301
+ const r = await pullRepo(name)
2302
+ if (!r.ok) return c.json({ error: r.error }, 400)
2303
+ return c.json({ ok: true, output: r.output })
2304
+ })
2305
+
2306
+ // ── topics ──
2307
+
2308
+ // ── kanban: focus boards (one directory per board, one .md file per column) ──
2309
+
2310
+ // Board management
2311
+ app.get("/api/kanban/boards", requireAuth, async (c) => {
2312
+ const boards = await listBoards()
2313
+ return c.json({ boards })
2314
+ })
2315
+
2316
+ app.post("/api/kanban/boards", requireAuth, async (c) => {
2317
+ const body = await c.req.json().catch(() => ({}))
2318
+ if (typeof body.name !== "string" || !body.name.trim()) {
2319
+ return c.json({ error: "name required" }, 400)
2320
+ }
2321
+ const ok = await createBoard(body.name.trim())
2322
+ if (!ok) return c.json({ error: "create failed" }, 500)
2323
+ return c.json({ ok: true })
2324
+ })
2325
+
2326
+ app.put("/api/kanban/boards/:name/rename", requireAuth, async (c) => {
2327
+ const oldName = decodeURIComponent(c.req.param("name") ?? "")
2328
+ const body = await c.req.json().catch(() => ({}))
2329
+ if (typeof body.name !== "string" || !body.name.trim()) {
2330
+ return c.json({ error: "name required" }, 400)
2331
+ }
2332
+ const ok = await renameBoard(oldName, body.name.trim())
2333
+ if (!ok) return c.json({ error: "rename failed" }, 500)
2334
+ return c.json({ ok: true })
2335
+ })
2336
+
2337
+ // Column management
2338
+ app.post("/api/kanban/columns/:board", requireAuth, async (c) => {
2339
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2340
+ const body = await c.req.json().catch(() => ({}))
2341
+ if (typeof body.filename !== "string" || !body.filename.trim()) {
2342
+ return c.json({ error: "filename required" }, 400)
2343
+ }
2344
+ const ok = await createColumn(board, body.filename + (body.filename.endsWith(".md") ? "" : ".md"), body.title)
2345
+ if (!ok) return c.json({ error: "create failed" }, 500)
2346
+ kanbanNotify()
2347
+ return c.json({ ok: true })
2348
+ })
2349
+
2350
+ app.put("/api/kanban/columns/:board/:filename/rename", requireAuth, async (c) => {
2351
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2352
+ const fromFile = decodeURIComponent(c.req.param("filename") ?? "")
2353
+ const body = await c.req.json().catch(() => ({}))
2354
+ if (typeof body.toFile !== "string" || !body.toFile.trim()) {
2355
+ return c.json({ error: "toFile required" }, 400)
2356
+ }
2357
+ const ok = await renameColumn(board, fromFile, body.toFile + (body.toFile.endsWith(".md") ? "" : ".md"))
2358
+ if (!ok) return c.json({ error: "rename failed" }, 500)
2359
+ kanbanNotify()
2360
+ return c.json({ ok: true })
2361
+ })
2362
+
2363
+ app.delete("/api/kanban/columns/:board/:filename", requireAuth, async (c) => {
2364
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2365
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2366
+ const ok = await deleteColumn(board, filename)
2367
+ if (!ok) return c.json({ error: "delete failed" }, 500)
2368
+ kanbanNotify()
2369
+ return c.json({ ok: true })
2370
+ })
2371
+
2372
+ app.put("/api/kanban/columns/:board/:filename/color", requireAuth, async (c) => {
2373
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2374
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2375
+ const body = await c.req.json().catch(() => ({}))
2376
+ if (typeof body.color !== "string") return c.json({ error: "color required" }, 400)
2377
+ await setColumnColor(board, filename, body.color)
2378
+ kanbanNotify()
2379
+ return c.json({ ok: true })
2380
+ })
2381
+
2382
+ app.put("/api/kanban/columns/:board/:filename/reorder", requireAuth, async (c) => {
2383
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2384
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2385
+ const body = await c.req.json().catch(() => ({}))
2386
+ if (!Array.isArray(body.cids)) return c.json({ error: "cids array required" }, 400)
2387
+ const ok = await reorderCards(board, filename, body.cids)
2388
+ if (!ok) return c.json({ error: "reorder failed" }, 500)
2389
+ kanbanNotify()
2390
+ return c.json({ ok: true })
2391
+ })
2392
+
2393
+ // Card mutations
2394
+ app.post("/api/kanban/columns/:board/:filename/cards", requireAuth, async (c) => {
2395
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2396
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2397
+ const body = await c.req.json().catch(() => ({}))
2398
+ if (typeof body.text !== "string" || !body.text.trim()) {
2399
+ return c.json({ error: "text required" }, 400)
2400
+ }
2401
+ const r = await addCard(board, filename, body)
2402
+ if (!r.ok) return c.json({ error: "add failed" }, 500)
2403
+ kanbanNotify()
2404
+ return c.json({ cid: r.cid })
2405
+ })
2406
+
2407
+ app.patch("/api/kanban/columns/:board/:filename/cards/:cid/toggle", requireAuth, async (c) => {
2408
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2409
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2410
+ const cid = c.req.param("cid") ?? ""
2411
+ const ok = await toggleCard(board, filename, cid)
2412
+ if (!ok) return c.json({ error: "not found" }, 404)
2413
+ kanbanNotify()
2414
+ return c.json({ ok: true })
2415
+ })
2416
+
2417
+ app.patch("/api/kanban/columns/:board/:filename/cards/:cid", requireAuth, async (c) => {
2418
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2419
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2420
+ const cid = c.req.param("cid") ?? ""
2421
+ const patch = await c.req.json().catch(() => ({}))
2422
+ const ok = await updateCardMeta(board, filename, cid, patch)
2423
+ if (!ok) return c.json({ error: "not found or patch failed" }, 404)
2424
+ kanbanNotify()
2425
+ return c.json({ ok: true })
2426
+ })
2427
+
2428
+ app.put("/api/kanban/columns/:board/:filename/cards/:cid/block", requireAuth, async (c) => {
2429
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2430
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2431
+ const cid = c.req.param("cid") ?? ""
2432
+ const body = await c.req.json().catch(() => ({}))
2433
+ if (typeof body.block !== "string") return c.json({ error: "block required" }, 400)
2434
+ const ok = await updateCardBlock(board, filename, cid, body.block)
2435
+ if (!ok) return c.json({ error: "not found" }, 404)
2436
+ kanbanNotify()
2437
+ return c.json({ ok: true })
2438
+ })
2439
+
2440
+ app.delete("/api/kanban/columns/:board/:filename/cards/:cid", requireAuth, async (c) => {
2441
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2442
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2443
+ const cid = c.req.param("cid") ?? ""
2444
+ const ok = await deleteCard(board, filename, cid)
2445
+ if (!ok) return c.json({ error: "not found" }, 404)
2446
+ kanbanNotify()
2447
+ return c.json({ ok: true })
2448
+ })
2449
+
2450
+ app.post("/api/kanban/columns/:board/:filename/cards/:cid/move", requireAuth, async (c) => {
2451
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2452
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2453
+ const cid = c.req.param("cid") ?? ""
2454
+ const body = await c.req.json().catch(() => ({}))
2455
+ if (typeof body.toFile !== "string") return c.json({ error: "toFile required" }, 400)
2456
+ const ok = await moveCard(board, filename, cid, body.toFile, body.toIndex)
2457
+ if (!ok) return c.json({ error: "move failed" }, 500)
2458
+ kanbanNotify()
2459
+ return c.json({ ok: true })
2460
+ })
2461
+
2462
+ app.post("/api/kanban/columns/:board/:filename/cards/:cid/assign-driver", requireAuth, async (c) => {
2463
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2464
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2465
+ const cid = c.req.param("cid") ?? ""
2466
+ const userId = c.get("userId") as string
2467
+ const r = await assignDriverForCard(board, filename, cid, userId)
2468
+ if (!r.ok) return c.json({ error: "no associated loop" }, 400)
2469
+ return c.json(r)
2470
+ })
2471
+
2472
+ app.post("/api/kanban/columns/:board/:filename/cards/:cid/create-loop", requireAuth, async (c) => {
2473
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2474
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2475
+ const cid = c.req.param("cid") ?? ""
2476
+ const userId = c.get("userId") as string
2477
+ const r = await createLoopFromCard(board, filename, cid, userId)
2478
+ if (!r.ok) return c.json({ error: "create failed" }, 500)
2479
+ return c.json(r)
2480
+ })
2481
+
2482
+ app.post("/api/kanban/columns/:board/:filename/cards/:cid/link-loop", requireAuth, async (c) => {
2483
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2484
+ const filename = decodeURIComponent(c.req.param("filename") ?? "")
2485
+ const cid = c.req.param("cid") ?? ""
2486
+ const userId = c.get("userId") as string
2487
+ const { loopId } = (await c.req.json().catch(() => ({}))) as { loopId?: string }
2488
+ if (!loopId) return c.json({ error: "loopId required" }, 400)
2489
+ const ok = await linkLoopToCard(board, filename, cid, loopId, userId)
2490
+ if (!ok) return c.json({ error: "link failed" }, 500)
2491
+ return c.json({ ok: true })
2492
+ })
2493
+
2494
+ // Board data (list columns + config)
2495
+ app.get("/api/kanban/:board", requireAuth, async (c) => {
2496
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2497
+ const columns = await listKanbanColumns(board)
2498
+ return c.json({ columns })
2499
+ })
2500
+
2501
+ app.get("/api/kanban/config/:board", requireAuth, async (c) => {
2502
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2503
+ const cfg = await readKanbanConfig(board)
2504
+ return c.json(cfg ?? { columns: [] })
2505
+ })
2506
+
2507
+ app.put("/api/kanban/config/:board", requireAuth, async (c) => {
2508
+ const board = decodeURIComponent(c.req.param("board") ?? "default")
2509
+ const body = await c.req.json().catch(() => ({}))
2510
+ if (Array.isArray(body.columns)) {
2511
+ await saveColumnOrder(board, body.columns)
2512
+ kanbanNotify()
2513
+ }
2514
+ return c.json({ ok: true })
2515
+ })
2516
+
2517
+ app.get("/api/topics", requireAuth, async (c) => {
2518
+ const loops = await listLoops()
2519
+ const titles = loops
2520
+ .filter((l) => !l.archived)
2521
+ .map((l) => ({ id: l.id, title: l.title }))
2522
+ return c.json({ topics: await listTopics(titles) })
2523
+ })
2524
+
2525
+ // ── Chat ──────────────────────────────────────────────────────────────────
2526
+ //
2527
+ // SQLite-backed channels + 1:1 DMs. Real-time fanout via /ws/chat with
2528
+ // per-conversation subscriber sets. When a loop is spawned from a chat
2529
+ // conversation, the last 1024 messages are snapshotted to a per-loop jsonl
2530
+ // at loops/<id>/context/chat/<convId>.jsonl so the AI inside the sandbox
2531
+ // can read it from /loopat/context/chat/.
2532
+
2533
+ type ChatSubscriber = { ws: any; userId: string; convs: Set<string> }
2534
+ const chatSubscribers = new Set<ChatSubscriber>()
2535
+
2536
+ function chatBroadcastToConv(convId: string, msg: object, isDm: boolean, dmParties: [string, string] | null) {
2537
+ const payload = JSON.stringify(msg)
2538
+ for (const sub of chatSubscribers) {
2539
+ if (!sub.convs.has(convId)) continue
2540
+ // DM: only the two parties receive even if a third party somehow subscribed.
2541
+ if (isDm && dmParties && sub.userId !== dmParties[0] && sub.userId !== dmParties[1]) continue
2542
+ try { sub.ws.send(payload) } catch {}
2543
+ }
2544
+ }
2545
+
2546
+ function chatBroadcastConvCreated(convCreatedPayload: any, isDm: boolean, dmParties: [string, string] | null) {
2547
+ // For channel creation: broadcast to every connected client so rails refresh.
2548
+ // For DM creation: only the two parties learn about it.
2549
+ const payload = JSON.stringify(convCreatedPayload)
2550
+ for (const sub of chatSubscribers) {
2551
+ if (isDm && dmParties && sub.userId !== dmParties[0] && sub.userId !== dmParties[1]) continue
2552
+ try { sub.ws.send(payload) } catch {}
2553
+ }
2554
+ }
2555
+
2556
+ app.get("/api/chat/users", requireAuth, async (c) => {
2557
+ // Workspace member directory for the DM picker. Filter to active accounts —
2558
+ // pending users can't log in so DMing them is pointless.
2559
+ const users = await listUsers()
2560
+ const me = c.get("userId") as string
2561
+ return c.json({
2562
+ users: users
2563
+ .filter((u) => u.status === "active")
2564
+ .map((u) => ({ id: u.id, role: u.role, isMe: u.id === me })),
2565
+ })
2566
+ })
2567
+
2568
+ app.get("/api/chat/conversations", requireAuth, (c) => {
2569
+ const userId = c.get("userId") as string
2570
+ const convs = listConversationsForUser(userId)
2571
+ return c.json({ conversations: convs })
2572
+ })
2573
+
2574
+ app.post("/api/chat/channels", requireAuth, async (c) => {
2575
+ const userId = c.get("userId") as string
2576
+ const body = await c.req.json().catch(() => ({}))
2577
+ if (typeof body.name !== "string") return c.json({ error: "name required" }, 400)
2578
+ const topic = typeof body.topic === "string" ? body.topic : undefined
2579
+ const r = createChannel({ name: body.name, topic, createdBy: userId })
2580
+ if (!r.ok) return c.json({ error: r.error }, 400)
2581
+ chatBroadcastConvCreated({ type: "conv_created", conv: r.conv }, false, null)
2582
+ return c.json({ conv: r.conv })
2583
+ })
2584
+
2585
+ app.delete("/api/chat/channels/:id", requireAdmin, (c) => {
2586
+ const id = c.req.param("id") ?? ""
2587
+ const conv = getConv(id)
2588
+ if (!conv || conv.kind !== "channel") return c.json({ error: "not found" }, 404)
2589
+ const ok = deleteChannel(id)
2590
+ if (!ok) return c.json({ error: "delete failed" }, 500)
2591
+ chatBroadcastConvCreated({ type: "conv_deleted", convId: id }, false, null)
2592
+ return c.json({ ok: true })
2593
+ })
2594
+
2595
+ app.post("/api/chat/dm/:username", requireAuth, async (c) => {
2596
+ const me = c.get("userId") as string
2597
+ const peer = c.req.param("username") ?? ""
2598
+ if (!peer) return c.json({ error: "username required" }, 400)
2599
+ if (peer === me) return c.json({ error: "cannot DM yourself" }, 400)
2600
+ const peerUser = await findUser(peer)
2601
+ if (!peerUser || peerUser.status !== "active") return c.json({ error: "user not found" }, 404)
2602
+ const conv = getOrCreateDm(me, peer, me)
2603
+ // Broadcast so both parties' rails see the new DM (idempotent — no-op if already known).
2604
+ chatBroadcastConvCreated(
2605
+ { type: "conv_created", conv },
2606
+ true,
2607
+ [conv.dmUserA as string, conv.dmUserB as string],
2608
+ )
2609
+ return c.json({ conv })
2610
+ })
2611
+
2612
+ app.get("/api/chat/conversations/:id/messages", requireAuth, (c) => {
2613
+ const id = c.req.param("id") ?? ""
2614
+ const userId = c.get("userId") as string
2615
+ const conv = getConv(id)
2616
+ if (!conv) return c.json({ error: "not found" }, 404)
2617
+ if (!userCanAccess(conv, userId)) return c.json({ error: "forbidden" }, 403)
2618
+ const before = parseInt(c.req.query("before") ?? "0", 10) || 0
2619
+ const limit = parseInt(c.req.query("limit") ?? "50", 10) || 50
2620
+ return c.json({ messages: listMessages(id, { before, limit }) })
2621
+ })
2622
+
2623
+ app.post("/api/chat/conversations/:id/messages", requireAuth, async (c) => {
2624
+ const id = c.req.param("id") ?? ""
2625
+ const userId = c.get("userId") as string
2626
+ const conv = getConv(id)
2627
+ if (!conv) return c.json({ error: "not found" }, 404)
2628
+ if (!userCanAccess(conv, userId)) return c.json({ error: "forbidden" }, 403)
2629
+ const body = await c.req.json().catch(() => ({}))
2630
+ if (typeof body.text !== "string" || !body.text.trim()) return c.json({ error: "text required" }, 400)
2631
+ const parentId = Number.isInteger(body.parentId) && body.parentId > 0 ? body.parentId : null
2632
+ let m
2633
+ try {
2634
+ m = postMessage(id, userId, body.text, parentId)
2635
+ } catch (e: any) {
2636
+ return c.json({ error: e?.message ?? "post failed" }, 400)
2637
+ }
2638
+ const dmParties: [string, string] | null =
2639
+ conv.kind === "dm" ? [conv.dmUserA as string, conv.dmUserB as string] : null
2640
+ // Broadcast carries parent_id implicitly via Message.parentId — clients
2641
+ // route it to the main feed (null) or the open ThreadPanel (matching root).
2642
+ chatBroadcastToConv(id, { type: "message", message: m }, conv.kind === "dm", dmParties)
2643
+ return c.json({ message: m })
2644
+ })
2645
+
2646
+ // Thread fetch: root message + all replies. Auth via the conversation the
2647
+ // root belongs to. Used by ThreadPanel on open.
2648
+ app.get("/api/chat/threads/:msgId", requireAuth, (c) => {
2649
+ const userId = c.get("userId") as string
2650
+ const rootId = parseInt(c.req.param("msgId") ?? "0", 10)
2651
+ if (!rootId) return c.json({ error: "invalid msgId" }, 400)
2652
+ const t = listThread(rootId)
2653
+ if (!t) return c.json({ error: "not found" }, 404)
2654
+ const conv = getConv(t.root.convId)
2655
+ if (!conv || !userCanAccess(conv, userId)) return c.json({ error: "forbidden" }, 403)
2656
+ return c.json({ root: t.root, replies: t.replies })
2657
+ })
2658
+
2659
+ app.post("/api/chat/conversations/:id/read", requireAuth, async (c) => {
2660
+ const id = c.req.param("id") ?? ""
2661
+ const userId = c.get("userId") as string
2662
+ const conv = getConv(id)
2663
+ if (!conv) return c.json({ error: "not found" }, 404)
2664
+ if (!userCanAccess(conv, userId)) return c.json({ error: "forbidden" }, 403)
2665
+ const body = await c.req.json().catch(() => ({}))
2666
+ const lastReadId = parseInt(body.lastReadId ?? 0, 10) || 0
2667
+ if (lastReadId <= 0) return c.json({ error: "lastReadId required" }, 400)
2668
+ markRead(userId, id, lastReadId)
2669
+ return c.json({ ok: true })
2670
+ })
2671
+
2672
+ // Spawn a loop seeded from a thread. The thread (= root message + replies,
2673
+ // length ≥ 1) is the natural semantic unit — even a brand-new top-level
2674
+ // message with no replies works (snapshot of 1 line). Snapshot lives at
2675
+ // loops/<id>/context/chat/<rootId>.jsonl, mounted ro at /loopat/context/chat/
2676
+ // inside the sandbox.
2677
+ app.post("/api/chat/threads/:msgId/spawn-loop", requireAuth, async (c) => {
2678
+ const rootId = parseInt(c.req.param("msgId") ?? "0", 10)
2679
+ if (!rootId) return c.json({ error: "invalid msgId" }, 400)
2680
+ const userId = c.get("userId") as string
2681
+ const t = listThread(rootId)
2682
+ if (!t) return c.json({ error: "not found" }, 404)
2683
+ const conv = getConv(t.root.convId)
2684
+ if (!conv || !userCanAccess(conv, userId)) return c.json({ error: "forbidden" }, 403)
2685
+ const body = await c.req.json().catch(() => ({}))
2686
+ const dmPeer = conv.kind === "dm"
2687
+ ? (conv.dmUserA === userId ? conv.dmUserB : conv.dmUserA)
2688
+ : null
2689
+ // Title default: first ~40 chars of the thread root (the topic). Cleaner
2690
+ // than "from #channel" — at thread granularity the root IS the topic.
2691
+ const defaultTitle = t.root.text.replace(/\s+/g, " ").slice(0, 40).trim() || "from chat"
2692
+ const title = typeof body.title === "string" && body.title.trim()
2693
+ ? body.title.trim()
2694
+ : defaultTitle
2695
+ let meta
2696
+ try {
2697
+ meta = await createLoop({ title, createdBy: userId })
2698
+ } catch (e: any) {
2699
+ return c.json({ error: e?.message ?? "loop create failed" }, 400)
2700
+ }
2701
+ const destPath = pathJoin(loopContextChatDir(meta.id), `${rootId}.jsonl`)
2702
+ let snapshot
2703
+ try {
2704
+ snapshot = await snapshotThreadToJsonl(rootId, destPath)
2705
+ } catch (e: any) {
2706
+ return c.json({ error: `snapshot failed: ${e?.message ?? e}` }, 500)
2707
+ }
2708
+ if (!snapshot) return c.json({ error: "thread vanished" }, 404)
2709
+ await patchLoopMeta(meta.id, {
2710
+ seededFrom: {
2711
+ kind: "chat",
2712
+ convId: t.root.convId,
2713
+ threadRootId: rootId,
2714
+ messageCount: snapshot.messageCount,
2715
+ snapshotAt: new Date().toISOString(),
2716
+ },
2717
+ } as any)
2718
+ const convLabel = conv.kind === "channel" ? `#${conv.name}` : `@${dmPeer}`
2719
+ const seedPrompt =
2720
+ `Spawned from a ${convLabel} thread (${snapshot.messageCount} message${snapshot.messageCount === 1 ? "" : "s"}). ` +
2721
+ `Snapshot at \`/loopat/context/chat/${rootId}.jsonl\` — read it with the Read tool, then propose next steps.`
2722
+ return c.json({ loopId: meta.id, seedPrompt, messageCount: snapshot.messageCount })
2723
+ })
2724
+
2725
+ // ── Chat WebSocket ────────────────────────────────────────────────────────
2726
+
2727
+ app.get(
2728
+ "/ws/chat",
2729
+ upgradeWebSocket(async (c) => {
2730
+ const userId = getRequestUserId(c)
2731
+ if (!userId) {
2732
+ return {
2733
+ onOpen(_e, ws) {
2734
+ ws.send(JSON.stringify({ type: "error", message: "unauthorized" }))
2735
+ ws.close()
2736
+ },
2737
+ }
2738
+ }
2739
+ let sub: ChatSubscriber | null = null
2740
+ return {
2741
+ onOpen(_e, ws) {
2742
+ sub = { ws, userId, convs: new Set() }
2743
+ chatSubscribers.add(sub)
2744
+ ws.send(JSON.stringify({ type: "chat_connected" }))
2745
+ },
2746
+ onMessage(event, ws) {
2747
+ try {
2748
+ const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer)
2749
+ const msg = JSON.parse(data)
2750
+ if (msg?.type === "subscribe" && typeof msg.convId === "string" && sub) {
2751
+ const conv = getConv(msg.convId)
2752
+ if (!conv) return
2753
+ if (!userCanAccess(conv, userId)) return
2754
+ sub.convs.add(msg.convId)
2755
+ } else if (msg?.type === "unsubscribe" && typeof msg.convId === "string" && sub) {
2756
+ sub.convs.delete(msg.convId)
2757
+ }
2758
+ } catch (e) {
2759
+ try { ws.send(JSON.stringify({ type: "error", message: "bad message" })) } catch {}
2760
+ }
2761
+ },
2762
+ onClose() {
2763
+ if (sub) chatSubscribers.delete(sub)
2764
+ sub = null
2765
+ },
2766
+ }
2767
+ })
2768
+ )
2769
+
2770
+ // ── Kanban WebSocket (real-time updates) ──
2771
+
2772
+ app.get(
2773
+ "/ws/kanban",
2774
+ upgradeWebSocket(async (c) => {
2775
+ const userId = getRequestUserId(c)
2776
+ if (!userId) {
2777
+ return {
2778
+ onOpen(_e, ws) {
2779
+ ws.send(JSON.stringify({ type: "error", message: "unauthorized" }))
2780
+ ws.close()
2781
+ },
2782
+ }
2783
+ }
2784
+ return {
2785
+ onOpen(_e, ws) {
2786
+ const sub: KanbanSubscriber = { ws, userId }
2787
+ kanbanSubscribers.add(sub)
2788
+ ws.send(JSON.stringify({ type: "kanban_connected" }))
2789
+ },
2790
+ onMessage(_event, _ws) {
2791
+ // No client-to-server messages needed for Kanban — it's broadcast-only
2792
+ },
2793
+ onClose(_e, ws) {
2794
+ for (const sub of kanbanSubscribers) {
2795
+ if (sub.ws === ws) { kanbanSubscribers.delete(sub); break }
2796
+ }
2797
+ },
2798
+ }
2799
+ })
2800
+ )
2801
+
2802
+ app.get(
2803
+ "/ws/loop/:id/term",
2804
+ upgradeWebSocket(async (c) => {
2805
+ const id = c.req.param("id") ?? ""
2806
+ const userId = getRequestUserId(c)
2807
+ if (!userId) {
2808
+ return {
2809
+ onOpen(_e, ws) {
2810
+ ws.send(JSON.stringify({ type: "error", message: "unauthorized" }))
2811
+ ws.close()
2812
+ },
2813
+ }
2814
+ }
2815
+ const canWrite = true
2816
+ const exists = await loopExists(id)
2817
+ if (!exists) {
2818
+ return {
2819
+ onOpen(_e, ws) {
2820
+ ws.send(JSON.stringify({ type: "error", message: `loop ${id} not found` }))
2821
+ ws.close()
2822
+ },
2823
+ }
2824
+ }
2825
+ // Read client dimensions from query params so we can resize the PTY
2826
+ // before replaying scrollback — avoids displaying 80×24 content on a
2827
+ // larger terminal, which misplaces the cursor.
2828
+ const qCols = parseInt(c.req.query("cols") || "0")
2829
+ const qRows = parseInt(c.req.query("rows") || "0")
2830
+ let attachedTerm: any = null
2831
+ return {
2832
+ async onOpen(_e, ws) {
2833
+ attachedTerm = ws
2834
+ try {
2835
+ if (qCols > 0 && qRows > 0) {
2836
+ await attachTerm(id, ws, qCols, qRows)
2837
+ } else {
2838
+ await attachTerm(id, ws)
2839
+ }
2840
+ } catch (e: any) {
2841
+ attachedTerm = null
2842
+ const msg = e?.message ?? String(e)
2843
+ console.error(`[term:${id.slice(0, 8)}] attach failed: ${msg}`)
2844
+ try {
2845
+ ws.send(JSON.stringify({ type: "error", message: msg }))
2846
+ ws.send(JSON.stringify({ type: "exit", code: -1 }))
2847
+ } catch {}
2848
+ try { ws.close() } catch {}
2849
+ }
2850
+ },
2851
+ async onMessage(event, ws) {
2852
+ try {
2853
+ const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer)
2854
+ const msg = JSON.parse(data)
2855
+ // resize is harmless; allow anonymous so viewers don't trigger auth errors on connect
2856
+ if (msg?.type === "resize" && typeof msg.cols === "number" && typeof msg.rows === "number") {
2857
+ resizeTerm(id, msg.cols, msg.rows)
2858
+ return
2859
+ }
2860
+ if (!canWrite) {
2861
+ try { ws.send(JSON.stringify({ type: "error", message: "login required to send" })) } catch {}
2862
+ return
2863
+ }
2864
+ // Block writes on archived loops (re-read each msg to honor unarchive).
2865
+ const meta = await getLoop(id)
2866
+ if (meta?.archived) {
2867
+ try { ws.send(JSON.stringify({ type: "error", message: "loop is archived (read-only)" })) } catch {}
2868
+ return
2869
+ }
2870
+ if (meta?.rfdRequestedAt) {
2871
+ try { ws.send(JSON.stringify({ type: "error", message: "loop is in RFD state — click Drive to take over" })) } catch {}
2872
+ return
2873
+ }
2874
+ if (meta && userId && !isDriver(meta, userId)) {
2875
+ try { ws.send(JSON.stringify({ type: "error", message: `only driver (${effectiveDriver(meta)}) can write` })) } catch {}
2876
+ return
2877
+ }
2878
+ if (msg?.type === "data" && typeof msg.data === "string") writeTerm(id, msg.data)
2879
+ } catch (e) {
2880
+ console.error("term ws parse", e)
2881
+ }
2882
+ },
2883
+ onClose() {
2884
+ if (attachedTerm) detachTerm(id, attachedTerm)
2885
+ },
2886
+ }
2887
+ })
2888
+ )
2889
+
2890
+ app.get(
2891
+ "/ws/loop/:id",
2892
+ upgradeWebSocket(async (c) => {
2893
+ const id = c.req.param("id") ?? ""
2894
+ const userId = getRequestUserId(c)
2895
+ const canWrite = !!userId
2896
+ const exists = await loopExists(id)
2897
+ if (!exists) {
2898
+ return {
2899
+ onOpen(_e, ws) {
2900
+ ws.send(JSON.stringify({ type: "error", message: `loop ${id} not found` }))
2901
+ ws.close()
2902
+ },
2903
+ }
2904
+ }
2905
+ // Anonymous attach is only allowed for loops that have been explicitly
2906
+ // shared (meta.public). Logged-in users can attach to any loop they can
2907
+ // see. Writes (sendUserText/clear/etc) for anon are blocked below.
2908
+ if (!userId) {
2909
+ const meta = await getLoop(id)
2910
+ if (!meta?.public) {
2911
+ return {
2912
+ onOpen(_e, ws) {
2913
+ ws.send(JSON.stringify({ type: "error", message: "unauthorized" }))
2914
+ ws.close()
2915
+ },
2916
+ }
2917
+ }
2918
+ }
2919
+ const session = getSession(id)
2920
+ let attached: any = null
2921
+ return {
2922
+ async onOpen(_e, ws) {
2923
+ attached = ws
2924
+ await session.attach(ws)
2925
+ },
2926
+ async onMessage(event, ws) {
2927
+ if (!canWrite) {
2928
+ try { ws.send(JSON.stringify({ type: "error", message: "login required to send" })) } catch {}
2929
+ return
2930
+ }
2931
+ // Block all writes on archived loops. Re-read meta per message so
2932
+ // unarchive takes effect without reconnect.
2933
+ const meta = await getLoop(id)
2934
+ if (meta?.archived) {
2935
+ try { ws.send(JSON.stringify({ type: "error", message: "loop is archived (read-only)" })) } catch {}
2936
+ return
2937
+ }
2938
+ if (meta?.rfdRequestedAt) {
2939
+ try { ws.send(JSON.stringify({ type: "error", message: "loop is in RFD state — click Drive to take over" })) } catch {}
2940
+ return
2941
+ }
2942
+ if (meta && userId && !isDriver(meta, userId)) {
2943
+ try { ws.send(JSON.stringify({ type: "error", message: `only driver (${effectiveDriver(meta)}) can write` })) } catch {}
2944
+ return
2945
+ }
2946
+ try {
2947
+ const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer)
2948
+ const msg = JSON.parse(data)
2949
+ if (msg?.type === "user" && typeof msg.text === "string") {
2950
+ // Validate against SDK PermissionMode values
2951
+ const validModes = ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"]
2952
+ const pm = msg.permissionMode
2953
+ const permissionMode = typeof pm === "string" && validModes.includes(pm)
2954
+ ? pm as "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk" | "auto"
2955
+ : undefined
2956
+ // /goal: extract goal, persist to meta, set on session.
2957
+ // Rewrite the text so CC sees a natural-language message instead
2958
+ // of an unrecognized slash command.
2959
+ const goalMatch = msg.text.match(/^\/goal\s+(.+)/)
2960
+ if (goalMatch) {
2961
+ const goal = goalMatch[1].trim()
2962
+ const setAt = new Date().toISOString()
2963
+ session.setGoal(goal, setAt)
2964
+ patchLoopMeta(id, { config: { ...(meta?.config ?? {}), goal, goalSetAt: setAt, goalStatus: "active" } }).catch(() => {})
2965
+ msg.text = `My goal is: ${goal}`
2966
+ }
2967
+ session.sendUserText(msg.text, permissionMode)
2968
+ } else if (msg?.type === "clear") {
2969
+ session.clear(userId ?? "anon")
2970
+ } else if (msg?.type === "interrupt") {
2971
+ session.interrupt()
2972
+ } else if (msg?.type === "queue_clear") {
2973
+ session.clearQueue()
2974
+ } else if (msg?.type === "queue_remove") {
2975
+ if (typeof msg?.index === "number") session.removeQueueItem(msg.index)
2976
+ } else if (msg?.type === "queue_status") {
2977
+ try { ws.send(JSON.stringify({ type: "queue_update", queueLength: session.getQueueLength() })) } catch {}
2978
+ } else if (msg?.type === "answers") {
2979
+ session.answerQuestions(msg.tool_use_id, msg.answers)
2980
+ } else if (msg?.type === "permission_answer") {
2981
+ session.answerPermission(msg.tool_use_id, !!msg.allow)
2982
+ } else if (msg?.type === "set_max_thinking_tokens") {
2983
+ session.setMaxThinkingTokens(
2984
+ typeof msg.tokens === "number" || msg.tokens === null ? msg.tokens : null,
2985
+ )
2986
+ } else if (msg?.type === "get_context_usage") {
2987
+ session.getContextUsage().then((usage) => {
2988
+ if (usage) {
2989
+ try { ws.send(JSON.stringify({ type: "context_usage", ...usage })) } catch {}
2990
+ }
2991
+ }).catch(() => {})
2992
+ } else if (msg?.type === "set_goal") {
2993
+ const goal = typeof msg.goal === "string" && msg.goal.trim() ? msg.goal.trim() : null
2994
+ const setAt = goal ? new Date().toISOString() : undefined
2995
+ session.setGoal(goal, setAt)
2996
+ patchLoopMeta(id, { config: { ...(meta?.config ?? {}), goal: goal ?? undefined, goalSetAt: setAt ?? undefined, goalStatus: goal ? "active" : undefined } }).catch(() => {})
2997
+ } else if (msg?.type === "complete_goal") {
2998
+ session.completeGoal()
2999
+ patchLoopMeta(id, { config: { ...(meta?.config ?? {}), goalStatus: "completed" } }).catch(() => {})
3000
+ } else if (msg?.type === "provider_select" && typeof msg.provider === "string") {
3001
+ const ok = session.setProvider(msg.provider)
3002
+ if (ok) {
3003
+ const source = msg.source === "personal" || msg.source === "workspace" ? msg.source : undefined
3004
+ const selectedModel = typeof msg.model === "string" ? msg.model : undefined
3005
+ // Persist to loop meta so it survives reloads
3006
+ patchLoopMeta(id, { config: { default_model: msg.provider, default_model_source: source, ...(selectedModel ? { default_model_id: selectedModel } : {}) } }).catch(() => {})
3007
+ try {
3008
+ // Resolve provider info: personal first, then workspace fallback.
3009
+ let p: ProviderConfig | undefined
3010
+ if (userId) {
3011
+ try {
3012
+ const loopMeta = await getLoop(id)
3013
+ const pCfg = await loadPersonalConfig(userId, loopMeta?.config?.vault)
3014
+ p = pCfg.providers[msg.provider]
3015
+ } catch {}
3016
+ }
3017
+ if (!p) {
3018
+ const wCfg = await loadConfig()
3019
+ p = wCfg.providers?.[msg.provider]
3020
+ }
3021
+ if (p) {
3022
+ const activeModel = selectedModel ?? p.models.find(m => m.enabled !== false)?.id ?? p.models[0]?.id ?? ""
3023
+ const activeModelEntry = p.models.find(m => m.id === activeModel)
3024
+ const ctxWindow = activeModelEntry?.maxContextTokens && activeModelEntry.maxContextTokens > 0
3025
+ ? activeModelEntry.maxContextTokens
3026
+ : p.maxContextTokens && p.maxContextTokens > 0
3027
+ ? p.maxContextTokens
3028
+ : 200_000
3029
+ ws.send(JSON.stringify({
3030
+ type: "provider",
3031
+ name: msg.provider,
3032
+ model: activeModel,
3033
+ models: p.models,
3034
+ contextWindow: ctxWindow,
3035
+ }))
3036
+ }
3037
+ } catch {}
3038
+ }
3039
+ }
3040
+ } catch (e) {
3041
+ console.error("ws message parse error", e)
3042
+ }
3043
+ },
3044
+ onClose() {
3045
+ if (attached) session.detach(attached)
3046
+ },
3047
+ }
3048
+ })
3049
+ )
3050
+
3051
+ // ── static assets (production) ──
3052
+ import { getLoopStatus, watchStatusFile, markLoopViewed, type LoopStatusMap } from "./loop-status"
3053
+
3054
+ // ── Loop status real-time hub ──
3055
+
3056
+ let lastSnapshot: LoopStatusMap = getLoopStatus()
3057
+ const statusWatchers = new Map<any, Set<string>>()
3058
+
3059
+ watchStatusFile((curr, prev) => {
3060
+ lastSnapshot = curr
3061
+ for (const [ws, ids] of statusWatchers) {
3062
+ const updates: LoopStatusMap = {}
3063
+ for (const id of ids) {
3064
+ if (curr[id]?.updated !== prev[id]?.updated) {
3065
+ updates[id] = curr[id]
3066
+ }
3067
+ }
3068
+ if (Object.keys(updates).length) {
3069
+ try { ws.send(JSON.stringify({ type: "update", data: updates })) } catch {}
3070
+ }
3071
+ }
3072
+ })
3073
+
3074
+ app.get("/ws/loop-status", upgradeWebSocket((c) => {
3075
+ return {
3076
+ onOpen: (_ev, ws) => {
3077
+ statusWatchers.set(ws, new Set())
3078
+ },
3079
+ onMessage: (ev, ws) => {
3080
+ try {
3081
+ const text = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data as ArrayBuffer)
3082
+ const msg = JSON.parse(text)
3083
+ if (msg.type === "subscribe") {
3084
+ const ids = new Set(msg.ids as string[])
3085
+ statusWatchers.set(ws, ids)
3086
+ const init: LoopStatusMap = {}
3087
+ for (const id of ids) {
3088
+ if (lastSnapshot[id]) init[id] = lastSnapshot[id]
3089
+ }
3090
+ ws.send(JSON.stringify({ type: "init", data: init }))
3091
+ }
3092
+ } catch (e) {
3093
+ console.error("[ws/loop-status] error:", e)
3094
+ }
3095
+ },
3096
+ onClose: (_ev, ws) => {
3097
+ statusWatchers.delete(ws)
3098
+ }
3099
+ }
3100
+ }))
3101
+
3102
+ import { join } from "node:path"
3103
+ import { networkInterfaces } from "node:os"
3104
+ const webDist = join(import.meta.dir, "..", "..", "web", "dist")
3105
+ const indexHtml = join(webDist, "index.html")
3106
+
3107
+ app.get("*", async (c, next) => {
3108
+ const path = c.req.path
3109
+ // Don't interfere with API / WS routes
3110
+ if (path.startsWith("/api/") || path.startsWith("/ws/")) return next()
3111
+ // Try to serve the exact file
3112
+ const file = Bun.file(join(webDist, path === "/" ? "index.html" : path))
3113
+ if (await file.exists()) {
3114
+ return new Response(file, {
3115
+ headers: { "content-type": file.type },
3116
+ })
3117
+ }
3118
+ // SPA fallback
3119
+ return new Response(Bun.file(indexHtml), {
3120
+ headers: { "content-type": "text/html" },
3121
+ })
3122
+ })
3123
+
3124
+ const port = Number(process.env.PORT ?? 7787)
3125
+ const hostname = process.env.HOST ?? "127.0.0.1"
3126
+
3127
+ // Fast, serve-critical init only — keep this short so the port opens quickly.
3128
+ await ensureWorkspaceDirs()
3129
+ const cfg = await loadConfig()
3130
+ // Initialise chat DB. bootstrap user = first admin (if one exists) — only used
3131
+ // to seed the default #general channel on a fresh DB.
3132
+ let chatSeed = ""
3133
+ try {
3134
+ const users = await listUsers()
3135
+ const firstAdmin = users.find((u) => u.role === "admin")
3136
+ chatSeed = firstAdmin?.id ?? users[0]?.id ?? ""
3137
+ } catch {}
3138
+ initChat(chatSeed)
3139
+
3140
+ // Open the port NOW, before the slow boot work below (mount backfill, podman
3141
+ // probe, container prewarm). Otherwise the vite dev proxy hits ECONNREFUSED
3142
+ // during the seconds those awaits run. The rest boots while we're listening.
3143
+ const server = Bun.serve({
3144
+ port,
3145
+ hostname,
3146
+ fetch: app.fetch,
3147
+ websocket,
3148
+ })
3149
+ console.log(`[loopat] server listening on http://${hostname}:${port}`)
3150
+ console.log(`[loopat] workspace serve starting via podman container (port ${process.env.LOOPAT_SERVE_PORT ?? "7788"})`)
3151
+
3152
+ await printBootstrapBanner(cfg)
3153
+ const backfilled = await backfillAllMounts()
3154
+ if (backfilled > 0) console.log(`[loopat] backfilled context mounts on ${backfilled} loop(s)`)
3155
+
3156
+ // Pull every imported personal repo from its remote on boot (best-effort).
3157
+ // personal is a per-user repo synced directly (not via loops), so a host that
3158
+ // was offline catches up here; settings edits then write-through on save.
3159
+ void (async () => {
3160
+ try {
3161
+ for (const u of await listUsers()) {
3162
+ if (await isPersonalFresh(u.id)) continue // not imported yet — nothing to pull
3163
+ const r = await pullPersonalFromRemote(u.id).catch(() => null)
3164
+ if (r && !r.ok) console.warn(`[loopat] boot pull personal (${u.id}): ${r.error}`)
3165
+ }
3166
+ } catch (e: any) {
3167
+ console.warn(`[loopat] boot personal pull-all failed: ${e?.message ?? e}`)
3168
+ }
3169
+ })()
3170
+
3171
+ // Probe podman availability up front so misconfigured hosts fail loudly on
3172
+ // boot rather than mid-session.
3173
+ import { probePodman, stopAllWorkspaceContainers, ensureServeContainer, ensurePortProxyContainer } from "./podman"
3174
+ const podmanProbe = await probePodman()
3175
+ if (podmanProbe.ok) {
3176
+ console.log(`[loopat] sandbox runtime: ${podmanProbe.version}`)
3177
+ // Start workspace serve in a container on the shared bridge network.
3178
+ ensureServeContainer().catch((e) => {
3179
+ console.warn(`[loopat] serve container failed: ${e?.message ?? e}`)
3180
+ })
3181
+ ensurePortProxyContainer().catch((e) => {
3182
+ console.warn(`[loopat] port-proxy container failed: ${e?.message ?? e}`)
3183
+ })
3184
+ } else {
3185
+ console.warn(`[loopat] sandbox runtime: NOT AVAILABLE — ${podmanProbe.hint}`)
3186
+ console.warn(`[loopat] chat / terminal will fail until podman is installed.`)
3187
+ }
3188
+
3189
+ // On graceful shutdown, stop every loopat-managed container so the host
3190
+ // isn't left with orphaned sandbox processes after the server dies.
3191
+ const stopAllOnExit = async () => {
3192
+ try {
3193
+ await stopAllWorkspaceContainers()
3194
+ } catch (e: any) {
3195
+ console.warn(`[loopat] stop-all on exit failed: ${e?.message ?? e}`)
3196
+ }
3197
+ }
3198
+ process.on("SIGINT", () => { void stopAllOnExit().finally(() => process.exit(0)) })
3199
+ process.on("SIGTERM", () => { void stopAllOnExit().finally(() => process.exit(0)) })
3200
+
3201
+ // Plugin caching is delegated to CC itself — admin uses `claude plugin
3202
+ // install` inside each sandbox's .claude/ dir. No loopat-side prewarm.
3203
+
3204
+ export { server }