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,390 @@
1
+ /**
2
+ * Chat module: SQLite-backed channels + 1:1 DMs.
3
+ *
4
+ * Storage of record is chat.db (bun:sqlite) at LOOPAT_HOME/chat.db.
5
+ * AI never reads the DB; when a loop is spawned from a chat conv, the
6
+ * relevant messages are dumped to a per-loop jsonl snapshot at
7
+ * loops/<id>/context/chat/<convId>.jsonl (last 1024 messages).
8
+ *
9
+ * Permissions (v0):
10
+ * - any auth user reads any channel and posts to any channel
11
+ * - any auth user creates channels; only admin deletes
12
+ * - DMs are strictly 1:1, visible only to the two parties
13
+ */
14
+ import { Database } from "bun:sqlite"
15
+ import { randomUUID } from "node:crypto"
16
+ import { mkdir, writeFile } from "node:fs/promises"
17
+ import { dirname } from "node:path"
18
+ import { chatDbPath } from "./paths"
19
+
20
+ export type ConvKind = "channel" | "dm"
21
+
22
+ export type Conversation = {
23
+ id: string
24
+ kind: ConvKind
25
+ name: string | null
26
+ topic: string | null
27
+ createdBy: string
28
+ createdAt: number
29
+ dmUserA: string | null
30
+ dmUserB: string | null
31
+ }
32
+
33
+ export type Message = {
34
+ id: number
35
+ convId: string
36
+ author: string
37
+ text: string
38
+ ts: number
39
+ /** NULL = thread root (a top-level message); otherwise the root msg id this
40
+ * reply belongs to. Slack-style single-level threading — replies cannot be
41
+ * replied to (we reject parent_id on a message that already has one). */
42
+ parentId: number | null
43
+ }
44
+
45
+ /** Thread root surfaced in the main feed. Carries denormalized reply stats
46
+ * so the UI can render "💬 N replies" without a per-row roundtrip. */
47
+ export type ThreadRoot = Message & {
48
+ replyCount: number
49
+ lastReplyTs: number | null
50
+ }
51
+
52
+ export type ConversationWithUnread = Conversation & {
53
+ unread: number
54
+ lastMessageTs: number | null
55
+ /** For DMs, the "display name" is the other party. */
56
+ peerUserId: string | null
57
+ }
58
+
59
+ let _db: Database | null = null
60
+
61
+ function db(): Database {
62
+ if (_db) return _db
63
+ const path = chatDbPath()
64
+ // mkdir parent in case LOOPAT_HOME doesn't exist yet (bootstrap order).
65
+ // ensureWorkspaceDirs runs before chat is used, but be defensive.
66
+ const d = new Database(path, { create: true })
67
+ d.exec(`
68
+ PRAGMA journal_mode = WAL;
69
+ PRAGMA synchronous = NORMAL;
70
+ PRAGMA foreign_keys = ON;
71
+
72
+ CREATE TABLE IF NOT EXISTS conversations (
73
+ id TEXT PRIMARY KEY,
74
+ kind TEXT NOT NULL CHECK (kind IN ('channel','dm')),
75
+ name TEXT,
76
+ topic TEXT,
77
+ created_by TEXT NOT NULL,
78
+ created_at INTEGER NOT NULL,
79
+ deleted_at INTEGER,
80
+ dm_user_a TEXT,
81
+ dm_user_b TEXT
82
+ );
83
+
84
+ CREATE UNIQUE INDEX IF NOT EXISTS conv_channel_name
85
+ ON conversations(name) WHERE kind = 'channel' AND deleted_at IS NULL;
86
+ CREATE UNIQUE INDEX IF NOT EXISTS conv_dm_pair
87
+ ON conversations(dm_user_a, dm_user_b) WHERE kind = 'dm';
88
+
89
+ CREATE TABLE IF NOT EXISTS messages (
90
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
91
+ conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
92
+ author TEXT NOT NULL,
93
+ text TEXT NOT NULL,
94
+ ts INTEGER NOT NULL,
95
+ parent_id INTEGER REFERENCES messages(id) ON DELETE CASCADE
96
+ );
97
+ CREATE INDEX IF NOT EXISTS msg_conv_id_idx ON messages(conv_id, id);
98
+
99
+ CREATE TABLE IF NOT EXISTS reads (
100
+ user_id TEXT NOT NULL,
101
+ conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
102
+ last_read_id INTEGER NOT NULL,
103
+ PRIMARY KEY (user_id, conv_id)
104
+ );
105
+ `)
106
+ // Migrate DBs created before parent_id existed. SQLite has no
107
+ // IF NOT EXISTS on ADD COLUMN, so swallow the duplicate-column error.
108
+ // MUST run before the partial index below — `WHERE parent_id IS NOT NULL`
109
+ // fails to parse if the column isn't there yet.
110
+ try {
111
+ d.exec(`ALTER TABLE messages ADD COLUMN parent_id INTEGER REFERENCES messages(id) ON DELETE CASCADE`)
112
+ } catch (e: any) {
113
+ if (!/duplicate column/i.test(e?.message ?? "")) throw e
114
+ }
115
+ d.exec(`CREATE INDEX IF NOT EXISTS msg_parent_idx ON messages(parent_id, id) WHERE parent_id IS NOT NULL`)
116
+ _db = d
117
+ return d
118
+ }
119
+
120
+ function rowToConv(r: any): Conversation {
121
+ return {
122
+ id: r.id,
123
+ kind: r.kind,
124
+ name: r.name,
125
+ topic: r.topic,
126
+ createdBy: r.created_by,
127
+ createdAt: r.created_at,
128
+ dmUserA: r.dm_user_a,
129
+ dmUserB: r.dm_user_b,
130
+ }
131
+ }
132
+
133
+ function rowToMessage(r: any): Message {
134
+ return {
135
+ id: r.id,
136
+ convId: r.conv_id,
137
+ author: r.author,
138
+ text: r.text,
139
+ ts: r.ts,
140
+ parentId: r.parent_id ?? null,
141
+ }
142
+ }
143
+
144
+ function isValidChannelName(name: string): boolean {
145
+ // lowercase letters, digits, dash, underscore. 1–32 chars. Slack-like.
146
+ return /^[a-z0-9][a-z0-9_-]{0,31}$/.test(name)
147
+ }
148
+
149
+ // ── channels ──────────────────────────────────────────────────────────────
150
+
151
+ export function listChannels(): Conversation[] {
152
+ const rows = db()
153
+ .query<any, []>(
154
+ `SELECT * FROM conversations
155
+ WHERE kind = 'channel' AND deleted_at IS NULL
156
+ ORDER BY name ASC`,
157
+ )
158
+ .all()
159
+ return rows.map(rowToConv)
160
+ }
161
+
162
+ export function createChannel(opts: { name: string; topic?: string; createdBy: string }): { ok: true; conv: Conversation } | { ok: false; error: string } {
163
+ const name = opts.name.trim().toLowerCase().replace(/^#/, "")
164
+ if (!isValidChannelName(name)) {
165
+ return { ok: false, error: "channel name must be lowercase letters/digits/-/_, 1-32 chars, start with letter/digit" }
166
+ }
167
+ const existing = db().query<any, [string]>(
168
+ `SELECT * FROM conversations WHERE kind = 'channel' AND name = ? AND deleted_at IS NULL`,
169
+ ).get(name)
170
+ if (existing) return { ok: false, error: "channel exists" }
171
+ const id = `c-${randomUUID()}`
172
+ const now = Date.now()
173
+ db().run(
174
+ `INSERT INTO conversations (id, kind, name, topic, created_by, created_at)
175
+ VALUES (?, 'channel', ?, ?, ?, ?)`,
176
+ [id, name, opts.topic?.trim() || null, opts.createdBy, now],
177
+ )
178
+ return { ok: true, conv: rowToConv(db().query<any, [string]>(`SELECT * FROM conversations WHERE id = ?`).get(id)) }
179
+ }
180
+
181
+ export function deleteChannel(id: string): boolean {
182
+ const r = db().run(
183
+ `UPDATE conversations SET deleted_at = ? WHERE id = ? AND kind = 'channel' AND deleted_at IS NULL`,
184
+ [Date.now(), id],
185
+ )
186
+ return r.changes > 0
187
+ }
188
+
189
+ // ── DMs ───────────────────────────────────────────────────────────────────
190
+
191
+ /** Normalize the (a, b) tuple so DB lookup is canonical regardless of caller order. */
192
+ function normalizeDmPair(a: string, b: string): [string, string] {
193
+ return a < b ? [a, b] : [b, a]
194
+ }
195
+
196
+ export function getOrCreateDm(userA: string, userB: string, createdBy: string): Conversation {
197
+ if (userA === userB) throw new Error("cannot DM yourself")
198
+ const [a, b] = normalizeDmPair(userA, userB)
199
+ const existing = db()
200
+ .query<any, [string, string]>(
201
+ `SELECT * FROM conversations WHERE kind = 'dm' AND dm_user_a = ? AND dm_user_b = ?`,
202
+ )
203
+ .get(a, b)
204
+ if (existing) return rowToConv(existing)
205
+ const id = `d-${randomUUID()}`
206
+ const now = Date.now()
207
+ db().run(
208
+ `INSERT INTO conversations (id, kind, created_by, created_at, dm_user_a, dm_user_b)
209
+ VALUES (?, 'dm', ?, ?, ?, ?)`,
210
+ [id, createdBy, now, a, b],
211
+ )
212
+ return rowToConv(db().query<any, [string]>(`SELECT * FROM conversations WHERE id = ?`).get(id))
213
+ }
214
+
215
+ export function getConv(id: string): Conversation | null {
216
+ const r = db().query<any, [string]>(`SELECT * FROM conversations WHERE id = ?`).get(id)
217
+ return r ? rowToConv(r) : null
218
+ }
219
+
220
+ /** Permission check: can `userId` read/write this conversation? */
221
+ export function userCanAccess(conv: Conversation, userId: string): boolean {
222
+ if (conv.kind === "channel") return conv.createdAt > 0 // any auth user
223
+ return conv.dmUserA === userId || conv.dmUserB === userId
224
+ }
225
+
226
+ /** All conversations visible to `userId`: every channel + every DM they're in. */
227
+ export function listConversationsForUser(userId: string): ConversationWithUnread[] {
228
+ const rows = db()
229
+ .query<any, [string, string, string]>(
230
+ `SELECT c.*,
231
+ (SELECT MAX(ts) FROM messages WHERE conv_id = c.id) AS last_ts,
232
+ (SELECT MAX(id) FROM messages WHERE conv_id = c.id) AS last_msg_id,
233
+ COALESCE((SELECT last_read_id FROM reads WHERE user_id = ? AND conv_id = c.id), 0) AS last_read_id
234
+ FROM conversations c
235
+ WHERE c.deleted_at IS NULL
236
+ AND (
237
+ c.kind = 'channel'
238
+ OR c.dm_user_a = ?
239
+ OR c.dm_user_b = ?
240
+ )`,
241
+ )
242
+ .all(userId, userId, userId)
243
+ return rows.map((r) => {
244
+ const conv = rowToConv(r)
245
+ const lastId = r.last_msg_id ?? 0
246
+ const lastRead = r.last_read_id ?? 0
247
+ const unread = lastId > lastRead
248
+ ? (db().query<{ n: number }, [string, number]>(
249
+ `SELECT COUNT(*) AS n FROM messages WHERE conv_id = ? AND id > ?`,
250
+ ).get(conv.id, lastRead)?.n ?? 0)
251
+ : 0
252
+ let peer: string | null = null
253
+ if (conv.kind === "dm") {
254
+ peer = conv.dmUserA === userId ? conv.dmUserB : conv.dmUserA
255
+ }
256
+ return {
257
+ ...conv,
258
+ unread,
259
+ lastMessageTs: r.last_ts ?? null,
260
+ peerUserId: peer,
261
+ }
262
+ })
263
+ }
264
+
265
+ // ── messages ──────────────────────────────────────────────────────────────
266
+
267
+ /** Main-feed listing: thread roots only. Each row carries denormalized
268
+ * reply_count + last_reply_ts via subqueries so the UI can render the
269
+ * "💬 N replies" affordance without a second roundtrip. */
270
+ export function listMessages(convId: string, opts: { before?: number; limit?: number } = {}): ThreadRoot[] {
271
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500)
272
+ const before = opts.before
273
+ const select = `
274
+ SELECT m.*,
275
+ (SELECT COUNT(*) FROM messages r WHERE r.parent_id = m.id) AS reply_count,
276
+ (SELECT MAX(ts) FROM messages r WHERE r.parent_id = m.id) AS last_reply_ts
277
+ FROM messages m
278
+ WHERE m.conv_id = ? AND m.parent_id IS NULL`
279
+ let rows: any[]
280
+ if (before && before > 0) {
281
+ rows = db()
282
+ .query<any, [string, number, number]>(`${select} AND m.id < ? ORDER BY m.id DESC LIMIT ?`)
283
+ .all(convId, before, limit)
284
+ } else {
285
+ rows = db()
286
+ .query<any, [string, number]>(`${select} ORDER BY m.id DESC LIMIT ?`)
287
+ .all(convId, limit)
288
+ }
289
+ // Return chronological (oldest → newest) — convenient for UI append.
290
+ return rows.reverse().map((r) => ({
291
+ ...rowToMessage(r),
292
+ replyCount: r.reply_count ?? 0,
293
+ lastReplyTs: r.last_reply_ts ?? null,
294
+ }))
295
+ }
296
+
297
+ /** Return a thread (root + all replies, chronological). null if the root id
298
+ * doesn't exist or is itself a reply (we don't surface "half threads"). */
299
+ export function listThread(rootId: number): { root: Message; replies: Message[] } | null {
300
+ const rootRow = db().query<any, [number]>(`SELECT * FROM messages WHERE id = ?`).get(rootId)
301
+ if (!rootRow || rootRow.parent_id != null) return null
302
+ const replyRows = db()
303
+ .query<any, [number]>(`SELECT * FROM messages WHERE parent_id = ? ORDER BY id ASC`)
304
+ .all(rootId)
305
+ return {
306
+ root: rowToMessage(rootRow),
307
+ replies: replyRows.map(rowToMessage),
308
+ }
309
+ }
310
+
311
+ /** Post a message. If parentId is set, it must reference a top-level message
312
+ * in the same conv (no nested threads, no cross-conv replies). Returns the
313
+ * new message. */
314
+ export function postMessage(convId: string, author: string, text: string, parentId: number | null = null): Message {
315
+ const trimmed = text.replace(/\r\n/g, "\n")
316
+ if (!trimmed.trim()) throw new Error("empty message")
317
+ if (parentId != null) {
318
+ const parent = db().query<any, [number]>(`SELECT conv_id, parent_id FROM messages WHERE id = ?`).get(parentId)
319
+ if (!parent) throw new Error("parent message not found")
320
+ if (parent.conv_id !== convId) throw new Error("parent message belongs to another conversation")
321
+ if (parent.parent_id != null) throw new Error("cannot reply to a reply (threads are single-level)")
322
+ }
323
+ const ts = Date.now()
324
+ const r = db().run(
325
+ `INSERT INTO messages (conv_id, author, text, ts, parent_id) VALUES (?, ?, ?, ?, ?)`,
326
+ [convId, author, trimmed, ts, parentId],
327
+ )
328
+ return {
329
+ id: Number(r.lastInsertRowid),
330
+ convId,
331
+ author,
332
+ text: trimmed,
333
+ ts,
334
+ parentId,
335
+ }
336
+ }
337
+
338
+ export function markRead(userId: string, convId: string, lastReadId: number): void {
339
+ db().run(
340
+ `INSERT INTO reads (user_id, conv_id, last_read_id) VALUES (?, ?, ?)
341
+ ON CONFLICT(user_id, conv_id) DO UPDATE SET last_read_id = MAX(last_read_id, excluded.last_read_id)`,
342
+ [userId, convId, lastReadId],
343
+ )
344
+ }
345
+
346
+ // ── jsonl snapshot (for spawn-loop-from-thread) ───────────────────────────
347
+
348
+ /**
349
+ * Dump a thread (root + all replies) to a jsonl file, chronological order:
350
+ * {"ts":"2026-05-16T10:42:00.000Z","author":"simpx","text":"..."}
351
+ *
352
+ * A "thread" is the natural semantic unit for AI seeding — every top-level
353
+ * message is a thread of length ≥ 1, so this works whether or not anyone
354
+ * actually replied. Returns null if the root doesn't exist or is itself a
355
+ * reply.
356
+ */
357
+ export async function snapshotThreadToJsonl(
358
+ rootId: number,
359
+ destPath: string,
360
+ ): Promise<{ messageCount: number; convId: string } | null> {
361
+ const t = listThread(rootId)
362
+ if (!t) return null
363
+ const all = [t.root, ...t.replies]
364
+ const lines = all.map((m) =>
365
+ JSON.stringify({
366
+ ts: new Date(m.ts).toISOString(),
367
+ author: m.author,
368
+ text: m.text,
369
+ }),
370
+ )
371
+ await mkdir(dirname(destPath), { recursive: true })
372
+ await writeFile(destPath, lines.join("\n") + (lines.length ? "\n" : ""))
373
+ return { messageCount: all.length, convId: t.root.convId }
374
+ }
375
+
376
+ // ── bootstrap ─────────────────────────────────────────────────────────────
377
+
378
+ /** Run once on server startup. Opens the DB (creating schema) and seeds a
379
+ * default `#general` channel if no channels exist. */
380
+ export function initChat(bootstrapUser: string): void {
381
+ db() // open + migrate
382
+ const count = db()
383
+ .query<{ n: number }, []>(
384
+ `SELECT COUNT(*) AS n FROM conversations WHERE kind = 'channel' AND deleted_at IS NULL`,
385
+ )
386
+ .get()?.n ?? 0
387
+ if (count === 0 && bootstrapUser) {
388
+ createChannel({ name: "general", topic: "workspace-wide chatter", createdBy: bootstrapUser })
389
+ }
390
+ }
@@ -0,0 +1,68 @@
1
+ import { existsSync } from "node:fs"
2
+ import { execSync } from "node:child_process"
3
+ import { fileURLToPath } from "node:url"
4
+ import { dirname, resolve, join } from "node:path"
5
+
6
+ function detectIsMusl(): boolean {
7
+ if (process.platform !== "linux") return false
8
+ try {
9
+ const lddOut = execSync("ldd --version 2>&1", { encoding: "utf8" }) as string
10
+ return /musl/i.test(lddOut)
11
+ } catch {}
12
+ return false
13
+ }
14
+
15
+ function findWorkspaceRoot(start: string): string[] {
16
+ const roots: string[] = []
17
+ let cur = start
18
+ for (let i = 0; i < 10; i++) {
19
+ if (existsSync(join(cur, "node_modules"))) roots.push(cur)
20
+ const parent = dirname(cur)
21
+ if (parent === cur) break
22
+ cur = parent
23
+ }
24
+ if (roots.length === 0) throw new Error("could not locate node_modules from " + start)
25
+ return roots
26
+ }
27
+
28
+ export function resolveClaudeBinary(): string {
29
+ const platform = process.platform
30
+ const arch = process.arch
31
+ const ext = platform === "win32" ? ".exe" : ""
32
+
33
+ const pkgs: string[] = []
34
+ if (platform === "linux") {
35
+ if (detectIsMusl()) {
36
+ pkgs.push(`claude-agent-sdk-linux-${arch}-musl`, `claude-agent-sdk-linux-${arch}`)
37
+ } else {
38
+ pkgs.push(`claude-agent-sdk-linux-${arch}`, `claude-agent-sdk-linux-${arch}-musl`)
39
+ }
40
+ } else {
41
+ pkgs.push(`claude-agent-sdk-${platform}-${arch}`)
42
+ }
43
+
44
+ const here = fileURLToPath(import.meta.url)
45
+ const roots = findWorkspaceRoot(dirname(here))
46
+ const candidates: string[] = []
47
+ for (const root of roots) {
48
+ for (const pkg of pkgs) {
49
+ candidates.push(join(root, "node_modules", "@anthropic-ai", pkg, `claude${ext}`))
50
+ const bunDir = join(root, "node_modules", ".bun")
51
+ if (existsSync(bunDir)) {
52
+ try {
53
+ const entries = execSync(`ls "${bunDir}"`, { encoding: "utf8" }).split("\n").filter(Boolean)
54
+ for (const entry of entries) {
55
+ if (entry.startsWith(`@anthropic-ai+${pkg}@`)) {
56
+ candidates.push(join(bunDir, entry, "node_modules", "@anthropic-ai", pkg, `claude${ext}`))
57
+ }
58
+ }
59
+ } catch {}
60
+ }
61
+ }
62
+ }
63
+
64
+ for (const c of candidates) {
65
+ if (existsSync(c)) return c
66
+ }
67
+ throw new Error(`claude binary not found; tried:\n${candidates.join("\n")}`)
68
+ }