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