loopat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +194 -0
  3. package/bin/loopat.mjs +65 -0
  4. package/package.json +52 -0
  5. package/server/package.json +22 -0
  6. package/server/src/api-tokens.ts +161 -0
  7. package/server/src/api-v1-openapi.ts +363 -0
  8. package/server/src/api-v1.ts +681 -0
  9. package/server/src/auth.ts +309 -0
  10. package/server/src/bootstrap.ts +113 -0
  11. package/server/src/chat.ts +390 -0
  12. package/server/src/claude-binary.ts +68 -0
  13. package/server/src/compose.ts +474 -0
  14. package/server/src/config.ts +783 -0
  15. package/server/src/files.ts +173 -0
  16. package/server/src/git-crypt-key.ts +36 -0
  17. package/server/src/git-host.ts +104 -0
  18. package/server/src/github.ts +161 -0
  19. package/server/src/index.ts +3204 -0
  20. package/server/src/kanban.ts +810 -0
  21. package/server/src/loop-stats.ts +225 -0
  22. package/server/src/loop-status.ts +67 -0
  23. package/server/src/loops.ts +1832 -0
  24. package/server/src/mcp-oauth.ts +516 -0
  25. package/server/src/onboarding.ts +105 -0
  26. package/server/src/paths.ts +190 -0
  27. package/server/src/personal-keys.ts +60 -0
  28. package/server/src/plugin-installer.ts +287 -0
  29. package/server/src/podman.ts +1216 -0
  30. package/server/src/presets.ts +30 -0
  31. package/server/src/profiles.ts +177 -0
  32. package/server/src/providers.ts +45 -0
  33. package/server/src/serve.ts +275 -0
  34. package/server/src/session.ts +1496 -0
  35. package/server/src/system-prompt.ts +90 -0
  36. package/server/src/term.ts +211 -0
  37. package/server/src/tiers.ts +762 -0
  38. package/server/src/vaults.ts +189 -0
  39. package/server/src/workspace.ts +501 -0
  40. package/server/templates/.claude-plugin/marketplace.json +13 -0
  41. package/server/templates/CLAUDE.md +78 -0
  42. package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
  43. package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
  44. package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
  45. package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
  46. package/server/templates/sandbox/Containerfile +113 -0
  47. package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
  48. package/web/dist/assets/Editor-DMS25Vve.js +1 -0
  49. package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
  50. package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
  51. package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
  52. package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
  53. package/web/dist/assets/index-DM5eO-Tv.js +163 -0
  54. package/web/dist/assets/index-DxIFezwv.css +1 -0
  55. package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
  56. package/web/dist/favicon.svg +1 -0
  57. package/web/dist/index.html +14 -0
  58. package/web/dist/logo.png +0 -0
@@ -0,0 +1,681 @@
1
+ /**
2
+ * Loop API v1 — see docs/api-v1.md.
3
+ *
4
+ * Public surface: just "chat with a loop". CRUD on loops + send message (SSE)
5
+ * + watch events (SSE) + answer choices + interrupt.
6
+ *
7
+ * All other web features (loop-list status, kanban, terminal, token usage
8
+ * meter, DM/channels) continue to use their existing WS or internal REST.
9
+ */
10
+ import { Hono, type Context, type MiddlewareHandler } from "hono"
11
+ import { streamSSE } from "hono/streaming"
12
+ import { Scalar } from "@scalar/hono-api-reference"
13
+ import { randomBytes } from "node:crypto"
14
+ import { getRequestUserId } from "./auth"
15
+ import { resolveApiToken, createApiToken, listApiTokens, revokeApiToken } from "./api-tokens"
16
+ import { v1OpenApiSpec } from "./api-v1-openapi"
17
+ import {
18
+ createLoop as internalCreateLoop,
19
+ getLoop,
20
+ listLoops,
21
+ loopExists,
22
+ patchLoopMeta,
23
+ type LoopMeta,
24
+ } from "./loops"
25
+ import { getSession, type LoopSessionMessageListener } from "./session"
26
+
27
+ // ── ID prefixing ─────────────────────────────────────────────────────────
28
+
29
+ const LOOP_PREFIX = "loop_"
30
+ const TURN_PREFIX = "turn_"
31
+ const CHOICE_PREFIX = "choice_"
32
+
33
+ function loopIdToApi(rawId: string): string {
34
+ return rawId.startsWith(LOOP_PREFIX) ? rawId : `${LOOP_PREFIX}${rawId}`
35
+ }
36
+ function loopIdFromApi(apiId: string): string {
37
+ return apiId.startsWith(LOOP_PREFIX) ? apiId.slice(LOOP_PREFIX.length) : apiId
38
+ }
39
+ function genTurnId(): string {
40
+ return `${TURN_PREFIX}${randomBytes(10).toString("hex")}`
41
+ }
42
+ function choiceIdToApi(toolUseId: string): string {
43
+ return toolUseId.startsWith(CHOICE_PREFIX) ? toolUseId : `${CHOICE_PREFIX}${toolUseId}`
44
+ }
45
+ function choiceIdFromApi(apiId: string): string {
46
+ return apiId.startsWith(CHOICE_PREFIX) ? apiId.slice(CHOICE_PREFIX.length) : apiId
47
+ }
48
+
49
+ // ── Auth: cookie OR Bearer ───────────────────────────────────────────────
50
+
51
+ async function resolveCaller(c: Context): Promise<string | null> {
52
+ // Try cookie session first (web same-origin), then Bearer token.
53
+ const sessionUser = getRequestUserId(c)
54
+ if (sessionUser) return sessionUser
55
+ const auth = c.req.header("authorization") ?? null
56
+ return await resolveApiToken(auth)
57
+ }
58
+
59
+ const requireApiAuth: MiddlewareHandler = async (c, next) => {
60
+ const userId = await resolveCaller(c)
61
+ if (!userId) {
62
+ return c.json({
63
+ error: { type: "authentication_error", code: "missing_credentials", message: "missing or invalid credentials" },
64
+ }, 401)
65
+ }
66
+ c.set("userId", userId)
67
+ await next()
68
+ }
69
+
70
+ // ── Error helper ─────────────────────────────────────────────────────────
71
+
72
+ function apiError(c: Context, status: number, type: string, code: string, message: string) {
73
+ return c.json({ error: { type, code, message } }, status as any)
74
+ }
75
+
76
+ // ── Loop resource shape ──────────────────────────────────────────────────
77
+
78
+ function metaToApi(meta: LoopMeta, opts: { withRuntime: boolean } = { withRuntime: false }) {
79
+ const base: Record<string, unknown> = {
80
+ id: loopIdToApi(meta.id),
81
+ title: meta.title,
82
+ created_at: meta.createdAt,
83
+ created_by: meta.createdBy,
84
+ archived: !!meta.archived,
85
+ archived_at: meta.archivedAt ?? null,
86
+ metadata: (meta as any).metadata ?? {},
87
+ profiles: meta.config?.profiles ?? [],
88
+ vault: meta.config?.vault ?? "default",
89
+ repo: meta.repo ?? null,
90
+ }
91
+ if (opts.withRuntime) {
92
+ const session = getSession(meta.id)
93
+ const busy = session.isBusy()
94
+ base.busy = busy
95
+ base.queue_depth = session.getQueueLength()
96
+ const currentChoice = pendingChoiceFor(meta.id)
97
+ base.current_turn = busy
98
+ ? {
99
+ turn_id: currentTurnIdFor(meta.id) ?? null,
100
+ started_at: currentTurnStartedAtFor(meta.id) ?? null,
101
+ pending_choice_id: currentChoice ?? null,
102
+ }
103
+ : null
104
+ }
105
+ return base
106
+ }
107
+
108
+ // ── Per-loop runtime trackers (in-memory) ────────────────────────────────
109
+ //
110
+ // MVP: track the current turn id + start time and the latest pending choice
111
+ // for each loop in memory. Used only to surface `current_turn` / snapshot
112
+ // state via the API; not authoritative — if the server restarts, the loop
113
+ // continues but these trackers reset.
114
+
115
+ type LoopRuntime = {
116
+ currentTurnId?: string
117
+ currentTurnStartedAt?: string
118
+ currentAssistantText: string
119
+ pendingChoiceId?: string
120
+ }
121
+ const loopRuntime = new Map<string, LoopRuntime>()
122
+ function rt(loopId: string): LoopRuntime {
123
+ let r = loopRuntime.get(loopId)
124
+ if (!r) {
125
+ r = { currentAssistantText: "" }
126
+ loopRuntime.set(loopId, r)
127
+ }
128
+ return r
129
+ }
130
+ function currentTurnIdFor(id: string): string | undefined { return loopRuntime.get(id)?.currentTurnId }
131
+ function currentTurnStartedAtFor(id: string): string | undefined { return loopRuntime.get(id)?.currentTurnStartedAt }
132
+ function pendingChoiceFor(id: string): string | undefined { return loopRuntime.get(id)?.pendingChoiceId }
133
+
134
+ // ── SDK message → v1 SSE event mapping ───────────────────────────────────
135
+
136
+ type V1Event = { event: string; data: Record<string, unknown> }
137
+
138
+ /**
139
+ * Pass-through event that emits the raw SDK / session-broadcast message
140
+ * verbatim. Loopat's own web UI consumes this to drive its rich chat view
141
+ * without forcing the team to rewrite its SDK-shaped dispatch pipeline.
142
+ *
143
+ * Bot frameworks should NOT depend on the shape — it's the underlying
144
+ * Anthropic SDK message format, which can change. The stable bot-facing
145
+ * events (assistant_delta, tool_call, etc.) are what's contractual.
146
+ */
147
+ function sdkPassthrough(msg: any): V1Event | null {
148
+ if (!msg || typeof msg !== "object") return null
149
+ if (typeof msg.type !== "string") return null
150
+ // Skip our own synthetic control events — they don't represent a real
151
+ // session-broadcast message worth replaying.
152
+ if (msg.type === "choice_resolved" || msg.type === "interrupted") return null
153
+ return { event: "sdk_message", data: msg as Record<string, unknown> }
154
+ }
155
+
156
+ function mapSdkMessageToV1(msg: any, runtime: LoopRuntime): V1Event[] {
157
+ const out: V1Event[] = []
158
+ const type = msg?.type
159
+
160
+ // Fine-grained streaming deltas via stream_event.
161
+ if (type === "stream_event") {
162
+ const ev = msg.event
163
+ if (ev?.type === "content_block_delta") {
164
+ const delta = ev.delta
165
+ if (delta?.type === "text_delta" && typeof delta.text === "string") {
166
+ runtime.currentAssistantText += delta.text
167
+ out.push({ event: "assistant_delta", data: { text: delta.text } })
168
+ } else if (delta?.type === "thinking_delta" && typeof delta.text === "string") {
169
+ out.push({ event: "thinking_delta", data: { text: delta.text } })
170
+ }
171
+ }
172
+ return out
173
+ }
174
+
175
+ // tool_call from assistant content blocks; tool_result from synthetic user content blocks.
176
+ if (type === "assistant" && Array.isArray(msg.message?.content)) {
177
+ for (const block of msg.message.content) {
178
+ if (block?.type === "tool_use" && typeof block.id === "string") {
179
+ out.push({
180
+ event: "tool_call",
181
+ data: {
182
+ tool_use_id: block.id,
183
+ tool: block.name ?? "unknown",
184
+ input_summary: summarizeToolInput(block.input),
185
+ },
186
+ })
187
+ }
188
+ }
189
+ return out
190
+ }
191
+ if (type === "user" && Array.isArray(msg.message?.content)) {
192
+ for (const block of msg.message.content) {
193
+ if (block?.type === "tool_result" && typeof block.tool_use_id === "string") {
194
+ out.push({
195
+ event: "tool_result",
196
+ data: { tool_use_id: block.tool_use_id, ok: !block.is_error },
197
+ })
198
+ }
199
+ }
200
+ return out
201
+ }
202
+
203
+ // Choices.
204
+ if (type === "permission_prompt" && typeof msg.tool_use_id === "string") {
205
+ const choiceId = choiceIdToApi(msg.tool_use_id)
206
+ runtime.pendingChoiceId = choiceId
207
+ out.push({
208
+ event: "requires_choice",
209
+ data: {
210
+ choice_id: choiceId,
211
+ kind: "permission",
212
+ payload: {
213
+ tool: msg.tool_name,
214
+ title: msg.title,
215
+ display_name: msg.displayName,
216
+ },
217
+ },
218
+ })
219
+ return out
220
+ }
221
+ if (type === "question" && typeof msg.tool_use_id === "string") {
222
+ const choiceId = choiceIdToApi(msg.tool_use_id)
223
+ runtime.pendingChoiceId = choiceId
224
+ out.push({
225
+ event: "requires_choice",
226
+ data: {
227
+ choice_id: choiceId,
228
+ kind: "question",
229
+ payload: { questions: msg.questions },
230
+ },
231
+ })
232
+ return out
233
+ }
234
+
235
+ if (type === "result") {
236
+ const turnId = runtime.currentTurnId ?? genTurnId()
237
+ out.push({ event: "done", data: { turn_id: turnId } })
238
+ runtime.currentTurnId = undefined
239
+ runtime.currentTurnStartedAt = undefined
240
+ runtime.currentAssistantText = ""
241
+ return out
242
+ }
243
+
244
+ if (type === "error") {
245
+ out.push({ event: "error", data: { code: "agent_error", message: msg.message ?? "agent error" } })
246
+ return out
247
+ }
248
+
249
+ // Synthetic control events (emitted by api-v1 itself via session.notifyListeners).
250
+ if (type === "choice_resolved") {
251
+ return [{ event: "choice_resolved", data: { choice_id: msg.choice_id, source: msg.source ?? "api" } }]
252
+ }
253
+ if (type === "interrupted") {
254
+ return [{ event: "interrupted", data: { turn_id: msg.turn_id ?? runtime.currentTurnId ?? "" } }]
255
+ }
256
+
257
+ // Everything else (queue_update / provider / goal / viewers / context_usage / etc)
258
+ // is web-UI noise — drop.
259
+ return out
260
+ }
261
+
262
+ function summarizeToolInput(input: unknown): string | undefined {
263
+ if (!input || typeof input !== "object") return undefined
264
+ // Best-effort one-line summary for observability. Don't dump full input.
265
+ try {
266
+ const obj = input as Record<string, unknown>
267
+ if (typeof obj.command === "string") return obj.command
268
+ if (typeof obj.file_path === "string") return obj.file_path
269
+ if (typeof obj.path === "string") return obj.path
270
+ if (typeof obj.query === "string") return obj.query
271
+ if (typeof obj.url === "string") return obj.url
272
+ return undefined
273
+ } catch {
274
+ return undefined
275
+ }
276
+ }
277
+
278
+ // ── Idempotency store ────────────────────────────────────────────────────
279
+ //
280
+ // In-memory MVP. Single-process loopat means this is fine; restart drops
281
+ // records, which is acceptable for a 24h replay window.
282
+
283
+ type IdempotencyRecord = {
284
+ userId: string
285
+ requestHash: string
286
+ events: V1Event[]
287
+ done: boolean
288
+ createdAt: number
289
+ }
290
+ const idempotencyStore = new Map<string, IdempotencyRecord>()
291
+ const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000
292
+
293
+ function idempotencyKey(userId: string, key: string): string {
294
+ return `${userId}|${key}`
295
+ }
296
+
297
+ function sweepIdempotency(): void {
298
+ const now = Date.now()
299
+ for (const [k, v] of idempotencyStore) {
300
+ if (now - v.createdAt > IDEMPOTENCY_TTL_MS) idempotencyStore.delete(k)
301
+ }
302
+ }
303
+
304
+ function hashRequest(content: string): string {
305
+ // Cheap content-based hash; collisions on the same userId+key are vanishingly
306
+ // unlikely for the dedup use case (caller usually retries with the same body).
307
+ let h = 0
308
+ for (let i = 0; i < content.length; i++) h = (h * 31 + content.charCodeAt(i)) | 0
309
+ return h.toString(16)
310
+ }
311
+
312
+ // ── App ──────────────────────────────────────────────────────────────────
313
+
314
+ type Variables = { userId: string }
315
+
316
+ export function buildApiV1(): Hono<{ Variables: Variables }> {
317
+ const v1 = new Hono<{ Variables: Variables }>()
318
+
319
+ // ── Docs ─────────────────────────────────────────────────────────────
320
+ // Machine-readable spec + interactive reference. No auth needed.
321
+ v1.get("/openapi.json", (c) => c.json(v1OpenApiSpec as any))
322
+ v1.get(
323
+ "/docs",
324
+ Scalar({
325
+ url: "/api/v1/openapi.json",
326
+ pageTitle: "Loopat Loop API v1",
327
+ theme: "default",
328
+ }),
329
+ )
330
+
331
+ // ── Token management (cookie-only — bot frameworks cannot self-issue) ─
332
+
333
+ v1.post("/me/tokens", async (c) => {
334
+ const userId = getRequestUserId(c)
335
+ if (!userId) return apiError(c, 401, "authentication_error", "missing_credentials", "session required")
336
+ const body = await c.req.json().catch(() => ({}))
337
+ const label = typeof body.label === "string" ? body.label : ""
338
+ const t = await createApiToken(userId, label)
339
+ return c.json({ tokenId: t.tokenId, token: t.token, label: t.label, createdAt: t.createdAt }, 201)
340
+ })
341
+
342
+ v1.get("/me/tokens", async (c) => {
343
+ const userId = getRequestUserId(c)
344
+ if (!userId) return apiError(c, 401, "authentication_error", "missing_credentials", "session required")
345
+ const tokens = await listApiTokens(userId)
346
+ return c.json({ tokens })
347
+ })
348
+
349
+ v1.delete("/me/tokens/:tokenId", async (c) => {
350
+ const userId = getRequestUserId(c)
351
+ if (!userId) return apiError(c, 401, "authentication_error", "missing_credentials", "session required")
352
+ const ok = await revokeApiToken(userId, c.req.param("tokenId") ?? "")
353
+ if (!ok) return apiError(c, 404, "not_found_error", "token_not_found", "no such token")
354
+ return c.body(null, 204)
355
+ })
356
+
357
+ // ── Loop CRUD ───────────────────────────────────────────────────────
358
+
359
+ v1.post("/loops", requireApiAuth, async (c) => {
360
+ const userId = c.get("userId") as string
361
+ const body = await c.req.json().catch(() => ({}))
362
+ const title = typeof body.title === "string" ? body.title.trim() : ""
363
+ if (title.length > 200) return apiError(c, 400, "invalid_request_error", "title_too_long", "title exceeds 200 chars")
364
+
365
+ const profiles = Array.isArray(body.profiles)
366
+ ? body.profiles.filter((p: unknown): p is string => typeof p === "string")
367
+ : undefined
368
+ const vault = typeof body.vault === "string" ? body.vault : undefined
369
+ const repo = typeof body.repo === "string" && body.repo ? body.repo : undefined
370
+ const metadata = (body.metadata && typeof body.metadata === "object") ? body.metadata as Record<string, unknown> : undefined
371
+ if (metadata && JSON.stringify(metadata).length > 16 * 1024) {
372
+ return apiError(c, 400, "invalid_request_error", "metadata_too_large", "metadata exceeds 16 KB")
373
+ }
374
+
375
+ const meta = await internalCreateLoop({
376
+ title: title || "untitled",
377
+ createdBy: userId,
378
+ profiles,
379
+ vault,
380
+ repo,
381
+ })
382
+ if (metadata) {
383
+ const patched = await patchLoopMeta(meta.id, { metadata })
384
+ if (patched) return c.json(metaToApi(patched), 201)
385
+ }
386
+ return c.json(metaToApi(meta), 201)
387
+ })
388
+
389
+ v1.get("/loops", requireApiAuth, async (c) => {
390
+ const userId = c.get("userId") as string
391
+ const limit = Math.min(Math.max(parseInt(c.req.query("limit") ?? "20", 10) || 20, 1), 100)
392
+ const after = c.req.query("after")
393
+ const before = c.req.query("before")
394
+ const includeArchived = c.req.query("archived") === "true"
395
+
396
+ const all = (await listLoops()).filter((m) => m.createdBy === userId && (includeArchived || !m.archived))
397
+
398
+ let filtered = all
399
+ if (after) {
400
+ const rawAfter = loopIdFromApi(after)
401
+ const idx = all.findIndex((m) => m.id === rawAfter)
402
+ filtered = idx >= 0 ? all.slice(idx + 1) : []
403
+ } else if (before) {
404
+ const rawBefore = loopIdFromApi(before)
405
+ const idx = all.findIndex((m) => m.id === rawBefore)
406
+ filtered = idx >= 0 ? all.slice(0, idx) : []
407
+ }
408
+
409
+ const page = filtered.slice(0, limit)
410
+ const hasMore = filtered.length > limit
411
+ return c.json({
412
+ data: page.map((m) => metaToApi(m)),
413
+ first_id: page[0] ? loopIdToApi(page[0].id) : null,
414
+ last_id: page[page.length - 1] ? loopIdToApi(page[page.length - 1].id) : null,
415
+ has_more: hasMore,
416
+ })
417
+ })
418
+
419
+ v1.get("/loops/:id", requireApiAuth, async (c) => {
420
+ const userId = c.get("userId") as string
421
+ const id = loopIdFromApi(c.req.param("id") ?? "")
422
+ const meta = await getLoop(id)
423
+ if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found")
424
+ if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop")
425
+ return c.json(metaToApi(meta, { withRuntime: true }))
426
+ })
427
+
428
+ v1.delete("/loops/:id", requireApiAuth, async (c) => {
429
+ const userId = c.get("userId") as string
430
+ const id = loopIdFromApi(c.req.param("id") ?? "")
431
+ const meta = await getLoop(id)
432
+ if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found")
433
+ if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop")
434
+ if (!meta.archived) {
435
+ await patchLoopMeta(id, { archived: true, archivedAt: new Date().toISOString() } as any)
436
+ }
437
+ return c.body(null, 204)
438
+ })
439
+
440
+ // ── Send message (SSE) ──────────────────────────────────────────────
441
+
442
+ v1.post("/loops/:id/messages", requireApiAuth, async (c) => {
443
+ const userId = c.get("userId") as string
444
+ const id = loopIdFromApi(c.req.param("id") ?? "")
445
+ if (!(await loopExists(id))) {
446
+ return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found")
447
+ }
448
+ const meta = await getLoop(id)
449
+ if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found")
450
+ if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop")
451
+ if (meta.archived) return apiError(c, 400, "invalid_request_error", "loop_archived", "loop is archived")
452
+
453
+ const body = await c.req.json().catch(() => ({}))
454
+ const content = typeof body.content === "string" ? body.content : ""
455
+ if (!content) return apiError(c, 400, "invalid_request_error", "missing_content", "content required")
456
+ if (content.length > 1024 * 1024) return apiError(c, 400, "invalid_request_error", "content_too_large", "content exceeds 1 MB")
457
+ const VALID_MODES = new Set(["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"])
458
+ const permissionMode = typeof body.permission_mode === "string" && VALID_MODES.has(body.permission_mode)
459
+ ? body.permission_mode as "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk" | "auto"
460
+ : undefined
461
+
462
+ // Idempotency check.
463
+ sweepIdempotency()
464
+ const idemKeyHeader = c.req.header("idempotency-key")
465
+ const reqHash = hashRequest(content)
466
+ let idemRecord: IdempotencyRecord | undefined
467
+ if (idemKeyHeader) {
468
+ if (idemKeyHeader.length > 256) {
469
+ return apiError(c, 400, "invalid_request_error", "idempotency_key_too_long", "Idempotency-Key exceeds 256 chars")
470
+ }
471
+ const fullKey = idempotencyKey(userId, idemKeyHeader)
472
+ idemRecord = idempotencyStore.get(fullKey)
473
+ if (idemRecord && idemRecord.requestHash !== reqHash) {
474
+ return apiError(c, 409, "conflict_error", "idempotency_key_reused",
475
+ "Idempotency-Key was previously used with a different request body")
476
+ }
477
+ if (!idemRecord) {
478
+ idemRecord = { userId, requestHash: reqHash, events: [], done: false, createdAt: Date.now() }
479
+ idempotencyStore.set(fullKey, idemRecord)
480
+ }
481
+ }
482
+
483
+ return streamSSE(c, async (stream) => {
484
+ const session = getSession(id)
485
+ const runtime = rt(id)
486
+ const wasBusy = session.isBusy()
487
+
488
+ // Emit (and replay-record) one event.
489
+ const emit = async (ev: V1Event) => {
490
+ idemRecord?.events.push(ev)
491
+ await stream.writeSSE({ event: ev.event, data: JSON.stringify(ev.data) })
492
+ }
493
+
494
+ const isReplay = !!idemRecord && idemRecord.events.length > 0
495
+
496
+ if (isReplay) {
497
+ for (const ev of idemRecord!.events) {
498
+ await stream.writeSSE({ event: ev.event, data: JSON.stringify(ev.data) })
499
+ }
500
+ if (idemRecord!.done) return
501
+ // Still in progress — attach to live stream below; do NOT re-send content.
502
+ } else {
503
+ if (wasBusy) {
504
+ await emit({ event: "queued", data: { position: session.getQueueLength() + 1 } })
505
+ }
506
+ const turnId = genTurnId()
507
+ runtime.currentTurnId = turnId
508
+ runtime.currentTurnStartedAt = new Date().toISOString()
509
+ runtime.currentAssistantText = ""
510
+ await emit({ event: "started", data: { turn_id: turnId, cold_start: false } })
511
+ }
512
+
513
+ let closeFn: () => void = () => {}
514
+ const closedPromise = new Promise<void>((resolve) => { closeFn = resolve })
515
+ let closed = false
516
+ const finishStream = () => {
517
+ if (closed) return
518
+ closed = true
519
+ if (idemRecord) idemRecord.done = true
520
+ closeFn()
521
+ }
522
+
523
+ const unsubscribe = session.onMessage((msg) => {
524
+ if (closed) return
525
+ // Gather every emit() promise for this message — when a terminal
526
+ // event (done/interrupted/error) arrives, we MUST wait for all
527
+ // SSE writes from the SAME batch to flush before calling
528
+ // finishStream(). Otherwise streamSSE's finally{stream.close()}
529
+ // races the pending writeSSE, and the client never sees `done`.
530
+ const pending: Promise<void>[] = []
531
+ const raw = sdkPassthrough(msg)
532
+ if (raw) pending.push(emit(raw).catch(() => {}))
533
+ let terminal = false
534
+ for (const ev of mapSdkMessageToV1(msg, runtime)) {
535
+ pending.push(emit(ev).catch(() => {}))
536
+ if (ev.event === "done" || ev.event === "interrupted" || ev.event === "error") {
537
+ terminal = true
538
+ }
539
+ }
540
+ if (terminal) {
541
+ Promise.all(pending).finally(() => finishStream())
542
+ }
543
+ })
544
+
545
+ const heartbeat = setInterval(() => {
546
+ if (!closed) stream.writeSSE({ event: "ping", data: "{}" }).catch(() => {})
547
+ }, 15_000)
548
+
549
+ if (!isReplay) {
550
+ session.sendUserText(content, permissionMode).catch(async (e: any) => {
551
+ await emit({ event: "error", data: { code: "send_failed", message: e?.message ?? String(e) } })
552
+ finishStream()
553
+ })
554
+ }
555
+
556
+ stream.onAbort(() => { finishStream() })
557
+
558
+ await closedPromise
559
+ unsubscribe()
560
+ clearInterval(heartbeat)
561
+ })
562
+ })
563
+
564
+ // ── Watch events (read-only SSE) ────────────────────────────────────
565
+
566
+ v1.get("/loops/:id/events", requireApiAuth, async (c) => {
567
+ const userId = c.get("userId") as string
568
+ const id = loopIdFromApi(c.req.param("id") ?? "")
569
+ const meta = await getLoop(id)
570
+ if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found")
571
+ if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop")
572
+
573
+ return streamSSE(c, async (stream) => {
574
+ const session = getSession(id)
575
+ const runtime = rt(id)
576
+
577
+ // Snapshot if a turn is currently running.
578
+ if (session.isBusy() && runtime.currentTurnId) {
579
+ await stream.writeSSE({
580
+ event: "snapshot",
581
+ data: JSON.stringify({
582
+ turn_id: runtime.currentTurnId,
583
+ assistant_text_so_far: runtime.currentAssistantText,
584
+ pending_choice_id: runtime.pendingChoiceId ?? null,
585
+ }),
586
+ })
587
+ }
588
+
589
+ let closed = false
590
+ const unsubscribe = session.onMessage((msg) => {
591
+ if (closed) return
592
+ const raw = sdkPassthrough(msg)
593
+ if (raw) stream.writeSSE({ event: raw.event, data: JSON.stringify(raw.data) }).catch(() => {})
594
+ for (const ev of mapSdkMessageToV1(msg, runtime)) {
595
+ stream.writeSSE({ event: ev.event, data: JSON.stringify(ev.data) }).catch(() => {})
596
+ }
597
+ })
598
+
599
+ const heartbeat = setInterval(() => {
600
+ if (!closed) stream.writeSSE({ event: "ping", data: "{}" }).catch(() => {})
601
+ }, 15_000)
602
+
603
+ stream.onAbort(() => {
604
+ closed = true
605
+ unsubscribe()
606
+ clearInterval(heartbeat)
607
+ })
608
+
609
+ // Block until client disconnects.
610
+ await new Promise<void>((resolve) => {
611
+ const check = setInterval(() => {
612
+ if (closed) {
613
+ clearInterval(check)
614
+ clearInterval(heartbeat)
615
+ resolve()
616
+ }
617
+ }, 1000)
618
+ })
619
+ })
620
+ })
621
+
622
+ // ── Answer choice (permission / question) ───────────────────────────
623
+
624
+ v1.post("/loops/:id/choices/:choiceId", requireApiAuth, async (c) => {
625
+ const userId = c.get("userId") as string
626
+ const id = loopIdFromApi(c.req.param("id") ?? "")
627
+ const choiceId = c.req.param("choiceId") ?? ""
628
+ const toolUseId = choiceIdFromApi(choiceId)
629
+
630
+ const meta = await getLoop(id)
631
+ if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found")
632
+ if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop")
633
+
634
+ const body = await c.req.json().catch(() => ({}))
635
+ const session = getSession(id)
636
+ const runtime = rt(id)
637
+
638
+ // Permission path
639
+ if (typeof body.allow === "boolean") {
640
+ const pending = session.hasPendingPermission(toolUseId)
641
+ if (!pending) return apiError(c, 404, "not_found_error", "choice_not_found", "choice not pending")
642
+ await session.answerPermission(toolUseId, body.allow)
643
+ runtime.pendingChoiceId = undefined
644
+ session.notifyListeners({ type: "choice_resolved", choice_id: choiceIdToApi(toolUseId), source: "api" })
645
+ return c.body(null, 202)
646
+ }
647
+
648
+ // Question path
649
+ if (body.answers && typeof body.answers === "object") {
650
+ const pending = session.hasPendingQuestion(toolUseId)
651
+ if (!pending) return apiError(c, 404, "not_found_error", "choice_not_found", "choice not pending")
652
+ await session.answerQuestions(toolUseId, body.answers as Record<string, string>)
653
+ runtime.pendingChoiceId = undefined
654
+ session.notifyListeners({ type: "choice_resolved", choice_id: choiceIdToApi(toolUseId), source: "api" })
655
+ return c.body(null, 202)
656
+ }
657
+
658
+ return apiError(c, 400, "invalid_request_error", "invalid_choice_payload",
659
+ "expected { allow: bool } for permission or { answers: {...} } for question")
660
+ })
661
+
662
+ // ── Interrupt ───────────────────────────────────────────────────────
663
+
664
+ v1.post("/loops/:id/interrupt", requireApiAuth, async (c) => {
665
+ const userId = c.get("userId") as string
666
+ const id = loopIdFromApi(c.req.param("id") ?? "")
667
+ const meta = await getLoop(id)
668
+ if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found")
669
+ if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop")
670
+ const session = getSession(id)
671
+ const runtime = rt(id)
672
+ const turnId = runtime.currentTurnId
673
+ await session.interrupt()
674
+ if (turnId) {
675
+ session.notifyListeners({ type: "interrupted", turn_id: turnId })
676
+ }
677
+ return c.body(null, 202)
678
+ })
679
+
680
+ return v1
681
+ }