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.
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/bin/loopat.mjs +65 -0
- package/package.json +52 -0
- package/server/package.json +22 -0
- package/server/src/api-tokens.ts +161 -0
- package/server/src/api-v1-openapi.ts +363 -0
- package/server/src/api-v1.ts +681 -0
- package/server/src/auth.ts +309 -0
- package/server/src/bootstrap.ts +113 -0
- package/server/src/chat.ts +390 -0
- package/server/src/claude-binary.ts +68 -0
- package/server/src/compose.ts +474 -0
- package/server/src/config.ts +783 -0
- package/server/src/files.ts +173 -0
- package/server/src/git-crypt-key.ts +36 -0
- package/server/src/git-host.ts +104 -0
- package/server/src/github.ts +161 -0
- package/server/src/index.ts +3204 -0
- package/server/src/kanban.ts +810 -0
- package/server/src/loop-stats.ts +225 -0
- package/server/src/loop-status.ts +67 -0
- package/server/src/loops.ts +1832 -0
- package/server/src/mcp-oauth.ts +516 -0
- package/server/src/onboarding.ts +105 -0
- package/server/src/paths.ts +190 -0
- package/server/src/personal-keys.ts +60 -0
- package/server/src/plugin-installer.ts +287 -0
- package/server/src/podman.ts +1216 -0
- package/server/src/presets.ts +30 -0
- package/server/src/profiles.ts +177 -0
- package/server/src/providers.ts +45 -0
- package/server/src/serve.ts +275 -0
- package/server/src/session.ts +1496 -0
- package/server/src/system-prompt.ts +90 -0
- package/server/src/term.ts +211 -0
- package/server/src/tiers.ts +762 -0
- package/server/src/vaults.ts +189 -0
- package/server/src/workspace.ts +501 -0
- package/server/templates/.claude-plugin/marketplace.json +13 -0
- package/server/templates/CLAUDE.md +78 -0
- package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
- package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
- package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
- package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
- package/server/templates/sandbox/Containerfile +113 -0
- package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
- package/web/dist/assets/Editor-DMS25Vve.js +1 -0
- package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
- package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
- package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
- package/web/dist/assets/index-DM5eO-Tv.js +163 -0
- package/web/dist/assets/index-DxIFezwv.css +1 -0
- package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +14 -0
- 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 }
|