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,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
|
+
}
|