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,161 @@
1
+ /**
2
+ * Per-user API tokens for the v1 Loop API.
3
+ *
4
+ * Storage: `$LOOPAT_HOME/api-tokens.json`. Tokens are SHA-256 hashed at rest;
5
+ * the plaintext (`la_<hex>`) is only returned to the caller at creation.
6
+ *
7
+ * Each entry has a stable `tokenId` (independent of the token value) so the
8
+ * web UI can list / revoke without exposing or suffix-matching the secret.
9
+ *
10
+ * Writes are serialized via an in-process promise chain. loopat is
11
+ * single-process so file-level locking isn't needed.
12
+ */
13
+ import { existsSync } from "node:fs"
14
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
15
+ import { createHash, randomBytes } from "node:crypto"
16
+ import { dirname, join } from "node:path"
17
+ import { LOOPAT_HOME } from "./paths"
18
+
19
+ const TOKENS_PATH = join(LOOPAT_HOME, "api-tokens.json")
20
+
21
+ type StoredToken = {
22
+ tokenId: string // stable short id, surfaced to UI
23
+ userId: string
24
+ label: string
25
+ createdAt: string
26
+ lastUsedAt?: string
27
+ }
28
+
29
+ type TokensFile = {
30
+ // keyed by SHA-256(token plaintext)
31
+ tokens: Record<string, StoredToken>
32
+ }
33
+
34
+ let cached: TokensFile | null = null
35
+
36
+ let writeLock: Promise<void> = Promise.resolve()
37
+ function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
38
+ const next = writeLock.then(fn, fn)
39
+ writeLock = next.then(() => {}, () => {})
40
+ return next
41
+ }
42
+
43
+ function hashToken(token: string): string {
44
+ return createHash("sha256").update(token).digest("hex")
45
+ }
46
+
47
+ function generateTokenValue(): string {
48
+ return `la_${randomBytes(24).toString("hex")}`
49
+ }
50
+
51
+ function generateTokenId(): string {
52
+ return `tok_${randomBytes(6).toString("hex")}`
53
+ }
54
+
55
+ async function readTokensFile(): Promise<TokensFile> {
56
+ if (cached) return cached
57
+ if (!existsSync(TOKENS_PATH)) {
58
+ cached = { tokens: {} }
59
+ return cached
60
+ }
61
+ try {
62
+ const raw = await readFile(TOKENS_PATH, "utf8")
63
+ const parsed = JSON.parse(raw) as TokensFile
64
+ if (!parsed.tokens || typeof parsed.tokens !== "object") {
65
+ cached = { tokens: {} }
66
+ } else {
67
+ cached = parsed
68
+ }
69
+ return cached
70
+ } catch {
71
+ cached = { tokens: {} }
72
+ return cached
73
+ }
74
+ }
75
+
76
+ async function writeTokensFile(data: TokensFile): Promise<void> {
77
+ await mkdir(dirname(TOKENS_PATH), { recursive: true })
78
+ await writeFile(TOKENS_PATH, JSON.stringify(data, null, 2) + "\n")
79
+ cached = data
80
+ }
81
+
82
+ export type ApiTokenView = {
83
+ tokenId: string
84
+ label: string
85
+ createdAt: string
86
+ lastUsedAt?: string
87
+ }
88
+
89
+ /** Resolve `Authorization: Bearer la_...` to a userId, or null. */
90
+ export async function resolveApiToken(authHeader: string | null): Promise<string | null> {
91
+ if (!authHeader || !authHeader.startsWith("Bearer ")) return null
92
+ const bearerToken = authHeader.slice("Bearer ".length).trim()
93
+ if (!bearerToken || !bearerToken.startsWith("la_")) return null
94
+ const file = await readTokensFile()
95
+ const entry = file.tokens[hashToken(bearerToken)]
96
+ if (!entry) return null
97
+ // Best-effort lastUsedAt update; don't block resolution on it.
98
+ withWriteLock(async () => {
99
+ const f = await readTokensFile()
100
+ const h = hashToken(bearerToken)
101
+ if (f.tokens[h]) {
102
+ f.tokens[h].lastUsedAt = new Date().toISOString()
103
+ await writeTokensFile(f)
104
+ }
105
+ }).catch(() => {})
106
+ return entry.userId
107
+ }
108
+
109
+ export async function createApiToken(userId: string, label: string): Promise<{
110
+ tokenId: string
111
+ token: string
112
+ label: string
113
+ createdAt: string
114
+ }> {
115
+ return withWriteLock(async () => {
116
+ const file = await readTokensFile()
117
+ const token = generateTokenValue()
118
+ const tokenId = generateTokenId()
119
+ const stored: StoredToken = {
120
+ tokenId,
121
+ userId,
122
+ label: (label || "").trim() || "default",
123
+ createdAt: new Date().toISOString(),
124
+ }
125
+ file.tokens[hashToken(token)] = stored
126
+ await writeTokensFile(file)
127
+ return { tokenId, token, label: stored.label, createdAt: stored.createdAt }
128
+ })
129
+ }
130
+
131
+ export async function listApiTokens(userId: string): Promise<ApiTokenView[]> {
132
+ const file = await readTokensFile()
133
+ return Object.values(file.tokens)
134
+ .filter((t) => t.userId === userId)
135
+ .map((t) => ({
136
+ tokenId: t.tokenId,
137
+ label: t.label,
138
+ createdAt: t.createdAt,
139
+ lastUsedAt: t.lastUsedAt,
140
+ }))
141
+ .sort((a, b) => a.createdAt < b.createdAt ? 1 : -1)
142
+ }
143
+
144
+ export async function revokeApiToken(userId: string, tokenId: string): Promise<boolean> {
145
+ return withWriteLock(async () => {
146
+ const file = await readTokensFile()
147
+ const hash = Object.keys(file.tokens).find((h) => {
148
+ const t = file.tokens[h]
149
+ return t.userId === userId && t.tokenId === tokenId
150
+ })
151
+ if (!hash) return false
152
+ delete file.tokens[hash]
153
+ await writeTokensFile(file)
154
+ return true
155
+ })
156
+ }
157
+
158
+ /** For tests only — drops the in-memory cache. */
159
+ export function _resetCache(): void {
160
+ cached = null
161
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * OpenAPI 3.1 schema for the v1 Loop API.
3
+ *
4
+ * Source of truth is `docs/api-v1.md`. This file mirrors that contract in
5
+ * a machine-readable form so the interactive docs (Scalar) can render it.
6
+ *
7
+ * Keep these two in sync when changing the API — spec doc first, then this.
8
+ */
9
+
10
+ export const v1OpenApiSpec = {
11
+ openapi: "3.1.0",
12
+ info: {
13
+ title: "Loopat Loop API",
14
+ version: "1.0.0",
15
+ description: [
16
+ "External API for creating and driving loops. The same surface powers ",
17
+ "the loopat web chat UI — bot frameworks and the web app speak the same ",
18
+ "protocol.",
19
+ "",
20
+ "**Auth**: pass `Authorization: Bearer la_<token>` for external programs, ",
21
+ "or a `loopat_session` cookie for same-origin web requests.",
22
+ "",
23
+ "**Scope**: v1 only covers chat conversation. Operator features (queue, ",
24
+ "goal, provider, archive admin flags) stay on internal endpoints; see the ",
25
+ "full spec at `docs/api-v1.md`.",
26
+ ].join("\n"),
27
+ },
28
+ servers: [{ url: "/api/v1", description: "current host, v1 prefix" }],
29
+ components: {
30
+ securitySchemes: {
31
+ BearerAuth: {
32
+ type: "http",
33
+ scheme: "bearer",
34
+ bearerFormat: "la_<48 hex>",
35
+ description: "External programs. Token from Settings → API Tokens.",
36
+ },
37
+ CookieAuth: {
38
+ type: "apiKey",
39
+ in: "cookie",
40
+ name: "loopat_session",
41
+ description: "Web same-origin requests; set by /api/auth/login.",
42
+ },
43
+ },
44
+ schemas: {
45
+ Loop: {
46
+ type: "object",
47
+ required: ["id", "title", "created_at", "created_by", "archived", "metadata", "profiles", "vault"],
48
+ properties: {
49
+ id: { type: "string", example: "loop_3a91ce5e-9c2f-4f0a-bcc9-7c7e..." },
50
+ title: { type: "string", maxLength: 200 },
51
+ created_at: { type: "string", format: "date-time" },
52
+ created_by: { type: "string", example: "alice" },
53
+ archived: { type: "boolean" },
54
+ archived_at: { type: "string", format: "date-time", nullable: true },
55
+ metadata: {
56
+ type: "object",
57
+ additionalProperties: { type: "string" },
58
+ description: "Caller-supplied k/v, ≤16 KB JSON-stringified. Not visible to the agent.",
59
+ },
60
+ profiles: { type: "array", items: { type: "string" } },
61
+ vault: { type: "string", example: "default" },
62
+ repo: { type: "string", nullable: true, example: "myproject" },
63
+ busy: { type: "boolean", description: "Only on GET /loops/{id}" },
64
+ queue_depth: { type: "integer", description: "Only on GET /loops/{id}" },
65
+ turn_count: { type: "integer", description: "Only on GET /loops/{id}" },
66
+ current_turn: {
67
+ type: "object",
68
+ nullable: true,
69
+ description: "Present iff busy=true",
70
+ properties: {
71
+ turn_id: { type: "string", nullable: true },
72
+ started_at: { type: "string", format: "date-time", nullable: true },
73
+ pending_choice_id: { type: "string", nullable: true },
74
+ },
75
+ },
76
+ },
77
+ },
78
+ LoopList: {
79
+ type: "object",
80
+ required: ["data", "has_more"],
81
+ properties: {
82
+ data: { type: "array", items: { $ref: "#/components/schemas/Loop" } },
83
+ first_id: { type: "string", nullable: true },
84
+ last_id: { type: "string", nullable: true },
85
+ has_more: { type: "boolean" },
86
+ },
87
+ },
88
+ ApiTokenView: {
89
+ type: "object",
90
+ required: ["tokenId", "label", "createdAt"],
91
+ properties: {
92
+ tokenId: { type: "string", example: "tok_a1b2c3d4e5f6" },
93
+ label: { type: "string" },
94
+ createdAt: { type: "string", format: "date-time" },
95
+ lastUsedAt: { type: "string", format: "date-time" },
96
+ },
97
+ },
98
+ ApiTokenCreated: {
99
+ type: "object",
100
+ required: ["tokenId", "token", "label", "createdAt"],
101
+ properties: {
102
+ tokenId: { type: "string" },
103
+ token: {
104
+ type: "string",
105
+ description: "Bearer token plaintext — only returned once at creation.",
106
+ example: "la_a1b2c3...",
107
+ },
108
+ label: { type: "string" },
109
+ createdAt: { type: "string", format: "date-time" },
110
+ },
111
+ },
112
+ Error: {
113
+ type: "object",
114
+ required: ["error"],
115
+ properties: {
116
+ error: {
117
+ type: "object",
118
+ required: ["type", "code", "message"],
119
+ properties: {
120
+ type: {
121
+ type: "string",
122
+ enum: [
123
+ "authentication_error",
124
+ "permission_error",
125
+ "invalid_request_error",
126
+ "not_found_error",
127
+ "conflict_error",
128
+ "rate_limit_error",
129
+ "internal_error",
130
+ ],
131
+ },
132
+ code: { type: "string", example: "loop_not_found" },
133
+ message: { type: "string" },
134
+ },
135
+ },
136
+ },
137
+ },
138
+ },
139
+ },
140
+ security: [{ BearerAuth: [] }, { CookieAuth: [] }],
141
+ paths: {
142
+ "/me/tokens": {
143
+ post: {
144
+ summary: "Create an API token",
145
+ description: "Cookie-only (web Settings UI). Bots cannot self-issue tokens.",
146
+ security: [{ CookieAuth: [] }],
147
+ requestBody: {
148
+ required: false,
149
+ content: {
150
+ "application/json": {
151
+ schema: {
152
+ type: "object",
153
+ properties: { label: { type: "string", default: "default" } },
154
+ },
155
+ },
156
+ },
157
+ },
158
+ responses: {
159
+ "201": {
160
+ description: "Token created. Plaintext returned once.",
161
+ content: {
162
+ "application/json": { schema: { $ref: "#/components/schemas/ApiTokenCreated" } },
163
+ },
164
+ },
165
+ "401": { description: "Session required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
166
+ },
167
+ },
168
+ get: {
169
+ summary: "List my API tokens",
170
+ security: [{ CookieAuth: [] }],
171
+ responses: {
172
+ "200": {
173
+ description: "Token list (plaintext omitted)",
174
+ content: {
175
+ "application/json": {
176
+ schema: {
177
+ type: "object",
178
+ properties: { tokens: { type: "array", items: { $ref: "#/components/schemas/ApiTokenView" } } },
179
+ },
180
+ },
181
+ },
182
+ },
183
+ },
184
+ },
185
+ },
186
+ "/me/tokens/{tokenId}": {
187
+ delete: {
188
+ summary: "Revoke an API token",
189
+ security: [{ CookieAuth: [] }],
190
+ parameters: [
191
+ { name: "tokenId", in: "path", required: true, schema: { type: "string" }, example: "tok_a1b2c3..." },
192
+ ],
193
+ responses: {
194
+ "204": { description: "Revoked" },
195
+ "404": { description: "Not found" },
196
+ },
197
+ },
198
+ },
199
+ "/loops": {
200
+ post: {
201
+ summary: "Create a loop",
202
+ requestBody: {
203
+ required: false,
204
+ content: {
205
+ "application/json": {
206
+ schema: {
207
+ type: "object",
208
+ properties: {
209
+ title: { type: "string", default: "untitled", maxLength: 200 },
210
+ metadata: { type: "object", additionalProperties: { type: "string" } },
211
+ profiles: { type: "array", items: { type: "string" } },
212
+ vault: { type: "string", default: "default" },
213
+ repo: { type: "string" },
214
+ },
215
+ },
216
+ },
217
+ },
218
+ },
219
+ responses: {
220
+ "201": { description: "Loop created", content: { "application/json": { schema: { $ref: "#/components/schemas/Loop" } } } },
221
+ "400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
222
+ },
223
+ },
224
+ get: {
225
+ summary: "List my loops",
226
+ parameters: [
227
+ { name: "limit", in: "query", schema: { type: "integer", default: 20, maximum: 100 } },
228
+ { name: "after", in: "query", schema: { type: "string" }, description: "Cursor: returns loops older than this id" },
229
+ { name: "before", in: "query", schema: { type: "string" }, description: "Cursor: returns loops newer than this id" },
230
+ { name: "archived", in: "query", schema: { type: "boolean", default: false } },
231
+ ],
232
+ responses: {
233
+ "200": { description: "Loop list", content: { "application/json": { schema: { $ref: "#/components/schemas/LoopList" } } } },
234
+ },
235
+ },
236
+ },
237
+ "/loops/{id}": {
238
+ get: {
239
+ summary: "Get a loop",
240
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
241
+ responses: {
242
+ "200": { description: "Loop with runtime state", content: { "application/json": { schema: { $ref: "#/components/schemas/Loop" } } } },
243
+ "403": { description: "Not loop owner", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
244
+ "404": { description: "Not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
245
+ },
246
+ },
247
+ delete: {
248
+ summary: "Archive a loop",
249
+ description: "Soft-delete. Sandbox is killed, meta retained. Unarchive/hard-delete are web-only.",
250
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
251
+ responses: {
252
+ "204": { description: "Archived" },
253
+ "403": { description: "Not loop owner" },
254
+ "404": { description: "Not found" },
255
+ },
256
+ },
257
+ },
258
+ "/loops/{id}/messages": {
259
+ post: {
260
+ summary: "Send a message and stream the turn (SSE)",
261
+ description: [
262
+ "Returns `text/event-stream`. See the SSE event vocabulary in `docs/api-v1.md`.",
263
+ "Closing the connection does **not** cancel the turn. Reconnect using the same",
264
+ "`Idempotency-Key` to replay buffered events and attach to the live stream.",
265
+ ].join(" "),
266
+ parameters: [
267
+ { name: "id", in: "path", required: true, schema: { type: "string" } },
268
+ { name: "Idempotency-Key", in: "header", schema: { type: "string", maxLength: 256 } },
269
+ ],
270
+ requestBody: {
271
+ required: true,
272
+ content: {
273
+ "application/json": {
274
+ schema: {
275
+ type: "object",
276
+ required: ["content"],
277
+ properties: {
278
+ content: { type: "string", maxLength: 1048576 },
279
+ permission_mode: {
280
+ type: "string",
281
+ enum: ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"],
282
+ description: "Override the loop's current permission mode for this turn.",
283
+ },
284
+ },
285
+ },
286
+ },
287
+ },
288
+ },
289
+ responses: {
290
+ "200": {
291
+ description: "SSE stream. Event vocabulary: queued, started, assistant_delta, thinking_delta, tool_call, tool_result, requires_choice, choice_resolved, done, interrupted, error, ping, sdk_message (web-internal).",
292
+ content: { "text/event-stream": { schema: { type: "string" } } },
293
+ },
294
+ "400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
295
+ "409": { description: "Idempotency-Key reused with different body", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
296
+ },
297
+ },
298
+ },
299
+ "/loops/{id}/events": {
300
+ get: {
301
+ summary: "Watch a loop's events (read-only SSE)",
302
+ description: "Attach to live events without sending a message. Useful for reconnect and passive observation.",
303
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
304
+ responses: {
305
+ "200": {
306
+ description: "SSE stream. Same vocabulary as POST /messages, prefixed by `event: snapshot` if a turn is already running.",
307
+ content: { "text/event-stream": { schema: { type: "string" } } },
308
+ },
309
+ },
310
+ },
311
+ },
312
+ "/loops/{id}/choices/{choiceId}": {
313
+ post: {
314
+ summary: "Answer a choice (permission or question)",
315
+ description: "Unblocks an agent that emitted `requires_choice`. Body shape depends on the choice kind.",
316
+ parameters: [
317
+ { name: "id", in: "path", required: true, schema: { type: "string" } },
318
+ { name: "choiceId", in: "path", required: true, schema: { type: "string" } },
319
+ ],
320
+ requestBody: {
321
+ required: true,
322
+ content: {
323
+ "application/json": {
324
+ schema: {
325
+ oneOf: [
326
+ {
327
+ title: "Permission",
328
+ type: "object",
329
+ required: ["allow"],
330
+ properties: { allow: { type: "boolean" } },
331
+ },
332
+ {
333
+ title: "Question",
334
+ type: "object",
335
+ required: ["answers"],
336
+ properties: {
337
+ answers: { type: "object", additionalProperties: { type: "string" } },
338
+ },
339
+ },
340
+ ],
341
+ },
342
+ },
343
+ },
344
+ },
345
+ responses: {
346
+ "202": { description: "Choice resolved" },
347
+ "400": { description: "Invalid body shape" },
348
+ "404": { description: "Choice not pending or already answered" },
349
+ },
350
+ },
351
+ },
352
+ "/loops/{id}/interrupt": {
353
+ post: {
354
+ summary: "Cancel the current turn",
355
+ description: "Open SSE streams receive `event: interrupted` and close.",
356
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
357
+ responses: {
358
+ "202": { description: "Interrupted" },
359
+ },
360
+ },
361
+ },
362
+ },
363
+ } as const