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,30 @@
1
+ export const DEFAULT_PROVIDER_PRESETS: Array<{ name: string; baseUrl: string; models: string[] }> = [
2
+ { name: "Anthropic", baseUrl: "https://api.anthropic.com",
3
+ models: ["claude-sonnet-4-20250514", "claude-opus-4-7-20251101"] },
4
+ { name: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic",
5
+ models: ["deepseek-v4-pro[1m]", "deepseek-v4-flash[1m]"] },
6
+ { name: "Kimi", baseUrl: "https://api.moonshot.cn/anthropic",
7
+ models: ["kimi-k2.6"] },
8
+ { name: "MiniMax", baseUrl: "https://api.minimaxi.com/anthropic",
9
+ models: ["MiniMax-M2.7"] },
10
+ { name: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1",
11
+ models: ["anthropic/claude-sonnet-4", "openai/gpt-4o", "google/gemini-2.5-flash"] },
12
+ ]
13
+
14
+ export const DEFAULT_MISE_TOOL_PRESETS: Array<{ name: string; suggestedVersion: string; description?: string; backend?: string }> = [
15
+ { name: "node", suggestedVersion: "22", description: "Node.js runtime" },
16
+ { name: "python", suggestedVersion: "3.12", description: "Python runtime" },
17
+ { name: "go", suggestedVersion: "1.22", description: "Go programming language" },
18
+ { name: "rust", suggestedVersion: "stable", description: "Rust programming language" },
19
+ { name: "bun", suggestedVersion: "latest", description: "Bun all-in-one runtime" },
20
+ { name: "java", suggestedVersion: "21", description: "Java Development Kit" },
21
+ { name: "terraform", suggestedVersion: "1.9", description: "Infrastructure as code", backend: "aqua:hashicorp/terraform" },
22
+ { name: "lua", suggestedVersion: "5.1", description: "Lua scripting language" },
23
+ { name: "zig", suggestedVersion: "0.13", description: "Zig general-purpose language" },
24
+ { name: "ripgrep", suggestedVersion: "14.1", description: "Line-oriented search tool", backend: "aqua:BurntSushi/ripgrep" },
25
+ { name: "fd", suggestedVersion: "10.2", description: "Fast file finder", backend: "aqua:sharkdp/fd" },
26
+ { name: "jq", suggestedVersion: "1.7", description: "Command-line JSON processor", backend: "aqua:jqlang/jq" },
27
+ ]
28
+
29
+ /** @deprecated — use DEFAULT_PROVIDER_PRESETS */
30
+ export const PROVIDER_PRESETS = DEFAULT_PROVIDER_PRESETS
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Profile resolver — CC-native model (post-2026-05 refactor).
3
+ *
4
+ * A "profile" in loopat is a directory under `.loopat/profiles/<name>/`
5
+ * that contains a `.claude/` subdir (the same shape CC's project-tier uses:
6
+ * settings.json + CLAUDE.md + skills/ + agents/). No loopat-invented schema.
7
+ *
8
+ * On loop spawn, loopat:
9
+ * 1. Determines active profiles (user defaults + CLI flags)
10
+ * 2. Merges team's `.loopat/.claude/` + each active profile's `.claude/`
11
+ * + personal layer into loop's `.claude/` (handled by compose.ts)
12
+ * 3. Reads merged settings.json's `enabledPlugins` + `extraKnownMarketplaces`
13
+ * to drive plugin installation (handled by plugin-installer.ts)
14
+ *
15
+ * See docs/composition.md.
16
+ */
17
+
18
+ import { existsSync } from "node:fs"
19
+ import { readFile, readdir } from "node:fs/promises"
20
+ import { join } from "node:path"
21
+ import {
22
+ personalClaudeDir,
23
+ personalLoopatConfigPath,
24
+ personalVaultDir,
25
+ workspaceProfileClaudeDir,
26
+ workspaceProfilesDir,
27
+ workspaceTeamClaudeDir,
28
+ } from "./paths"
29
+
30
+ /** personal/<u>/.loopat/config.json fields relevant to profile resolution. */
31
+ export type PersonalProfileConfig = {
32
+ default_profiles?: string[]
33
+ default_vault?: string
34
+ prefs?: Record<string, unknown>
35
+ }
36
+
37
+ /** Output of resolveLoopPlan — describes the materialization sources. */
38
+ export type LoopPlan = {
39
+ user: string
40
+ /** `.claude/` dirs to merge into the loop's .claude/, in load order
41
+ * (later sources win on conflicts; team first, profiles in declared order,
42
+ * personal last). */
43
+ claudeSources: Array<{ source: string; dir: string }>
44
+ /** Active profile names (excludes team & personal). */
45
+ profiles: string[]
46
+ /** Vault selection (from personal config or override). */
47
+ vault?: string
48
+ /** Resolved vault dir on host (if exists). */
49
+ vaultDir?: string
50
+ }
51
+
52
+ export type ResolveInput = {
53
+ user: string
54
+ /** Profiles added via CLI (+name). */
55
+ cliAdded?: string[]
56
+ /** Profiles removed via CLI (-name). */
57
+ cliRemoved?: string[]
58
+ /** Hard override — replaces default_profiles. */
59
+ overrideProfiles?: string[]
60
+ /** Override vault selection. */
61
+ vaultOverride?: string
62
+ /** @deprecated Workdir is the SDK's project tier, read directly via
63
+ * settingSources='project'. It is NOT merged into the user tier any more
64
+ * (otherwise edits to workdir/.claude/ would change a frozen loop's
65
+ * user-tier snapshot, violating principle 1). The field is kept for the
66
+ * loopat CLI which still bundles workdir into a one-shot compose; remove
67
+ * once that path is gone. */
68
+ workdir?: string
69
+ }
70
+
71
+ async function readPersonalConfig(user: string): Promise<PersonalProfileConfig> {
72
+ const path = personalLoopatConfigPath(user)
73
+ if (!existsSync(path)) return {}
74
+ try {
75
+ return JSON.parse(await readFile(path, "utf8")) as PersonalProfileConfig
76
+ } catch {
77
+ return {}
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Compute active profile set: (default_profiles ∪ cliAdded) − cliRemoved,
83
+ * with overrideProfiles replacing default_profiles when set. Order preserved:
84
+ * defaults first, then cliAdded. Team-tier is always implicit (handled by
85
+ * compose); no need to include it here.
86
+ */
87
+ function computeActiveProfiles(
88
+ cfg: PersonalProfileConfig,
89
+ cliAdded: string[],
90
+ cliRemoved: string[],
91
+ overrideProfiles?: string[],
92
+ ): string[] {
93
+ const base = overrideProfiles ?? cfg.default_profiles ?? []
94
+ const removed = new Set(cliRemoved)
95
+ const all = [...base, ...cliAdded]
96
+ const out: string[] = []
97
+ const seen = new Set<string>()
98
+ for (const p of all) {
99
+ if (removed.has(p) || seen.has(p)) continue
100
+ seen.add(p)
101
+ out.push(p)
102
+ }
103
+ return out
104
+ }
105
+
106
+ /**
107
+ * Main entry: produce a LoopPlan from inputs. Pure / no side effects.
108
+ * Validates that named profiles actually have `.claude/` subdirs (otherwise
109
+ * they'd be silently invisible).
110
+ */
111
+ export async function resolveLoopPlan(input: ResolveInput): Promise<LoopPlan> {
112
+ const { user, cliAdded = [], cliRemoved = [], overrideProfiles, vaultOverride, workdir } = input
113
+
114
+ const cfg = await readPersonalConfig(user)
115
+ const activeNames = computeActiveProfiles(cfg, cliAdded, cliRemoved, overrideProfiles)
116
+
117
+ // Validate
118
+ const profilesRoot = workspaceProfilesDir()
119
+ if (activeNames.length > 0 && !existsSync(profilesRoot)) {
120
+ throw new Error(`workspace profiles dir not found: ${profilesRoot}`)
121
+ }
122
+ for (const name of activeNames) {
123
+ const cdir = workspaceProfileClaudeDir(name)
124
+ if (!existsSync(cdir)) {
125
+ throw new Error(`profile "${name}" has no .claude/ dir at ${cdir}`)
126
+ }
127
+ }
128
+
129
+ // Build claudeSources in merge order
130
+ const claudeSources: LoopPlan["claudeSources"] = []
131
+ const teamDir = workspaceTeamClaudeDir()
132
+ if (existsSync(teamDir)) {
133
+ claudeSources.push({ source: "team", dir: teamDir })
134
+ }
135
+ for (const name of activeNames) {
136
+ claudeSources.push({ source: `profile:${name}`, dir: workspaceProfileClaudeDir(name) })
137
+ }
138
+ // Personal `.claude/` — 4th layer. Same CC-native shape as workspace + profile.
139
+ const personalCdir = personalClaudeDir(user)
140
+ if (existsSync(personalCdir)) {
141
+ claudeSources.push({ source: `personal:${user}`, dir: personalCdir })
142
+ }
143
+
144
+ // Repo `.claude/` — 5th (highest) layer. CC project-tier from workdir.
145
+ // Optional; only if workdir is set AND has a .claude/ subdir.
146
+ if (workdir) {
147
+ const repoCdir = join(workdir, ".claude")
148
+ if (existsSync(repoCdir)) {
149
+ claudeSources.push({ source: `repo:${workdir}`, dir: repoCdir })
150
+ }
151
+ }
152
+
153
+ const vault = vaultOverride ?? cfg.default_vault
154
+ const vaultDir = vault ? personalVaultDir(user, vault) : undefined
155
+
156
+ return {
157
+ user,
158
+ claudeSources,
159
+ profiles: activeNames,
160
+ vault,
161
+ vaultDir: vaultDir && existsSync(vaultDir) ? vaultDir : undefined,
162
+ }
163
+ }
164
+
165
+ /** List available profile names = direct subdirs of profiles/ that contain `.claude/`. */
166
+ export async function listProfiles(): Promise<string[]> {
167
+ const root = workspaceProfilesDir()
168
+ if (!existsSync(root)) return []
169
+ const entries = await readdir(root, { withFileTypes: true })
170
+ const out: string[] = []
171
+ for (const e of entries) {
172
+ if (!e.isDirectory() || e.name.startsWith(".")) continue
173
+ if (!existsSync(workspaceProfileClaudeDir(e.name))) continue
174
+ out.push(e.name)
175
+ }
176
+ return out.sort()
177
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Git-host provider registry bootstrap.
3
+ *
4
+ * - Built-in (open-source) providers self-register via the static imports below.
5
+ * - External / internal providers live OUTSIDE the repo, in
6
+ * `LOOPAT_HOME/extensions/providers/*.{ts,js,mjs}`. `loadExtensionProviders()`
7
+ * dynamically imports each file and registers its default export. An extension
8
+ * is a plain object shaped like GitHostProvider — it does NOT import loopat, so
9
+ * an internal platform's adapter never has to enter the open-source core.
10
+ */
11
+ import { join } from "node:path"
12
+ import { existsSync } from "node:fs"
13
+ import { readdir } from "node:fs/promises"
14
+ import { pathToFileURL } from "node:url"
15
+ import { registerProvider, type GitHostProvider } from "./git-host"
16
+ import { extensionsProvidersDir } from "./paths"
17
+
18
+ import "./github" // built-in, open-source
19
+
20
+ let extLoaded = false
21
+
22
+ /** Idempotently load external provider extensions from the extensions dir. */
23
+ export async function loadExtensionProviders(): Promise<void> {
24
+ if (extLoaded) return
25
+ extLoaded = true
26
+ const dir = extensionsProvidersDir()
27
+ if (!existsSync(dir)) return
28
+ let files: string[] = []
29
+ try { files = await readdir(dir) } catch { return }
30
+ for (const f of files) {
31
+ if (!/\.(ts|js|mjs)$/.test(f)) continue
32
+ try {
33
+ const mod: any = await import(pathToFileURL(join(dir, f)).href)
34
+ const p = mod.default ?? mod.provider
35
+ if (p?.id && typeof p.authenticate === "function" && typeof p.ensureRepo === "function") {
36
+ registerProvider(p as GitHostProvider)
37
+ console.log(`[loopat] loaded git-host extension: ${p.id}`)
38
+ } else {
39
+ console.warn(`[loopat] ${f}: not a valid GitHostProvider (need id / authenticate / ensureRepo)`)
40
+ }
41
+ } catch (e: any) {
42
+ console.warn(`[loopat] failed to load provider extension ${f}: ${e?.message ?? e}`)
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Standalone workspace serve service.
3
+ * Listens on port 7788, serves loop workdirs via subdomain routing.
4
+ * Supports static file serving and HTTP port forwarding.
5
+ */
6
+ import { createServer, request as httpRequest } from "node:http"
7
+ import { existsSync, statSync, createReadStream, readdirSync, readFileSync as readFileSyncFs } from "node:fs"
8
+ import { join, normalize } from "node:path"
9
+ import { loopsDir, loopWorkdir, loopMetaPath } from "./paths"
10
+
11
+ const SERVE_PORT = Number(process.env.LOOPAT_SERVE_PORT ?? 7788)
12
+ const SERVE_HOST = process.env.LOOPAT_SERVE_HOST ?? "127.0.0.1"
13
+
14
+ // Blocked paths — never served
15
+ const BLOCKED = new Set([
16
+ ".git", ".ssh", ".env", "node_modules", ".DS_Store",
17
+ ".bun", ".claude", ".vscode", ".idea",
18
+ ])
19
+
20
+ function isBlocked(filePath: string): boolean {
21
+ const parts = filePath.split("/").filter(Boolean)
22
+ return parts.some((p) => BLOCKED.has(p) || p.startsWith(".env"))
23
+ }
24
+
25
+ const MIME_TYPES: Record<string, string> = {
26
+ ".html": "text/html",
27
+ ".css": "text/css",
28
+ ".js": "application/javascript",
29
+ ".json": "application/json",
30
+ ".png": "image/png",
31
+ ".jpg": "image/jpeg",
32
+ ".jpeg": "image/jpeg",
33
+ ".gif": "image/gif",
34
+ ".svg": "image/svg+xml",
35
+ ".ico": "image/x-icon",
36
+ ".woff": "font/woff",
37
+ ".woff2": "font/woff2",
38
+ ".ttf": "font/ttf",
39
+ ".eot": "application/vnd.ms-fontobject",
40
+ ".otf": "font/otf",
41
+ ".txt": "text/plain",
42
+ ".md": "text/markdown",
43
+ ".xml": "application/xml",
44
+ ".pdf": "application/pdf",
45
+ ".zip": "application/zip",
46
+ ".tar": "application/x-tar",
47
+ ".gz": "application/gzip",
48
+ ".wasm": "application/wasm",
49
+ ".webp": "image/webp",
50
+ ".mp4": "video/mp4",
51
+ ".webm": "video/webm",
52
+ ".mp3": "audio/mpeg",
53
+ ".ogg": "audio/ogg",
54
+ ".wav": "audio/wav",
55
+ ".csv": "text/csv",
56
+ ".yaml": "text/yaml",
57
+ ".yml": "text/yaml",
58
+ ".toml": "text/toml",
59
+ }
60
+
61
+ function getMime(path: string): string {
62
+ return MIME_TYPES[normalize(path).split(".").pop() ? `.${normalize(path).split(".").pop()}`.toLowerCase() : ""] || "application/octet-stream"
63
+ }
64
+
65
+ type LoopMeta = {
66
+ id: string
67
+ title: string
68
+ shareEnabled?: boolean
69
+ shareMode?: "static" | "port"
70
+ shareAlias?: string
71
+ sharePort?: number
72
+ }
73
+
74
+ // Cache: alias -> loop_id
75
+ const aliasCache = new Map<string, string>()
76
+
77
+ function loadMeta(loopId: string): LoopMeta | null {
78
+ const p = loopMetaPath(loopId)
79
+ if (!existsSync(p)) return null
80
+ try {
81
+ return JSON.parse(readFileSyncFs(p, "utf8"))
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ function resolveLoop(host: string): { loopId: string; meta: LoopMeta } | null {
88
+ const parts = host.split(".")
89
+ if (parts.length < 2) return null
90
+ const subdomain = parts[0].toLowerCase()
91
+
92
+ // Check alias cache first
93
+ if (aliasCache.has(subdomain)) {
94
+ const loopId = aliasCache.get(subdomain)!
95
+ const meta = loadMeta(loopId)
96
+ if (meta?.shareEnabled) return { loopId, meta }
97
+ aliasCache.delete(subdomain)
98
+ }
99
+
100
+ // Scan all loops
101
+ let dirs: string[]
102
+ try {
103
+ dirs = readdirSync(loopsDir())
104
+ } catch {
105
+ return null
106
+ }
107
+
108
+ for (const dir of dirs) {
109
+ const meta = loadMeta(dir)
110
+ if (!meta) continue
111
+ if (!meta.shareEnabled) continue
112
+ const shortId = dir.slice(0, 8)
113
+ if (shortId === subdomain || meta.shareAlias === subdomain) {
114
+ if (meta.shareAlias) aliasCache.set(meta.shareAlias, dir)
115
+ return { loopId: dir, meta }
116
+ }
117
+ }
118
+ return null
119
+ }
120
+
121
+ function rebuildAliasCache() {
122
+ aliasCache.clear()
123
+ let dirs: string[]
124
+ try {
125
+ dirs = readdirSync(loopsDir())
126
+ } catch {
127
+ return
128
+ }
129
+ for (const dir of dirs) {
130
+ const meta = loadMeta(dir)
131
+ if (meta?.shareEnabled && meta.shareAlias) {
132
+ aliasCache.set(meta.shareAlias, dir)
133
+ }
134
+ }
135
+ }
136
+
137
+ rebuildAliasCache()
138
+ setInterval(rebuildAliasCache, 30_000)
139
+
140
+ function serveStaticFile(workdir: string, urlPath: string, res: any): boolean {
141
+ let rel = decodeURIComponent(urlPath)
142
+ if (rel.startsWith("/")) rel = rel.slice(1)
143
+ if (!rel) rel = "index.html"
144
+
145
+ const full = normalize(join(workdir, rel))
146
+ if (!full.startsWith(normalize(workdir))) {
147
+ res.writeHead(403)
148
+ res.end("Forbidden")
149
+ return true
150
+ }
151
+
152
+ if (isBlocked(rel)) {
153
+ res.writeHead(403)
154
+ res.end("Forbidden")
155
+ return true
156
+ }
157
+
158
+ if (!existsSync(full)) {
159
+ if (existsSync(join(full, "index.html"))) {
160
+ return serveStaticFile(workdir, rel + "/index.html", res)
161
+ }
162
+ res.writeHead(404)
163
+ res.end("Not found")
164
+ return true
165
+ }
166
+
167
+ const s = statSync(full)
168
+ if (s.isDirectory()) {
169
+ if (existsSync(join(full, "index.html"))) {
170
+ return serveStaticFile(workdir, rel + "/index.html", res)
171
+ }
172
+ res.writeHead(403)
173
+ res.end("Directory listing not allowed")
174
+ return true
175
+ }
176
+
177
+ if (!s.isFile()) {
178
+ res.writeHead(403)
179
+ res.end("Forbidden")
180
+ return true
181
+ }
182
+
183
+ res.writeHead(200, {
184
+ "Content-Type": getMime(full),
185
+ "Content-Length": s.size,
186
+ "Cache-Control": "no-cache",
187
+ })
188
+ createReadStream(full).pipe(res)
189
+ return true
190
+ }
191
+
192
+ function proxyToPort(port: number, req: any, res: any): void {
193
+ const headers: Record<string, string> = { ...req.headers }
194
+ delete headers["host"]
195
+ headers["host"] = `localhost:${port}`
196
+ if (req.socket.remoteAddress) headers["x-forwarded-for"] = req.socket.remoteAddress
197
+ if (req.headers["host"]) headers["x-forwarded-host"] = req.headers["host"]
198
+
199
+ const proxyReq = httpRequest({
200
+ hostname: "127.0.0.1",
201
+ port,
202
+ method: req.method,
203
+ path: req.url,
204
+ headers,
205
+ timeout: 30_000,
206
+ }, (proxyRes: any) => {
207
+ res.writeHead(proxyRes.statusCode, proxyRes.headers)
208
+ proxyRes.pipe(res)
209
+ })
210
+
211
+ proxyReq.on("error", () => {
212
+ res.writeHead(502)
213
+ res.end("Port forwarding error - is the service running?")
214
+ })
215
+
216
+ proxyReq.on("timeout", () => {
217
+ proxyReq.destroy()
218
+ res.writeHead(504)
219
+ res.end("Gateway timeout")
220
+ })
221
+
222
+ req.pipe(proxyReq)
223
+ }
224
+
225
+ const server = createServer((req, res) => {
226
+ const host = (req.headers["host"] ?? "").split(":")[0].toLowerCase()
227
+ const resolved = resolveLoop(host)
228
+
229
+ if (!resolved) {
230
+ res.writeHead(404, { "Content-Type": "text/plain" })
231
+ res.end("No workspace found for this domain")
232
+ return
233
+ }
234
+
235
+ const { meta, loopId } = resolved
236
+
237
+ if (!meta.shareEnabled) {
238
+ res.writeHead(403)
239
+ res.end("Workspace sharing is disabled")
240
+ return
241
+ }
242
+
243
+ const workdir = loopWorkdir(loopId)
244
+ if (!existsSync(workdir)) {
245
+ res.writeHead(404)
246
+ res.end("Workdir not found")
247
+ return
248
+ }
249
+
250
+ if (meta.shareMode === "port" && meta.sharePort) {
251
+ if (meta.sharePort < 1024 || meta.sharePort > 65535) {
252
+ res.writeHead(400)
253
+ res.end("Invalid port — must be 1024-65535")
254
+ return
255
+ }
256
+ proxyToPort(meta.sharePort, req, res)
257
+ } else {
258
+ serveStaticFile(workdir, req.url ?? "/", res)
259
+ }
260
+ })
261
+
262
+ server.on("error", (e: any) => {
263
+ if (e.code === "EADDRINUSE") {
264
+ console.error(`[loopat] workspace serve port ${SERVE_PORT} already in use`)
265
+ } else {
266
+ console.error(`[loopat] workspace serve error:`, e)
267
+ }
268
+ })
269
+
270
+ console.log(`[loopat] workspace serve starting on http://${SERVE_HOST}:${SERVE_PORT}`)
271
+ server.listen(SERVE_PORT, SERVE_HOST, () => {
272
+ console.log(`[loopat] workspace serve listening on http://${SERVE_HOST}:${SERVE_PORT}`)
273
+ })
274
+
275
+ export { server }