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,90 @@
1
+ /**
2
+ * System prompt composition. Layers:
3
+ * L1 (preset) Claude Code preset — built-in
4
+ * L2 (doctrine) bundled platform doctrine (server/templates/CLAUDE.md):
5
+ * sandbox layout, virtual paths, memory model. Always loaded.
6
+ * Injected via `systemPrompt.append`.
7
+ * L2+ (workspace) optional workspace supplement at knowledge/.loopat/claude/CLAUDE.md.
8
+ * Bound into CLAUDE_CONFIG_DIR/CLAUDE.md and auto-loaded by
9
+ * Claude Code as user-tier (settingSources: ["user", ...]).
10
+ * See bwrap.ts for the bind.
11
+ * L2++ (project) optional <workdir>/CLAUDE.md auto-loaded by Claude Code
12
+ * itself (enabled via `settingSources: [..., "project"]`).
13
+ * L3 (runtime) per-loop dynamic info (title/id/branch/repo).
14
+ * Injected via `systemPrompt.append`.
15
+ *
16
+ * Doctrine uses **virtual paths** (/loopat/loop/<id>/, /loopat/context/*) since
17
+ * the loop runs inside the outer bwrap sandbox and that's what Claude sees.
18
+ */
19
+ import { readFile } from "node:fs/promises"
20
+ import { execFile } from "node:child_process"
21
+ import { promisify } from "node:util"
22
+ import { effectiveDriver, type LoopMeta } from "./loops"
23
+ import { bundledDoctrinePath, workspaceNotesDir, workspaceKnowledgeDir } from "./paths"
24
+
25
+ const execFileP = promisify(execFile)
26
+
27
+ let cachedBundled: string | null = null
28
+
29
+ async function loadBundled(): Promise<string> {
30
+ if (cachedBundled !== null) return cachedBundled
31
+ cachedBundled = await readFile(bundledDoctrinePath(), "utf8")
32
+ return cachedBundled
33
+ }
34
+
35
+ export function invalidateDoctrineCache(): void {
36
+ cachedBundled = null
37
+ }
38
+
39
+ async function detectTrunkBranch(repoDir: string): Promise<string> {
40
+ try {
41
+ const { stdout } = await execFileP("git", ["-C", repoDir, "symbolic-ref", "--short", "HEAD"])
42
+ return stdout.trim() || "main"
43
+ } catch {
44
+ return "main"
45
+ }
46
+ }
47
+
48
+ async function buildRuntimeBlock(loop: LoopMeta): Promise<string> {
49
+ const repoLine = loop.repo ? `${loop.repo} (branch ${loop.branch ?? "main"})` : "(no repo bound — empty workdir)"
50
+ const [notesTrunk, knowledgeTrunk] = await Promise.all([
51
+ detectTrunkBranch(workspaceNotesDir()),
52
+ detectTrunkBranch(workspaceKnowledgeDir()),
53
+ ])
54
+ const lines = [
55
+ `## Runtime context (this loop)`,
56
+ ``,
57
+ `- title: ${loop.title}`,
58
+ `- id: ${loop.id}`,
59
+ `- driver: ${effectiveDriver(loop)}`,
60
+ `- workdir: /loopat/loop/${loop.id}/workdir`,
61
+ `- repo: ${repoLine}`,
62
+ `- context worktrees: notes on branch \`loop/${loop.id}\` (trunk \`${notesTrunk}\`), knowledge on branch \`loop/${loop.id}\` (trunk \`${knowledgeTrunk}\`)`,
63
+ `- created: ${loop.createdAt}`,
64
+ ]
65
+ if (loop.config?.goal) {
66
+ const status = (loop.config as any).goalStatus === "completed" ? "completed" : "active"
67
+ const statusNote = status === "completed"
68
+ ? `(marked complete)`
69
+ : `(active — work persistently; when done, tell the user so they can mark it complete)`
70
+ lines.push(``)
71
+ lines.push(`## Goal ${statusNote}`)
72
+ lines.push(``)
73
+ lines.push(loop.config.goal)
74
+ lines.push(``)
75
+ if (status === "active") {
76
+ lines.push(`This is your top priority. Every action you take should move this goal forward.`)
77
+ lines.push(`Break it into concrete steps, execute them, and verify the results.`)
78
+ lines.push(`When you believe the goal is fully accomplished, explicitly tell the user what you did and ask them to confirm completion.`)
79
+ } else {
80
+ lines.push(`This goal has been marked complete. The user may set a new goal with /goal.`)
81
+ }
82
+ }
83
+ return lines.join("\n").trim()
84
+ }
85
+
86
+ export async function buildLoopatAppend(loop: LoopMeta): Promise<string> {
87
+ const bundled = await loadBundled()
88
+ const runtime = await buildRuntimeBlock(loop)
89
+ return `${bundled}\n\n${runtime}\n`.trim()
90
+ }
@@ -0,0 +1,211 @@
1
+ import { spawn, type IPty } from "bun-pty"
2
+ import type { WSContext } from "hono/ws"
3
+ import { mkdir, chmod } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { ensureContainer, buildPodmanExecArgs, markActive, markInactive, V_LOOP_WORKDIR, getLoopWarning } from "./podman"
6
+ import { updateLoopStatus } from "./loop-status"
7
+ import { effectiveDriver, getLoop, loopEphemeralPorts } from "./loops"
8
+ import { loadPersonalConfig } from "./config"
9
+
10
+ type Term = {
11
+ proc: IPty
12
+ subscribers: Set<WSContext>
13
+ /**
14
+ * Rolling buffer of recent PTY output. Replayed to each new subscriber
15
+ * so the initial prompt (emitted before the first ws joined) and history
16
+ * since term spawn are visible on attach. Capped by SCROLLBACK_MAX_BYTES.
17
+ */
18
+ scrollback: string[]
19
+ scrollbackBytes: number
20
+ }
21
+
22
+ const SCROLLBACK_MAX_BYTES = 64 * 1024
23
+
24
+ const terms = new Map<string, Term>()
25
+ const pending = new Map<string, Promise<Term>>()
26
+
27
+ async function getOrSpawn(loopId: string, initCols = 80, initRows = 24): Promise<Term> {
28
+ const existing = terms.get(loopId)
29
+ if (existing) return existing
30
+ const inflight = pending.get(loopId)
31
+ if (inflight) return inflight
32
+
33
+ const tag = loopId.slice(0, 8)
34
+ const p = (async () => {
35
+ const meta = await getLoop(loopId)
36
+ if (!meta) throw new Error(`loop ${loopId} not found`)
37
+ const driver = effectiveDriver(meta)
38
+ const personalCfg = await loadPersonalConfig(driver, meta.config?.vault)
39
+
40
+ // Shell resolution (highest precedence first):
41
+ // 1. personal config `shell` — user's per-user override
42
+ // 2. /bin/bash — POSIX-guaranteed fallback
43
+ let innerShell = personalCfg.shell
44
+ if (!innerShell) innerShell = "/bin/bash"
45
+ // `script -qfc "<shell> -i" /dev/null` gives the inner shell a fresh
46
+ // controlling tty so prompt + job control work cleanly. PATH (incl.
47
+ // the mise shims dir) is baked into the per-loop image's ENV, so the
48
+ // toolchain works in here without host-side activation.
49
+ const innerCmd = `script -qfc "${innerShell} -i" /dev/null`
50
+
51
+ // Fish (and other interactive shells) want XDG_DATA_HOME / XDG_RUNTIME_DIR
52
+ // to be writable. /tmp is bound shared with host inside the container at
53
+ // the same path, so we can safely mkdir paths here that the container
54
+ // will see at the same location.
55
+ const fishHome = `/tmp/loopat-fish-${loopId}`
56
+ const fishData = join(fishHome, "data")
57
+ const fishRuntime = join(fishHome, "runtime")
58
+ await mkdir(fishData, { recursive: true })
59
+ await mkdir(fishRuntime, { recursive: true })
60
+ await chmod(fishRuntime, 0o700).catch(() => {})
61
+
62
+ await ensureContainer({
63
+ loopId,
64
+ createdBy: driver,
65
+ vaultName: meta.config?.vault,
66
+ knowledgeRw: meta.config?.knowledge_rw,
67
+ mountAllLoops: meta.config?.mount_all_loops,
68
+ extraEnv: personalCfg.vaultEnvs,
69
+ ephemeralPorts: loopEphemeralPorts(meta),
70
+ }, {
71
+ onProgress: (msg) => updateLoopStatus(loopId, msg),
72
+ })
73
+ markActive(loopId, "pty")
74
+ updateLoopStatus(loopId, "Ready")
75
+
76
+ const podmanArgs = buildPodmanExecArgs({
77
+ loopId,
78
+ command: "/bin/bash",
79
+ args: ["-c", innerCmd],
80
+ env: {
81
+ ...personalCfg.vaultEnvs,
82
+ TERM: "xterm-256color",
83
+ XDG_DATA_HOME: fishData,
84
+ XDG_RUNTIME_DIR: fishRuntime,
85
+ },
86
+ tty: true,
87
+ interactive: true,
88
+ workdir: V_LOOP_WORKDIR(loopId),
89
+ })
90
+
91
+ const binary = process.env.LOOPAT_PODMAN_BIN || "podman"
92
+ console.error(`[term:${tag}] spawn ${binary} argc=${podmanArgs.length}`)
93
+ const proc = spawn(binary, podmanArgs, {
94
+ name: "xterm-256color",
95
+ cols: initCols,
96
+ rows: initRows,
97
+ env: { ...process.env, TERM: "xterm-256color" } as Record<string, string>,
98
+ })
99
+ const t: Term = { proc, subscribers: new Set(), scrollback: [], scrollbackBytes: 0 }
100
+ terms.set(loopId, t)
101
+
102
+ proc.onData((chunk) => {
103
+ t.scrollback.push(chunk)
104
+ t.scrollbackBytes += chunk.length
105
+ while (t.scrollbackBytes > SCROLLBACK_MAX_BYTES && t.scrollback.length > 1) {
106
+ const dropped = t.scrollback.shift()!
107
+ t.scrollbackBytes -= dropped.length
108
+ }
109
+ for (const ws of t.subscribers) {
110
+ try { ws.send(JSON.stringify({ type: "data", data: chunk })) } catch {}
111
+ }
112
+ })
113
+ proc.onExit(({ exitCode }) => {
114
+ if (exitCode !== 0) {
115
+ const trailing = t.scrollback.join("").slice(-400)
116
+ console.error(`[term:${tag}] podman exit=${exitCode}; last 400 bytes of pty output:\n${trailing}`)
117
+ }
118
+ for (const ws of t.subscribers) {
119
+ try {
120
+ ws.send(JSON.stringify({ type: "exit", code: exitCode }))
121
+ ws.close()
122
+ } catch {}
123
+ }
124
+ terms.delete(loopId)
125
+ })
126
+
127
+ return t
128
+ })()
129
+
130
+ pending.set(loopId, p)
131
+ try {
132
+ return await p
133
+ } catch (e: any) {
134
+ console.error(`[term:${tag}] spawn failed: ${e?.message ?? e}`)
135
+ throw e
136
+ } finally {
137
+ pending.delete(loopId)
138
+ }
139
+ }
140
+
141
+ export async function attachTerm(loopId: string, ws: WSContext, initCols = 80, initRows = 24) {
142
+ const t = await getOrSpawn(loopId, initCols, initRows)
143
+ t.subscribers.add(ws)
144
+ // If ensureLoopImage fell back to the base image because the loop's
145
+ // mise.toml failed to build, surface the reason to the user in-band.
146
+ // The loop is still usable — the user just doesn't get their toolchain
147
+ // until they fix mise.toml and restart the loop.
148
+ const warning = getLoopWarning(loopId)
149
+ if (warning) {
150
+ try {
151
+ ws.send(JSON.stringify({
152
+ type: "data",
153
+ data: `\r\n\x1b[33m⚠ ${warning}\x1b[0m\r\n`,
154
+ }))
155
+ } catch {}
156
+ }
157
+ // Send ^L so the inner shell redraws once the new viewer is attached.
158
+ t.proc.write("\x0c")
159
+ }
160
+
161
+ export function detachTerm(loopId: string, ws: WSContext) {
162
+ const t = terms.get(loopId)
163
+ if (!t) return
164
+ t.subscribers.delete(ws)
165
+ if (t.subscribers.size === 0) {
166
+ try { t.proc.kill() } catch {}
167
+ terms.delete(loopId)
168
+ markInactive(loopId, "pty")
169
+ }
170
+ }
171
+
172
+ export function writeTerm(loopId: string, data: string) {
173
+ const t = terms.get(loopId)
174
+ if (!t) return
175
+ t.proc.write(data)
176
+ }
177
+
178
+ export function resizeTerm(loopId: string, cols: number, rows: number) {
179
+ const t = terms.get(loopId)
180
+ if (!t) return
181
+ try { t.proc.resize(cols, rows) } catch {}
182
+ }
183
+
184
+ /** Force-kill a loop's terminal PTY process and disconnect all subscribers.
185
+ * Handles the in-flight spawn case (pending promise). */
186
+ export function killTerm(loopId: string) {
187
+ const inflight = pending.get(loopId)
188
+ if (inflight) {
189
+ inflight.then((t) => {
190
+ terms.delete(loopId)
191
+ for (const ws of t.subscribers) {
192
+ try { ws.send(JSON.stringify({ type: "exit", code: -1 })); ws.close() } catch {}
193
+ }
194
+ try { t.proc.kill() } catch {}
195
+ }).catch(() => {})
196
+ pending.delete(loopId)
197
+ return
198
+ }
199
+ const t = terms.get(loopId)
200
+ if (!t) return
201
+ terms.delete(loopId)
202
+ for (const ws of t.subscribers) {
203
+ try {
204
+ ws.send(JSON.stringify({ type: "exit", code: -1 }))
205
+ ws.close()
206
+ } catch {}
207
+ }
208
+ t.subscribers.clear()
209
+ try { t.proc.kill() } catch {}
210
+ markInactive(loopId, "pty")
211
+ }