From 3b9de60f5821129751ca09ce0ec4f4c341834e70 Mon Sep 17 00:00:00 2001 From: simpx Date: Sun, 10 May 2026 15:58:56 +0800 Subject: [PATCH 001/585] =?UTF-8?q?feat(loopat):=20single-user=20MVP=20?= =?UTF-8?q?=E2=80=94=20Loop=20/=20Focus=20/=20Context=20tabs=20(v5.0?= =?UTF-8?q?=E2=80=935.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server (Hono + ws + Claude Agent SDK + sandbox-runtime): - per-loop sandbox: bwrap allowlist (denyRead /home/$USER + carve-outs); personal// symlinks auto-resolved into sandbox allow paths; loop dir IS the sandbox description (memory: project_loop_dir_is_sandbox.md) - workspace-scoped paths: ~/.loopat//{loops,context/{knowledge, notes,repos},personal/}; workspace hardcoded "1001" for now - providers via ~/.loopat/1001/config.json (auto-template); bailian / anthropic preset, default bailian; no env-var dependency on ccx - workspace files API + auto-commit on save (each PUT → ": ") - git worktree integration: createLoop({title, repo}) → git worktree add to ~/.loopat/.../loops//workdir on branch loop/- - ws history persistence + history_end marker; subscriber pending-buffer during attach replay (no race); viewers count broadcast web (React 19 + Vite + assistant-ui prebuilt + Tailwind v4 + shadcn): - 3-tab shell ported from phase1-prototype/src/App.tsx (brand dropdown, tab nav, + New Loop, user widget) + react-router v7 - Loop page: phase-1 LoopList + LoopHeader + RightPanel (info/workdir/ editor/terminal); FileTree with section coloring (context cyan, workdir emerald); textarea Editor with auto-commit save - Context page: 4 sub-tabs (knowledge/notes/personal/repos); VaultPane tree + view/edit; auto-commit on save; new-file dialog - Focus page: zen layout, derived from notes/focus.md (## pinned / ## listed) + notes/inbox.md - NewLoopDialog: repo dropdown + name; PUT /api/workspace/file etc. Phase-3 single-user complete. Apphosts on localhost:5173 (web) + localhost:7787 (server). config.json apiKey filled per user. --- .gitignore | 13 +- loopat/loop/.gitignore | 7 + loopat/loop/README.md | 27 ++ loopat/loop/package.json | 14 + loopat/loop/server/package.json | 18 + loopat/loop/server/src/claude-binary.ts | 68 +++ loopat/loop/server/src/config.ts | 61 +++ loopat/loop/server/src/files.ts | 85 ++++ loopat/loop/server/src/index.ts | 235 +++++++++ loopat/loop/server/src/loops.ts | 153 ++++++ loopat/loop/server/src/paths.ts | 33 ++ loopat/loop/server/src/personal-deps.ts | 33 ++ loopat/loop/server/src/sandbox.ts | 72 +++ loopat/loop/server/src/session.ts | 281 +++++++++++ loopat/loop/server/src/term.ts | 91 ++++ loopat/loop/server/src/workspace.ts | 235 +++++++++ loopat/loop/server/tsconfig.json | 12 + loopat/loop/web/components.json | 24 + loopat/loop/web/index.html | 12 + loopat/loop/web/package.json | 36 ++ loopat/loop/web/src/App.tsx | 187 ++++++++ loopat/loop/web/src/Editor.tsx | 80 ++++ loopat/loop/web/src/FileTree.tsx | 187 ++++++++ loopat/loop/web/src/Terminal.tsx | 76 +++ loopat/loop/web/src/api.ts | 107 +++++ .../components/assistant-ui/attachment.tsx | 223 +++++++++ .../components/assistant-ui/markdown-text.tsx | 243 ++++++++++ .../src/components/assistant-ui/reasoning.tsx | 282 +++++++++++ .../src/components/assistant-ui/thread.tsx | 445 +++++++++++++++++ .../components/assistant-ui/tool-fallback.tsx | 324 +++++++++++++ .../components/assistant-ui/tool-group.tsx | 231 +++++++++ .../assistant-ui/tooltip-icon-button.tsx | 45 ++ .../src/components/dialog/NewLoopDialog.tsx | 126 +++++ loopat/loop/web/src/components/ui/avatar.tsx | 107 +++++ loopat/loop/web/src/components/ui/button.tsx | 64 +++ .../web/src/components/ui/collapsible.tsx | 33 ++ loopat/loop/web/src/components/ui/dialog.tsx | 156 ++++++ loopat/loop/web/src/components/ui/tooltip.tsx | 57 +++ loopat/loop/web/src/ctx.ts | 10 + loopat/loop/web/src/index.css | 20 + loopat/loop/web/src/lib/utils.ts | 6 + loopat/loop/web/src/main.tsx | 10 + loopat/loop/web/src/pages/ContextPage.tsx | 446 ++++++++++++++++++ loopat/loop/web/src/pages/FocusPage.tsx | 270 +++++++++++ loopat/loop/web/src/pages/LoopPage.tsx | 376 +++++++++++++++ loopat/loop/web/src/state.ts | 31 ++ loopat/loop/web/src/useLoopRuntime.ts | 179 +++++++ loopat/loop/web/tsconfig.json | 20 + loopat/loop/web/vite.config.ts | 24 + 49 files changed, 5870 insertions(+), 5 deletions(-) create mode 100644 loopat/loop/.gitignore create mode 100644 loopat/loop/README.md create mode 100644 loopat/loop/package.json create mode 100644 loopat/loop/server/package.json create mode 100644 loopat/loop/server/src/claude-binary.ts create mode 100644 loopat/loop/server/src/config.ts create mode 100644 loopat/loop/server/src/files.ts create mode 100644 loopat/loop/server/src/index.ts create mode 100644 loopat/loop/server/src/loops.ts create mode 100644 loopat/loop/server/src/paths.ts create mode 100644 loopat/loop/server/src/personal-deps.ts create mode 100644 loopat/loop/server/src/sandbox.ts create mode 100644 loopat/loop/server/src/session.ts create mode 100644 loopat/loop/server/src/term.ts create mode 100644 loopat/loop/server/src/workspace.ts create mode 100644 loopat/loop/server/tsconfig.json create mode 100644 loopat/loop/web/components.json create mode 100644 loopat/loop/web/index.html create mode 100644 loopat/loop/web/package.json create mode 100644 loopat/loop/web/src/App.tsx create mode 100644 loopat/loop/web/src/Editor.tsx create mode 100644 loopat/loop/web/src/FileTree.tsx create mode 100644 loopat/loop/web/src/Terminal.tsx create mode 100644 loopat/loop/web/src/api.ts create mode 100644 loopat/loop/web/src/components/assistant-ui/attachment.tsx create mode 100644 loopat/loop/web/src/components/assistant-ui/markdown-text.tsx create mode 100644 loopat/loop/web/src/components/assistant-ui/reasoning.tsx create mode 100644 loopat/loop/web/src/components/assistant-ui/thread.tsx create mode 100644 loopat/loop/web/src/components/assistant-ui/tool-fallback.tsx create mode 100644 loopat/loop/web/src/components/assistant-ui/tool-group.tsx create mode 100644 loopat/loop/web/src/components/assistant-ui/tooltip-icon-button.tsx create mode 100644 loopat/loop/web/src/components/dialog/NewLoopDialog.tsx create mode 100644 loopat/loop/web/src/components/ui/avatar.tsx create mode 100644 loopat/loop/web/src/components/ui/button.tsx create mode 100644 loopat/loop/web/src/components/ui/collapsible.tsx create mode 100644 loopat/loop/web/src/components/ui/dialog.tsx create mode 100644 loopat/loop/web/src/components/ui/tooltip.tsx create mode 100644 loopat/loop/web/src/ctx.ts create mode 100644 loopat/loop/web/src/index.css create mode 100644 loopat/loop/web/src/lib/utils.ts create mode 100644 loopat/loop/web/src/main.tsx create mode 100644 loopat/loop/web/src/pages/ContextPage.tsx create mode 100644 loopat/loop/web/src/pages/FocusPage.tsx create mode 100644 loopat/loop/web/src/pages/LoopPage.tsx create mode 100644 loopat/loop/web/src/state.ts create mode 100644 loopat/loop/web/src/useLoopRuntime.ts create mode 100644 loopat/loop/web/tsconfig.json create mode 100644 loopat/loop/web/vite.config.ts diff --git a/.gitignore b/.gitignore index eb9d3a4c..cb6b3a62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ -# Nested repos with their own histories -claw-code/ -loop/ -opencode/ -loopat-ts/ +# Nested repos with their own histories (top-level only) +/claw-code/ +/loop/ +/opencode/ +/loopat-ts/ + +# loopat code lock files +bun.lock # Editor swap / temp files .*.swp diff --git a/loopat/loop/.gitignore b/loopat/loop/.gitignore new file mode 100644 index 00000000..01c620b6 --- /dev/null +++ b/loopat/loop/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.env +.env.local +*.log +.DS_Store +bun.lock diff --git a/loopat/loop/README.md b/loopat/loop/README.md new file mode 100644 index 00000000..8443eeaa --- /dev/null +++ b/loopat/loop/README.md @@ -0,0 +1,27 @@ +# loopat / loop + +1001 Phase 3 (0.1 单人版) 实现。Loop 页 MVP。 + +## 架构 + +- **server/** — Hono + ws + Claude Agent SDK,本机 `localhost:7787` +- **web/** — Vite + React + assistant-ui,`localhost:5173`,dev 时 ws 经 vite proxy 转 server +- **数据**:`~/.loopat/loops//`(filesystem-first,per-loop 一个目录) + +## 起 + +```sh +bun install +export ANTHROPIC_API_KEY=sk-ant-... +bun run dev +``` + +打开 。 + +## v1 范围 + +- 创建 loop / 列出 loop +- 一个 loop 跟 Claude 来回对话 +- 消息落 `~/.loopat/loops//.claude/projects//.jsonl` + +不做:attach、driver-transfer、context mount、富卡片右 panel、focus、chat tab、agent。 diff --git a/loopat/loop/package.json b/loopat/loop/package.json new file mode 100644 index 00000000..c63b1140 --- /dev/null +++ b/loopat/loop/package.json @@ -0,0 +1,14 @@ +{ + "name": "loopat-loop", + "private": true, + "type": "module", + "workspaces": [ + "server", + "web" + ], + "scripts": { + "dev": "bun run --filter '*' dev", + "dev:server": "bun --cwd server run dev", + "dev:web": "bun --cwd web run dev" + } +} diff --git a/loopat/loop/server/package.json b/loopat/loop/server/package.json new file mode 100644 index 00000000..568b9645 --- /dev/null +++ b/loopat/loop/server/package.json @@ -0,0 +1,18 @@ +{ + "name": "@loopat/server", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --hot src/index.ts" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.138", + "@anthropic-ai/sandbox-runtime": "^0.0.50", + "bun-pty": "^0.4.8", + "hono": "^4.12.18" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.6.0" + } +} diff --git a/loopat/loop/server/src/claude-binary.ts b/loopat/loop/server/src/claude-binary.ts new file mode 100644 index 00000000..0017d095 --- /dev/null +++ b/loopat/loop/server/src/claude-binary.ts @@ -0,0 +1,68 @@ +import { existsSync } from "node:fs" +import { execSync } from "node:child_process" +import { fileURLToPath } from "node:url" +import { dirname, resolve, join } from "node:path" + +function detectIsMusl(): boolean { + if (process.platform !== "linux") return false + try { + const lddOut = execSync("ldd --version 2>&1", { encoding: "utf8" }) as string + return /musl/i.test(lddOut) + } catch {} + return false +} + +function findWorkspaceRoot(start: string): string[] { + const roots: string[] = [] + let cur = start + for (let i = 0; i < 10; i++) { + if (existsSync(join(cur, "node_modules"))) roots.push(cur) + const parent = dirname(cur) + if (parent === cur) break + cur = parent + } + if (roots.length === 0) throw new Error("could not locate node_modules from " + start) + return roots +} + +export function resolveClaudeBinary(): string { + const platform = process.platform + const arch = process.arch + const ext = platform === "win32" ? ".exe" : "" + + const pkgs: string[] = [] + if (platform === "linux") { + if (detectIsMusl()) { + pkgs.push(`claude-agent-sdk-linux-${arch}-musl`, `claude-agent-sdk-linux-${arch}`) + } else { + pkgs.push(`claude-agent-sdk-linux-${arch}`, `claude-agent-sdk-linux-${arch}-musl`) + } + } else { + pkgs.push(`claude-agent-sdk-${platform}-${arch}`) + } + + const here = fileURLToPath(import.meta.url) + const roots = findWorkspaceRoot(dirname(here)) + const candidates: string[] = [] + for (const root of roots) { + for (const pkg of pkgs) { + candidates.push(join(root, "node_modules", "@anthropic-ai", pkg, `claude${ext}`)) + const bunDir = join(root, "node_modules", ".bun") + if (existsSync(bunDir)) { + try { + const entries = execSync(`ls "${bunDir}"`, { encoding: "utf8" }).split("\n").filter(Boolean) + for (const entry of entries) { + if (entry.startsWith(`@anthropic-ai+${pkg}@`)) { + candidates.push(join(bunDir, entry, "node_modules", "@anthropic-ai", pkg, `claude${ext}`)) + } + } + } catch {} + } + } + } + + for (const c of candidates) { + if (existsSync(c)) return c + } + throw new Error(`claude binary not found; tried:\n${candidates.join("\n")}`) +} diff --git a/loopat/loop/server/src/config.ts b/loopat/loop/server/src/config.ts new file mode 100644 index 00000000..d23d1828 --- /dev/null +++ b/loopat/loop/server/src/config.ts @@ -0,0 +1,61 @@ +import { existsSync } from "node:fs" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { workspaceDir } from "./paths" + +export type ProviderConfig = { + model: string + baseUrl: string + apiKey: string +} + +export type WorkspaceConfig = { + default: string + providers: Record +} + +const TEMPLATE: WorkspaceConfig = { + default: "bailian", + providers: { + bailian: { + model: "glm-5", + baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", + apiKey: "", + }, + anthropic: { + model: "claude-opus-4-7", + baseUrl: "https://api.anthropic.com", + apiKey: "", + }, + }, +} + +export const configPath = () => join(workspaceDir(), "config.json") + +let cached: WorkspaceConfig | null = null + +export async function loadConfig(): Promise { + if (cached) return cached + const path = configPath() + if (!existsSync(path)) { + await mkdir(workspaceDir(), { recursive: true }) + await writeFile(path, JSON.stringify(TEMPLATE, null, 2) + "\n") + console.warn(`[loopat] config: created template at ${path} — fill in apiKey then restart`) + cached = TEMPLATE + return cached + } + const raw = await readFile(path, "utf8") + const parsed = JSON.parse(raw) as WorkspaceConfig + if (!parsed.providers || typeof parsed.providers !== "object") { + throw new Error(`config.json malformed: missing providers`) + } + if (!parsed.default || !parsed.providers[parsed.default]) { + throw new Error(`config.json: default "${parsed.default}" not in providers`) + } + cached = parsed + return cached +} + +export function getActiveProvider(cfg: WorkspaceConfig): { name: string; provider: ProviderConfig } { + return { name: cfg.default, provider: cfg.providers[cfg.default] } +} diff --git a/loopat/loop/server/src/files.ts b/loopat/loop/server/src/files.ts new file mode 100644 index 00000000..0d53a880 --- /dev/null +++ b/loopat/loop/server/src/files.ts @@ -0,0 +1,85 @@ +import { readdir, readFile, writeFile, stat } from "node:fs/promises" +import { join, normalize, relative, sep, dirname } from "node:path" +import { mkdir } from "node:fs/promises" +import { loopDir } from "./paths" + +export type FileEntry = { + name: string + path: string // relative to workdir, posix-style + type: "file" | "dir" + size?: number +} + +function safeJoin(rootAbs: string, rel: string): string | null { + const candidate = normalize(join(rootAbs, rel)) + const insideRel = relative(rootAbs, candidate) + if (insideRel.startsWith("..") || insideRel.startsWith("/" + sep)) return null + return candidate +} + +const SKIP_DIRS = new Set(["node_modules", ".git", ".bun", ".claude"]) + +export async function listDir(loopId: string, relPath: string): Promise { + const root = loopDir(loopId) + const abs = safeJoin(root, relPath) + if (!abs) throw new Error("path escapes workdir") + let names: string[] = [] + try { + names = await readdir(abs) + } catch { + return [] + } + const out: FileEntry[] = [] + for (const name of names) { + if (SKIP_DIRS.has(name)) continue + if (name === ".git" || name === ".DS_Store") continue + const childRel = relPath ? `${relPath}/${name}` : name + let isDir = false + let size: number | undefined + try { + // stat follows symlinks → symlinked-dir reports as dir + const s = await stat(join(abs, name)) + isDir = s.isDirectory() + if (!isDir) size = s.size + } catch { + continue + } + out.push({ name, path: childRel, type: isDir ? "dir" : "file", size }) + } + out.sort((a, b) => { + if (a.type !== b.type) return a.type === "dir" ? -1 : 1 + return a.name.localeCompare(b.name) + }) + return out +} + +const MAX_BYTES = 256 * 1024 + +export async function readWorkdirFile(loopId: string, relPath: string): Promise<{ content: string; truncated: boolean; size: number } | null> { + const root = loopDir(loopId) + const abs = safeJoin(root, relPath) + if (!abs) return null + try { + const s = await stat(abs) + if (!s.isFile()) return null + const truncated = s.size > MAX_BYTES + const buf = await readFile(abs) + const slice = truncated ? buf.subarray(0, MAX_BYTES) : buf + return { content: slice.toString("utf8"), truncated, size: s.size } + } catch { + return null + } +} + +export async function writeWorkdirFile(loopId: string, relPath: string, content: string): Promise { + const root = loopDir(loopId) + const abs = safeJoin(root, relPath) + if (!abs) return false + try { + await mkdir(dirname(abs), { recursive: true }) + await writeFile(abs, content) + return true + } catch { + return false + } +} diff --git a/loopat/loop/server/src/index.ts b/loopat/loop/server/src/index.ts new file mode 100644 index 00000000..072db114 --- /dev/null +++ b/loopat/loop/server/src/index.ts @@ -0,0 +1,235 @@ +import { Hono } from "hono" +import { cors } from "hono/cors" +import { createBunWebSocket } from "hono/bun" +import { existsSync } from "node:fs" +import { listLoops, createLoop, getLoop, loopExists, backfillAllMounts, ensureWorkspaceDirs } from "./loops" +import { getSession } from "./session" +import { listDir, readWorkdirFile, writeWorkdirFile } from "./files" +import { vaultList, vaultRead, vaultWrite, vaultCreateFile, listRepos, readFocusData, type VaultId } from "./workspace" +import { attachTerm, detachTerm, writeTerm, resizeTerm } from "./term" +import { + LOOPAT_HOME, + WORKSPACE, + loopContextKnowledge, + loopContextNotes, + loopContextPersonal, +} from "./paths" +import { loadConfig, getActiveProvider, configPath } from "./config" + +const { upgradeWebSocket, websocket } = createBunWebSocket() + +const app = new Hono() + +app.use("/api/*", cors()) + +app.get("/api/health", (c) => c.json({ ok: true, loopatHome: LOOPAT_HOME, workspace: WORKSPACE })) + +app.get("/api/loops", async (c) => { + return c.json({ loops: await listLoops() }) +}) + +app.post("/api/loops", async (c) => { + const body = await c.req.json().catch(() => ({})) + const title = typeof body.title === "string" ? body.title : "untitled" + const repo = typeof body.repo === "string" && body.repo.trim() ? body.repo.trim() : undefined + try { + const meta = await createLoop({ title, repo }) + return c.json(meta) + } catch (e: any) { + return c.json({ error: e?.message ?? "create failed" }, 400) + } +}) + +app.get("/api/loops/:id", async (c) => { + const id = c.req.param("id") ?? "" + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + return c.json(meta) +}) + +app.get("/api/loops/:id/context", async (c) => { + const id = c.req.param("id") ?? "" + if (!(await loopExists(id))) return c.json({ error: "not found" }, 404) + const mounts: { name: string; path: string }[] = [] + if (existsSync(loopContextKnowledge(id))) mounts.push({ name: "knowledge", path: "context/knowledge" }) + if (existsSync(loopContextNotes(id))) mounts.push({ name: "notes", path: "context/notes" }) + if (existsSync(loopContextPersonal(id))) mounts.push({ name: "personal", path: "context/personal" }) + return c.json({ mounts }) +}) + +app.get("/api/loops/:id/files", async (c) => { + const id = c.req.param("id") ?? "" + if (!(await loopExists(id))) return c.json({ error: "not found" }, 404) + const path = c.req.query("path") ?? "" + return c.json({ entries: await listDir(id, path) }) +}) + +app.get("/api/loops/:id/file", async (c) => { + const id = c.req.param("id") ?? "" + if (!(await loopExists(id))) return c.json({ error: "not found" }, 404) + const path = c.req.query("path") ?? "" + if (!path) return c.json({ error: "path required" }, 400) + const r = await readWorkdirFile(id, path) + if (!r) return c.json({ error: "not a file or unreadable" }, 404) + return c.json(r) +}) + +app.put("/api/loops/:id/file", async (c) => { + const id = c.req.param("id") ?? "" + if (!(await loopExists(id))) return c.json({ error: "not found" }, 404) + const path = c.req.query("path") ?? "" + if (!path) return c.json({ error: "path required" }, 400) + const body = await c.req.json().catch(() => ({})) + if (typeof body.content !== "string") return c.json({ error: "content required" }, 400) + const ok = await writeWorkdirFile(id, path, body.content) + if (!ok) return c.json({ error: "write failed" }, 500) + return c.json({ ok: true }) +}) + +// Workspace vault APIs (Context tab) +const VAULTS = new Set(["knowledge", "notes", "personal", "repos"]) + +app.get("/api/workspace/files", async (c) => { + const vault = c.req.query("vault") ?? "" + if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400) + const path = c.req.query("path") ?? "" + return c.json({ entries: await vaultList(vault as VaultId, path) }) +}) + +app.get("/api/workspace/file", async (c) => { + const vault = c.req.query("vault") ?? "" + if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400) + const path = c.req.query("path") ?? "" + if (!path) return c.json({ error: "path required" }, 400) + const r = await vaultRead(vault as VaultId, path) + if (!r) return c.json({ error: "not a file" }, 404) + return c.json(r) +}) + +app.put("/api/workspace/file", async (c) => { + const vault = c.req.query("vault") ?? "" + if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400) + const path = c.req.query("path") ?? "" + if (!path) return c.json({ error: "path required" }, 400) + const body = await c.req.json().catch(() => ({})) + if (typeof body.content !== "string") return c.json({ error: "content required" }, 400) + const r = await vaultWrite(vault as VaultId, path, body.content) + if (!r.ok) return c.json({ error: r.error }, 500) + return c.json(r) +}) + +app.post("/api/workspace/file", async (c) => { + const vault = c.req.query("vault") ?? "" + if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400) + const body = await c.req.json().catch(() => ({})) + if (typeof body.path !== "string" || !body.path) return c.json({ error: "path required" }, 400) + const r = await vaultCreateFile(vault as VaultId, body.path) + if (!r.ok) return c.json({ error: r.error }, r.error === "exists" ? 409 : 500) + return c.json({ ok: true }) +}) + +app.get("/api/workspace/repos", async (c) => { + return c.json({ repos: await listRepos() }) +}) + +app.get("/api/workspace/focus", async (c) => { + return c.json(await readFocusData()) +}) + +app.get( + "/ws/loop/:id/term", + upgradeWebSocket(async (c) => { + const id = c.req.param("id") ?? "" + const exists = await loopExists(id) + if (!exists) { + return { + onOpen(_e, ws) { + ws.send(JSON.stringify({ type: "error", message: `loop ${id} not found` })) + ws.close() + }, + } + } + let attachedTerm: any = null + return { + async onOpen(_e, ws) { + attachedTerm = ws + await attachTerm(id, ws) + }, + onMessage(event, _ws) { + try { + const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer) + const msg = JSON.parse(data) + if (msg?.type === "data" && typeof msg.data === "string") writeTerm(id, msg.data) + else if (msg?.type === "resize" && typeof msg.cols === "number" && typeof msg.rows === "number") + resizeTerm(id, msg.cols, msg.rows) + } catch (e) { + console.error("term ws parse", e) + } + }, + onClose() { + if (attachedTerm) detachTerm(id, attachedTerm) + }, + } + }) +) + +app.get( + "/ws/loop/:id", + upgradeWebSocket(async (c) => { + const id = c.req.param("id") ?? "" + const exists = await loopExists(id) + if (!exists) { + return { + onOpen(_e, ws) { + ws.send(JSON.stringify({ type: "error", message: `loop ${id} not found` })) + ws.close() + }, + } + } + const session = getSession(id) + let attached: any = null + return { + async onOpen(_e, ws) { + attached = ws + await session.attach(ws) + }, + onMessage(event, _ws) { + try { + const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer) + const msg = JSON.parse(data) + if (msg?.type === "user" && typeof msg.text === "string") { + session.sendUserText(msg.text) + } else if (msg?.type === "interrupt") { + session.interrupt() + } + } catch (e) { + console.error("ws message parse error", e) + } + }, + onClose() { + if (attached) session.detach(attached) + }, + } + }) +) + +const port = Number(process.env.PORT ?? 7787) +await ensureWorkspaceDirs() +const backfilled = await backfillAllMounts() +const cfg = await loadConfig() +const { name: activeName, provider: activeProvider } = getActiveProvider(cfg) +console.log(`[loopat] server on http://localhost:${port}`) +console.log(`[loopat] data root: ${LOOPAT_HOME}`) +console.log(`[loopat] active workspace: ${WORKSPACE}`) +console.log(`[loopat] config: ${configPath()}`) +console.log(`[loopat] active provider: ${activeName} (model=${activeProvider.model}, baseUrl=${activeProvider.baseUrl})`) +if (!activeProvider.apiKey) { + console.warn(`[loopat] WARNING: provider "${activeName}" has empty apiKey — loops will fail until you fill it in ${configPath()}`) +} +console.log(`[loopat] backfilled context mounts on ${backfilled} loop(s)`) + +export default { + port, + fetch: app.fetch, + websocket, +} diff --git a/loopat/loop/server/src/loops.ts b/loopat/loop/server/src/loops.ts new file mode 100644 index 00000000..8ef8e1a3 --- /dev/null +++ b/loopat/loop/server/src/loops.ts @@ -0,0 +1,153 @@ +import { mkdir, readdir, readFile, writeFile, stat, symlink, lstat, rm } from "node:fs/promises" +import { randomUUID } from "node:crypto" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { existsSync } from "node:fs" +import { + ME, + loopsDir, + loopDir, + loopWorkdir, + loopClaudeDir, + loopContextDir, + loopContextKnowledge, + loopContextNotes, + loopContextPersonal, + loopMetaPath, + workspaceKnowledgeDir, + workspaceNotesDir, + workspaceReposDir, + workspaceRepoDir, + personalDir, +} from "./paths" + +const execFileP = promisify(execFile) + +export type LoopMeta = { + id: string + title: string + createdAt: string + repo?: string + branch?: string +} + +export async function ensureWorkspaceDirs() { + await mkdir(loopsDir(), { recursive: true }) + await mkdir(workspaceKnowledgeDir(), { recursive: true }) + await mkdir(workspaceNotesDir(), { recursive: true }) + await mkdir(workspaceReposDir(), { recursive: true }) + await mkdir(personalDir(ME), { recursive: true }) +} + +async function ensureSymlink(link: string, target: string) { + try { + await lstat(link) + } catch { + await symlink(target, link, "dir") + } +} + +export async function ensureContextMounts(id: string) { + await mkdir(loopContextDir(id), { recursive: true }) + await ensureSymlink(loopContextKnowledge(id), workspaceKnowledgeDir()) + await ensureSymlink(loopContextNotes(id), workspaceNotesDir()) + await ensureSymlink(loopContextPersonal(id), personalDir(ME)) +} + +export async function listLoops(): Promise { + try { + const ids = await readdir(loopsDir()) + const metas = await Promise.all( + ids.map(async (id) => { + try { + const raw = await readFile(loopMetaPath(id), "utf8") + return JSON.parse(raw) as LoopMeta + } catch { + return null + } + }) + ) + return metas.filter((m): m is LoopMeta => m !== null).sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + } catch (e: any) { + if (e?.code === "ENOENT") return [] + throw e + } +} + +async function shortBranchSlug(title: string): Promise { + const base = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32) + return base || "loop" +} + +export async function createLoop(opts: { title: string; repo?: string }): Promise { + await ensureWorkspaceDirs() + const id = randomUUID() + const meta: LoopMeta = { + id, + title: opts.title.trim() || "untitled", + createdAt: new Date().toISOString(), + } + await mkdir(loopDir(id), { recursive: true }) + await mkdir(loopClaudeDir(id), { recursive: true }) + + // workdir = git worktree add (if repo selected) OR plain mkdir + if (opts.repo) { + const repoPath = workspaceRepoDir(opts.repo) + if (!existsSync(repoPath)) { + throw new Error(`repo "${opts.repo}" not found in context/repos/`) + } + const branch = `loop/${(await shortBranchSlug(meta.title))}-${id.slice(0, 6)}` + try { + // best-effort worktree add (creates a new branch off origin/HEAD or current HEAD) + await execFileP("git", ["-C", repoPath, "worktree", "add", "-b", branch, loopWorkdir(id)]) + meta.repo = opts.repo + meta.branch = branch + } catch (e: any) { + // fallback: plain mkdir (let user know) + console.warn(`[loopat] git worktree add failed for repo=${opts.repo}: ${e?.stderr ?? e?.message}`) + await mkdir(loopWorkdir(id), { recursive: true }) + } + } else { + await mkdir(loopWorkdir(id), { recursive: true }) + } + + await ensureContextMounts(id) + await writeFile(loopMetaPath(id), JSON.stringify(meta, null, 2)) + return meta +} + +export async function getLoop(id: string): Promise { + try { + const raw = await readFile(loopMetaPath(id), "utf8") + return JSON.parse(raw) as LoopMeta + } catch { + return null + } +} + +export async function loopExists(id: string): Promise { + try { + const s = await stat(loopDir(id)) + return s.isDirectory() + } catch { + return false + } +} + +export async function backfillAllMounts(): Promise { + let count = 0 + try { + const ids = await readdir(loopsDir()) + for (const id of ids) { + try { + await ensureContextMounts(id) + count++ + } catch {} + } + } catch {} + return count +} diff --git a/loopat/loop/server/src/paths.ts b/loopat/loop/server/src/paths.ts new file mode 100644 index 00000000..7c1bf815 --- /dev/null +++ b/loopat/loop/server/src/paths.ts @@ -0,0 +1,33 @@ +import { homedir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +export const LOOPAT_HOME = process.env.LOOPAT_HOME ?? join(homedir(), ".loopat") + +// loopat code install dir (contains node_modules/, helper binaries the sandbox needs) +// Computed from this file's path: server/src/paths.ts → loop/ +const __DIRNAME = dirname(fileURLToPath(import.meta.url)) +export const LOOPAT_INSTALL_DIR = resolve(__DIRNAME, "../..") + +// Hardcoded for now; future = subdomain-routed multi-workspace. +export const WORKSPACE = "1001" +export const ME = "simpx" + +export const workspaceDir = () => join(LOOPAT_HOME, WORKSPACE) +export const loopsDir = () => join(workspaceDir(), "loops") +export const workspaceContextDir = () => join(workspaceDir(), "context") +export const workspaceKnowledgeDir = () => join(workspaceContextDir(), "knowledge") +export const workspaceNotesDir = () => join(workspaceContextDir(), "notes") +export const workspaceReposDir = () => join(workspaceContextDir(), "repos") +export const workspaceRepoDir = (name: string) => join(workspaceReposDir(), name) +export const personalDir = (user: string) => join(workspaceDir(), "personal", user) + +export const loopDir = (id: string) => join(loopsDir(), id) +export const loopWorkdir = (id: string) => join(loopDir(id), "workdir") +export const loopClaudeDir = (id: string) => join(loopDir(id), ".claude") +export const loopContextDir = (id: string) => join(loopDir(id), "context") +export const loopContextKnowledge = (id: string) => join(loopContextDir(id), "knowledge") +export const loopContextNotes = (id: string) => join(loopContextDir(id), "notes") +export const loopContextPersonal = (id: string) => join(loopContextDir(id), "personal") +export const loopMetaPath = (id: string) => join(loopDir(id), "meta.json") +export const loopHistoryPath = (id: string) => join(loopDir(id), "messages.jsonl") diff --git a/loopat/loop/server/src/personal-deps.ts b/loopat/loop/server/src/personal-deps.ts new file mode 100644 index 00000000..b563c1e9 --- /dev/null +++ b/loopat/loop/server/src/personal-deps.ts @@ -0,0 +1,33 @@ +import { readdir, lstat, realpath } from "node:fs/promises" +import { join } from "node:path" +import { personalDir, ME } from "./paths" + +/** + * Walk personal// for symlinks and return resolved targets. + * These targets are added to sandbox allowRead/allowWrite — i.e., the + * mechanism that lets a loop see external files (ssh keys, tool configs). + * + * See memory: project_loop_dir_is_sandbox.md + */ +export async function resolvePersonalDeps(): Promise { + const dir = personalDir(ME) + const out: string[] = [] + let entries: string[] = [] + try { + entries = await readdir(dir) + } catch { + return [] + } + for (const name of entries) { + const full = join(dir, name) + try { + const st = await lstat(full) + if (!st.isSymbolicLink()) continue + const target = await realpath(full) + out.push(target) + } catch { + // broken symlink / permission denied — skip + } + } + return out +} diff --git a/loopat/loop/server/src/sandbox.ts b/loopat/loop/server/src/sandbox.ts new file mode 100644 index 00000000..cd35665c --- /dev/null +++ b/loopat/loop/server/src/sandbox.ts @@ -0,0 +1,72 @@ +import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime" +import { homedir } from "node:os" +import { + ME, + loopWorkdir, + loopClaudeDir, + loopContextDir, + workspaceKnowledgeDir, + workspaceNotesDir, + personalDir, + LOOPAT_INSTALL_DIR, +} from "./paths" +import { resolvePersonalDeps } from "./personal-deps" + +let initPromise: Promise | null = null + +function emptyConfig(): SandboxRuntimeConfig { + return { + network: { allowedDomains: ["*"], deniedDomains: [] }, + filesystem: { allowWrite: [], denyWrite: [], denyRead: [], allowRead: [] }, + } as SandboxRuntimeConfig +} + +async function loopConfig(loopId: string): Promise { + const home = homedir() + const personalDeps = await resolvePersonalDeps() + return { + network: { allowedDomains: ["*"], deniedDomains: [] }, + filesystem: { + denyRead: [home], + allowRead: [ + LOOPAT_INSTALL_DIR, + loopContextDir(loopId), + workspaceKnowledgeDir(), + ...personalDeps, + ], + allowWrite: [ + loopWorkdir(loopId), + loopClaudeDir(loopId), + workspaceNotesDir(), + personalDir(ME), + ...personalDeps, + ], + denyWrite: [], + }, + } as SandboxRuntimeConfig +} + +export async function ensureSandboxInitialized(): Promise { + if (initPromise) return initPromise + initPromise = (async () => { + if (!SandboxManager.isSupportedPlatform()) { + console.warn("[loopat] sandbox: unsupported platform, PTY will run unsandboxed") + return + } + await SandboxManager.initialize(emptyConfig()) + const dep = SandboxManager.checkDependencies() + if (dep.errors.length > 0) { + console.warn("[loopat] sandbox missing deps:", dep.errors.join(", ")) + } else { + console.log("[loopat] sandbox runtime initialized (bwrap + socat ok)") + if (dep.warnings.length > 0) console.warn("[loopat] sandbox warnings:", dep.warnings.join(", ")) + } + })() + return initPromise +} + +export async function wrapForLoop(command: string, loopId: string): Promise { + await ensureSandboxInitialized() + if (!SandboxManager.isSandboxingEnabled()) return command + return SandboxManager.wrapWithSandbox(command, "/bin/bash", await loopConfig(loopId)) +} diff --git a/loopat/loop/server/src/session.ts b/loopat/loop/server/src/session.ts new file mode 100644 index 00000000..1641471b --- /dev/null +++ b/loopat/loop/server/src/session.ts @@ -0,0 +1,281 @@ +import { query, type Query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk" +import type { WSContext } from "hono/ws" +import { appendFile, readFile, readdir } from "node:fs/promises" +import { join } from "node:path" +import { homedir } from "node:os" +import { + ME, + loopWorkdir, + loopClaudeDir, + loopContextDir, + loopContextKnowledge, + loopContextNotes, + loopContextPersonal, + loopHistoryPath, + workspaceKnowledgeDir, + workspaceNotesDir, + personalDir, + LOOPAT_INSTALL_DIR, +} from "./paths" +import { existsSync } from "node:fs" +import { resolveClaudeBinary } from "./claude-binary" +import { loadConfig, getActiveProvider } from "./config" +import { resolvePersonalDeps } from "./personal-deps" + +const CLAUDE_BINARY = resolveClaudeBinary() + +function pushIterable() { + const queue: T[] = [] + let resolver: ((v: IteratorResult) => void) | null = null + let done = false + + const iter: AsyncIterableIterator = { + [Symbol.asyncIterator]() { + return this + }, + next(): Promise> { + if (queue.length > 0) { + return Promise.resolve({ value: queue.shift()!, done: false }) + } + if (done) { + return Promise.resolve({ value: undefined as any, done: true }) + } + return new Promise((r) => { + resolver = r + }) + }, + return(value?: any): Promise> { + done = true + return Promise.resolve({ value, done: true }) + }, + } + + return { + push(v: T) { + if (done) return + if (resolver) { + const r = resolver + resolver = null + r({ value: v, done: false }) + } else { + queue.push(v) + } + }, + end() { + done = true + if (resolver) { + const r = resolver + resolver = null + r({ value: undefined as any, done: true }) + } + }, + iter, + } +} + +async function hasPriorSdkSession(loopId: string): Promise { + const projectsDir = join(loopClaudeDir(loopId), "projects") + try { + const projects = await readdir(projectsDir) + for (const p of projects) { + const files = await readdir(join(projectsDir, p)) + if (files.some((f) => f.endsWith(".jsonl"))) return true + } + } catch {} + return false +} + +type SubscriberState = { pending: any[] | null } + +class LoopSession { + id: string + private q: Query | null = null + private input = pushIterable() + private subscribers = new Map() + private history: SDKMessage[] = [] + private historyLoaded: Promise + + constructor(id: string) { + this.id = id + this.historyLoaded = this.loadHistoryFromDisk() + } + + private async loadHistoryFromDisk() { + try { + const raw = await readFile(loopHistoryPath(this.id), "utf8") + for (const line of raw.split("\n")) { + if (!line) continue + try { + this.history.push(JSON.parse(line)) + } catch {} + } + } catch {} + } + + private async ensureStarted() { + if (this.q) return + const shouldContinue = await hasPriorSdkSession(this.id) + const cfg = await loadConfig() + const { name: providerName, provider } = getActiveProvider(cfg) + if (!provider.apiKey) { + throw new Error(`config.json: provider "${providerName}" has empty apiKey — fill it in and restart`) + } + + const workdir = loopWorkdir(this.id) + const claudeDir = loopClaudeDir(this.id) + const additionalDirectories: string[] = [] + for (const p of [loopContextKnowledge(this.id), loopContextNotes(this.id), loopContextPersonal(this.id)]) { + if (existsSync(p)) additionalDirectories.push(p) + } + const personalDeps = await resolvePersonalDeps() + const home = homedir() + + this.q = query({ + prompt: this.input.iter, + options: { + cwd: workdir, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: claudeDir, + ANTHROPIC_API_KEY: provider.apiKey, + ANTHROPIC_BASE_URL: provider.baseUrl, + }, + model: provider.model, + stderr: (s) => console.error(`[sdk:${this.id.slice(0, 8)}] ${s.trimEnd()}`), + pathToClaudeCodeExecutable: CLAUDE_BINARY, + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + settingSources: [], + additionalDirectories, + sandbox: { + enabled: true, + failIfUnavailable: true, + autoAllowBashIfSandboxed: true, + allowUnsandboxedCommands: false, + filesystem: { + denyRead: [home], + allowRead: [ + LOOPAT_INSTALL_DIR, // sandbox helpers (apply-seccomp, claude binary) + loopContextDir(this.id), // context/ for symlink visibility (don't include loopDir — it'd ro-clobber workdir) + workspaceKnowledgeDir(), // knowledge symlink target — read-only + ...personalDeps, + ], + allowWrite: [ + workdir, + claudeDir, + workspaceNotesDir(), // notes symlink target — rw + personalDir(ME), // personal symlink target — rw + ...personalDeps, + ], + }, + }, + ...(shouldContinue ? { continue: true } : {}), + }, + }) + this.consume(this.q) + } + + private async consume(q: Query) { + try { + for await (const msg of q) { + this.history.push(msg) + this.persist(msg) + this.broadcast(msg) + } + } catch (e: any) { + const err = { type: "error", message: e?.message ?? String(e) } + this.history.push(err as any) + this.persist(err) + this.broadcast(err) + } + } + + private persist(msg: any) { + appendFile(loopHistoryPath(this.id), JSON.stringify(msg) + "\n").catch((e) => { + console.error("[loopat] persist failed", e) + }) + } + + private broadcast(msg: any) { + const data = JSON.stringify(msg) + for (const [ws, state] of this.subscribers) { + if (state.pending !== null) { + state.pending.push(msg) + continue + } + try { + ws.send(data) + } catch {} + } + } + + private broadcastViewers() { + const msg = { type: "viewers", count: this.subscribers.size } + const data = JSON.stringify(msg) + for (const [ws, state] of this.subscribers) { + if (state.pending !== null) continue + try { + ws.send(data) + } catch {} + } + } + + async attach(ws: WSContext) { + await this.historyLoaded + const state: SubscriberState = { pending: [] } + this.subscribers.set(ws, state) + const snapshot = this.history.slice() + for (const m of snapshot) { + try { + ws.send(JSON.stringify(m)) + } catch {} + } + if (state.pending) { + for (const m of state.pending) { + try { + ws.send(JSON.stringify(m)) + } catch {} + } + state.pending = null + } + try { + ws.send(JSON.stringify({ type: "history_end" })) + } catch {} + this.broadcastViewers() + console.log(`[loop:${this.id.slice(0, 8)}] attach → viewers=${this.subscribers.size}`) + } + + detach(ws: WSContext) { + this.subscribers.delete(ws) + this.broadcastViewers() + console.log(`[loop:${this.id.slice(0, 8)}] detach → viewers=${this.subscribers.size}`) + } + + async sendUserText(text: string) { + await this.ensureStarted() + const userMsg: SDKUserMessage = { + type: "user", + message: { role: "user", content: text }, + parent_tool_use_id: null, + } + this.history.push(userMsg) + this.persist(userMsg) + this.broadcast(userMsg) + this.input.push(userMsg) + } + + async interrupt() { + if (this.q) await this.q.interrupt().catch(() => {}) + } +} + +const sessions = new Map() + +export function getSession(id: string): LoopSession { + let s = sessions.get(id) + if (!s) { + s = new LoopSession(id) + sessions.set(id, s) + } + return s +} diff --git a/loopat/loop/server/src/term.ts b/loopat/loop/server/src/term.ts new file mode 100644 index 00000000..386e5abe --- /dev/null +++ b/loopat/loop/server/src/term.ts @@ -0,0 +1,91 @@ +import { spawn, type IPty } from "bun-pty" +import type { WSContext } from "hono/ws" +import { loopWorkdir } from "./paths" +import { wrapForLoop } from "./sandbox" + +type Term = { + proc: IPty + subscribers: Set +} + +const terms = new Map() +const pending = new Map>() + +async function getOrSpawn(loopId: string): Promise { + const existing = terms.get(loopId) + if (existing) return existing + const inflight = pending.get(loopId) + if (inflight) return inflight + + const p = (async () => { + const workdir = loopWorkdir(loopId) + const innerShell = process.env.SHELL ?? "/bin/bash" + const wrappedCmd = await wrapForLoop(`${innerShell} -i`, loopId) + const proc = spawn("/bin/bash", ["-c", wrappedCmd], { + name: "xterm-256color", + cols: 80, + rows: 24, + cwd: workdir, + env: { ...process.env, TERM: "xterm-256color" } as Record, + }) + const t: Term = { proc, subscribers: new Set() } + terms.set(loopId, t) + + proc.onData((chunk) => { + for (const ws of t.subscribers) { + try { + ws.send(JSON.stringify({ type: "data", data: chunk })) + } catch {} + } + }) + proc.onExit(({ exitCode }) => { + for (const ws of t.subscribers) { + try { + ws.send(JSON.stringify({ type: "exit", code: exitCode })) + ws.close() + } catch {} + } + terms.delete(loopId) + }) + + return t + })() + + pending.set(loopId, p) + try { + return await p + } finally { + pending.delete(loopId) + } +} + +export async function attachTerm(loopId: string, ws: WSContext) { + const t = await getOrSpawn(loopId) + t.subscribers.add(ws) +} + +export function detachTerm(loopId: string, ws: WSContext) { + const t = terms.get(loopId) + if (!t) return + t.subscribers.delete(ws) + if (t.subscribers.size === 0) { + try { + t.proc.kill() + } catch {} + terms.delete(loopId) + } +} + +export function writeTerm(loopId: string, data: string) { + const t = terms.get(loopId) + if (!t) return + t.proc.write(data) +} + +export function resizeTerm(loopId: string, cols: number, rows: number) { + const t = terms.get(loopId) + if (!t) return + try { + t.proc.resize(cols, rows) + } catch {} +} diff --git a/loopat/loop/server/src/workspace.ts b/loopat/loop/server/src/workspace.ts new file mode 100644 index 00000000..b529d688 --- /dev/null +++ b/loopat/loop/server/src/workspace.ts @@ -0,0 +1,235 @@ +/** + * Workspace-level file APIs for Context tab vaults (knowledge / notes / + * personal / repos). Auto-commits on write per user's design: + * "每次修改自动 commit, log 记录动作"。 + */ +import { readdir, readFile, writeFile, stat, mkdir } from "node:fs/promises" +// Re-using readFile for parsing focus/inbox markdown. +import { existsSync } from "node:fs" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { join, normalize, relative, sep, dirname } from "node:path" +import { + workspaceKnowledgeDir, + workspaceNotesDir, + workspaceReposDir, + personalDir, + ME, +} from "./paths" + +const execFileP = promisify(execFile) + +export type VaultId = "knowledge" | "notes" | "personal" | "repos" + +export type VaultEntry = { + name: string + path: string + type: "file" | "dir" + size?: number +} + +export function vaultRoot(vault: VaultId): string { + switch (vault) { + case "knowledge": + return workspaceKnowledgeDir() + case "notes": + return workspaceNotesDir() + case "personal": + return personalDir(ME) + case "repos": + return workspaceReposDir() + } +} + +function safeJoin(rootAbs: string, rel: string): string | null { + const candidate = normalize(join(rootAbs, rel)) + const insideRel = relative(rootAbs, candidate) + if (insideRel.startsWith("..") || insideRel.startsWith("/" + sep)) return null + return candidate +} + +const SKIP_DIRS = new Set(["node_modules", ".git", ".bun"]) + +export async function vaultList(vault: VaultId, relPath: string): Promise { + const root = vaultRoot(vault) + const abs = safeJoin(root, relPath) + if (!abs) return [] + let names: string[] = [] + try { + names = await readdir(abs) + } catch { + return [] + } + const out: VaultEntry[] = [] + for (const name of names) { + if (SKIP_DIRS.has(name)) continue + if (name === ".git" || name === ".DS_Store") continue + const childRel = relPath ? `${relPath}/${name}` : name + let isDir = false + let size: number | undefined + try { + const s = await stat(join(abs, name)) + isDir = s.isDirectory() + if (!isDir) size = s.size + } catch { + continue + } + out.push({ name, path: childRel, type: isDir ? "dir" : "file", size }) + } + out.sort((a, b) => { + if (a.type !== b.type) return a.type === "dir" ? -1 : 1 + return a.name.localeCompare(b.name) + }) + return out +} + +const MAX_BYTES = 1024 * 1024 + +export async function vaultRead(vault: VaultId, relPath: string): Promise<{ content: string; size: number; truncated: boolean } | null> { + const root = vaultRoot(vault) + const abs = safeJoin(root, relPath) + if (!abs) return null + try { + const s = await stat(abs) + if (!s.isFile()) return null + const truncated = s.size > MAX_BYTES + const buf = await readFile(abs) + const slice = truncated ? buf.subarray(0, MAX_BYTES) : buf + return { content: slice.toString("utf8"), size: s.size, truncated } + } catch { + return null + } +} + +export async function vaultWrite( + vault: VaultId, + relPath: string, + content: string, +): Promise<{ ok: boolean; commit?: string; error?: string }> { + const root = vaultRoot(vault) + const abs = safeJoin(root, relPath) + if (!abs) return { ok: false, error: "path escapes root" } + try { + await mkdir(dirname(abs), { recursive: true }) + await writeFile(abs, content) + } catch (e: any) { + return { ok: false, error: e?.message ?? "write failed" } + } + // auto-commit if root is a git repo + if (existsSync(join(root, ".git"))) { + try { + const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z") + const env = { ...process.env, GIT_AUTHOR_NAME: "loopat", GIT_AUTHOR_EMAIL: "auto@loopat.local", GIT_COMMITTER_NAME: "loopat", GIT_COMMITTER_EMAIL: "auto@loopat.local" } + await execFileP("git", ["-C", root, "add", "--", relPath], { env }) + const { stdout } = await execFileP( + "git", + ["-C", root, "commit", "-m", `${relPath}: ${ts}`, "--allow-empty"], + { env }, + ) + const m = stdout.match(/\b([0-9a-f]{7,})\b/) + return { ok: true, commit: m?.[1] } + } catch (e: any) { + // file written but commit failed (e.g., no changes); still success + return { ok: true, error: e?.stderr ?? e?.message } + } + } + return { ok: true } +} + +export async function vaultCreateFile(vault: VaultId, relPath: string): Promise<{ ok: boolean; error?: string }> { + const root = vaultRoot(vault) + const abs = safeJoin(root, relPath) + if (!abs) return { ok: false, error: "path escapes root" } + if (existsSync(abs)) return { ok: false, error: "exists" } + try { + await mkdir(dirname(abs), { recursive: true }) + await writeFile(abs, "") + } catch (e: any) { + return { ok: false, error: e?.message } + } + return { ok: true } +} + +export type RepoEntry = { + name: string + path: string + remote?: string +} + +export type FocusData = { + pinned: string[] + listed: string[] + inbox: string[] +} + +function parseList(body: string): string[] { + return body + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.startsWith("- ")) + .map((l) => l.slice(2).trim()) + .filter(Boolean) +} + +function parseSections(body: string): { pinned: string[]; listed: string[] } { + // Split on `## ` headers; collect entries under "pinned" and "listed" + const lines = body.split("\n") + let current: string | null = null + const sections = new Map() + for (const line of lines) { + const m = line.match(/^##\s+(\w+)/) + if (m) { + current = m[1].toLowerCase() + if (!sections.has(current)) sections.set(current, []) + continue + } + if (current) { + const t = line.trim() + if (t.startsWith("- ")) { + const name = t.slice(2).trim().split("—")[0].trim() + if (name) sections.get(current)!.push(name) + } + } + } + return { + pinned: sections.get("pinned") ?? [], + listed: sections.get("listed") ?? [], + } +} + +export async function readFocusData(): Promise { + const focusBody = await readFile(join(workspaceNotesDir(), "focus.md"), "utf8").catch(() => "") + const inboxBody = await readFile(join(workspaceNotesDir(), "inbox.md"), "utf8").catch(() => "") + const { pinned, listed } = parseSections(focusBody) + const inbox = parseList(inboxBody) + return { pinned, listed, inbox } +} + +export async function listRepos(): Promise { + const root = workspaceReposDir() + let names: string[] = [] + try { + names = await readdir(root) + } catch { + return [] + } + const out: RepoEntry[] = [] + for (const name of names) { + const p = join(root, name) + let target = p + try { + const s = await stat(p) + if (!s.isDirectory()) continue + target = p + } catch { + continue + } + let remote: string | undefined + try { + const { stdout } = await execFileP("git", ["-C", target, "remote", "get-url", "origin"]) + remote = stdout.trim() + } catch {} + out.push({ name, path: target, remote }) + } + return out +} diff --git a/loopat/loop/server/tsconfig.json b/loopat/loop/server/tsconfig.json new file mode 100644 index 00000000..ecc686dd --- /dev/null +++ b/loopat/loop/server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/loopat/loop/web/components.json b/loopat/loop/web/components.json new file mode 100644 index 00000000..9bcd7aca --- /dev/null +++ b/loopat/loop/web/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@assistant-ui": "https://r.assistant-ui.com/{name}.json" + } +} diff --git a/loopat/loop/web/index.html b/loopat/loop/web/index.html new file mode 100644 index 00000000..e83cacaf --- /dev/null +++ b/loopat/loop/web/index.html @@ -0,0 +1,12 @@ + + + + + + loopat + + +
+ + + diff --git a/loopat/loop/web/package.json b/loopat/loop/web/package.json new file mode 100644 index 00000000..58f31a4f --- /dev/null +++ b/loopat/loop/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "@loopat/web", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@assistant-ui/react": "^0.14.0", + "@assistant-ui/react-markdown": "^0.14.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.14.0", + "radix-ui": "^1.4.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.15.0", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.5.0", + "tw-shimmer": "^0.4.11", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.3.0", + "typescript": "^5.6.0", + "vite": "^8.0.11" + } +} diff --git a/loopat/loop/web/src/App.tsx b/loopat/loop/web/src/App.tsx new file mode 100644 index 00000000..f6588bdb --- /dev/null +++ b/loopat/loop/web/src/App.tsx @@ -0,0 +1,187 @@ +/** + * Top-level shell. Ports phase1-prototype/src/App.tsx layout literally — + * top bar (brand + tab nav + new-loop + user widget) + for the + * current tab's page. Routing via react-router v7. + */ +import { useEffect, useRef, useState } from "react" +import { BrowserRouter, Routes, Route, Navigate, NavLink, Outlet, useNavigate } from "react-router-dom" +import { TooltipProvider } from "@/components/ui/tooltip" +import { useWorkspaceState, type WorkspaceState } from "./state" +import { WorkspaceCtx } from "./ctx" +import { NewLoopDialog } from "./components/dialog/NewLoopDialog" +import { LoopPage } from "./pages/LoopPage" +import { FocusPage } from "./pages/FocusPage" +import { ContextPage } from "./pages/ContextPage" + +const WORKSPACE_NAME = "loopat" +const WORKSPACE_ID = "1001" +const ME = "simpx" + +const TABS = [ + { id: "loop", label: "Loop", icon: "⑂" }, + { id: "focus", label: "Focus", icon: "◉" }, + { id: "context", label: "Context", icon: "⌘" }, +] as const + +function Layout() { + const ws = useWorkspaceState() + return ( + + + + ) +} + +function Shell({ ws }: { ws: WorkspaceState }) { + const navigate = useNavigate() + const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false) + const workspaceRef = useRef(null) + + useEffect(() => { + const onDocClick = (e: MouseEvent) => { + if (workspaceRef.current && !workspaceRef.current.contains(e.target as Node)) { + setWorkspaceMenuOpen(false) + } + } + document.addEventListener("click", onDocClick) + return () => document.removeEventListener("click", onDocClick) + }, []) + + const handleCreate = async (opts: { title: string; repo?: string }) => { + const m = await ws.createLoop(opts) + ws.setNewLoopDialogOpen(false) + navigate(`/loop/${m.id}`) + return m.id + } + + return ( +
+
+
+ + {workspaceMenuOpen && ( +
+
+
+ 🧶 +
+
+ {WORKSPACE_NAME} · {WORKSPACE_ID} +
+
single-user
+
+
+
+ +
+ )} +
+ +
+ + +
+
+ +
+ {ws.newLoopDialogOpen && ( + ws.setNewLoopDialogOpen(false)} onCreate={handleCreate} /> + )} +
+ ) +} + +function LoopRedirect() { + const ws = useWorkspaceState() + if (ws.loops.length === 0) return ws.setNewLoopDialogOpen(true)} /> + return +} + +function LoopEmpty({ onNew }: { onNew: () => void }) { + return ( +
+
no loops yet
+ +
+ ) +} + +export function App() { + return ( + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) +} diff --git a/loopat/loop/web/src/Editor.tsx b/loopat/loop/web/src/Editor.tsx new file mode 100644 index 00000000..bdc186e7 --- /dev/null +++ b/loopat/loop/web/src/Editor.tsx @@ -0,0 +1,80 @@ +/** + * Right-panel editor mode. Layout follows phase1-prototype: + * + * + * + * v5: textarea + monospace. Switch to CodeMirror later. + */ +import { useEffect, useState } from "react" +import { readFile, writeFile } from "./api" + +export function Editor({ loopId, path }: { loopId: string; path: string | null }) { + const [original, setOriginal] = useState("") + const [draft, setDraft] = useState("") + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!path) { + setOriginal("") + setDraft("") + return + } + setLoading(true) + readFile(loopId, path) + .then((r) => { + const c = r?.content ?? "" + setOriginal(c) + setDraft(c) + }) + .finally(() => setLoading(false)) + }, [loopId, path]) + + const dirty = path && draft !== original + const save = async () => { + if (!path || saving) return + setSaving(true) + try { + const ok = await writeFile(loopId, path, draft) + if (ok) setOriginal(draft) + } finally { + setSaving(false) + } + } + + if (!path) { + return ( +
+ 没打开文件 · 在 ▤ workdir 里点一个 +
+ ) + } + + return ( + <> +
+