diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..1216eb14 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +node_modules +node_modules/ +*/node_modules +*/node_modules/ +*/*/node_modules +*/*/node_modules/ +*/*/*/node_modules +*/*/*/node_modules/ +.git/ +.git +dist/ +dist +.vite/ +.vite +*.tsbuildinfo +*.log +.env +.env.local +.DS_Store +loopat-data/ +loopat-data +server/src/*.js +server/src/*.js.map diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..68c1e688 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: cd server && bun test diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..e8b3223d --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,52 @@ +name: Publish Docker image + +# Build and push the Docker image to GHCR on a version tag (same v* tags +# that trigger the npm publish). Manually runnable via workflow_dispatch. +# +# Uses the built-in GITHUB_TOKEN — no extra secret needed. The image lands +# at ghcr.io//, e.g. ghcr.io/simpx/loopat. + +# Opt all JS actions into the Node 24 runtime (Node 20 is deprecated). +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v5 + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + provenance: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..e8713d6a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +name: Publish to npm + +# Publish loopat to npm when a version tag is pushed. +# +# Release flow: +# npm version patch # bumps package.json, commits, tags vX.Y.Z +# git push origin main --tags +# +# Setup (one time): add the npm Automation token (bypasses 2FA) as a repo +# secret named NPM_TOKEN — Settings -> Secrets and variables -> Actions. + +# Opt all JS actions into the Node 24 runtime (Node 20 is deprecated). +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + environment: release + steps: + - uses: actions/checkout@v5 + + # Bun is needed to build web/dist (vite) during prepublishOnly. + - uses: oven-sh/setup-bun@v2 + + # Node provides npm and wires NODE_AUTH_TOKEN into the registry. + - uses: actions/setup-node@v5 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + # prepublishOnly runs `bun install && bun run build:web` for us. + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/sandbox-image.yml b/.github/workflows/sandbox-image.yml new file mode 100644 index 00000000..c3980865 --- /dev/null +++ b/.github/workflows/sandbox-image.yml @@ -0,0 +1,72 @@ +name: Publish sandbox image to GHCR + +# Build the loopat sandbox base image and push it to GHCR so end users pull a +# ready 700MB+ image instead of apt-installing ~150 packages over a flaky China +# mirror at first run. Tagged by the Containerfile content hash (must match +# baseContainerfileHash() in server/src/podman.ts) so loopat can pull the exact +# image its Containerfile describes; also tagged : and :latest. +# +# Multi-arch (amd64 + arm64): macs are arm64, most linux servers amd64. The +# arm64 leg builds under QEMU emulation — slow (apt + mise can take 10-20 min), +# but this only runs at release time. +# +# One-time setup: after the first successful run, make the GHCR package PUBLIC +# (GitHub -> repo -> Packages -> loopat-sandbox -> Package settings -> Change +# visibility -> Public). Anonymous pulls fail until then, and loopat silently +# falls back to a local build. + +on: + push: + tags: + - "v*" + workflow_dispatch: + +env: + IMAGE: ghcr.io/${{ github.repository_owner }}/loopat-sandbox + +jobs: + publish-sandbox: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Compute Containerfile content hash + id: hash + run: | + cd server/templates/sandbox + # Must match baseContainerfileHash(): sha256(Containerfile || loopat-host), first 16 hex. + H=$(cat Containerfile loopat-host | sha256sum | cut -c1-16) + echo "hash=$H" >> "$GITHUB_OUTPUT" + echo "sandbox content hash: $H" + + - name: Derive version tag + id: ver + env: + REF_NAME: ${{ github.ref_name }} + run: echo "version=${REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push (amd64 + arm64) + uses: docker/build-push-action@v6 + with: + context: server/templates/sandbox + file: server/templates/sandbox/Containerfile + platforms: linux/amd64,linux/arm64 + push: true + provenance: false + tags: | + ${{ env.IMAGE }}:${{ steps.hash.outputs.hash }} + ${{ env.IMAGE }}:${{ steps.ver.outputs.version }} + ${{ env.IMAGE }}:latest diff --git a/.gitignore b/.gitignore index eb9d3a4c..8034494b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,43 @@ -# Nested repos with their own histories -claw-code/ -loop/ -opencode/ -loopat-ts/ +node_modules/ +dist/ +.vite/ +.run/ +.env +.env.local +*.log +.DS_Store +bun.lock +*.tsbuildinfo +test-results/ +playwright-report/ +e2e/.auth.json +e2e/.test-meta.json +dogfood/.auth.json +dogfood/.test-meta.json +dogfood/first-run/.test-meta.json +dogfood/sync/.test-meta.json +dogfood/sync/.authA.json +dogfood/sync/.authB.json +server/src/*.js +server/src/*.js.map + +# Rust +**/target/ + +# Python +__pycache__/ +*.pyc # Editor swap / temp files .*.swp .*.swo *~ -# JS build artifacts -node_modules/ -dist/ -.vite/ +# Misc +*.tmp + +# Internal git-host provider extension + its installer — NOT open-source. +# They carry internal hosts/URLs and are distributed out-of-band (OSS), so they +# must never land in this repo. (Drop into LOOPAT_HOME/extensions/providers/.) +/code.ts +/install.sh diff --git a/CLA.md b/CLA.md new file mode 100644 index 00000000..1c881191 --- /dev/null +++ b/CLA.md @@ -0,0 +1,114 @@ +# loopat Individual Contributor License Agreement + +Thank you for your interest in contributing to loopat (the "Project"), +maintained by simpx ("we", "us", or the "Project Maintainer"). + +This Contributor License Agreement ("Agreement") sets out the terms under +which You ("You" or "Contributor") license Your Contributions to us. It is +modeled on the [Apache Software Foundation Individual CLA v2.2][apache-icla] +and uses the same definitions and structure, simplified for an +individual-maintainer project that may, in the future, transfer ownership to +a successor entity (such as a company or foundation). + +By submitting a Contribution to the Project (for example, by opening a +pull request on GitHub and signing the CLA via [CLA Assistant][cla-assistant]), +You accept and agree to the following terms for Your present and future +Contributions. + +[apache-icla]: https://www.apache.org/licenses/contributor-agreements.html +[cla-assistant]: https://cla-assistant.io/ + +--- + +## 1. Definitions + +- **"You"** means the individual who Submits a Contribution to Us. +- **"Contribution"** means any original work of authorship, including any + modifications or additions to existing work, that You intentionally Submit + to Us for inclusion in the Project. +- **"Submit"** means any form of communication sent to Us or our + representatives — including but not limited to pull requests, patches, + issues, mailing lists, and chat — for the purpose of discussing or + improving the Project, except communication that You conspicuously mark + as "Not a Contribution". +- **"Successor"** means any individual or legal entity to whom We assign or + transfer ownership of the Project, in whole or in part. The rights granted + in this Agreement extend to Us and any Successor. + +## 2. Grant of Copyright License + +You grant to Us and to any Successor a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable copyright license to reproduce, prepare +derivative works of, publicly display, publicly perform, sublicense, and +distribute Your Contributions and such derivative works **under any license +terms We choose**, including but not limited to the Apache License 2.0 and +any later commercial, copyleft, or source-available license. + +This sublicensing right is the key purpose of this Agreement: it allows Us +(or any Successor) to relicense the Project as a whole in the future without +having to re-collect permission from every Contributor. + +## 3. Grant of Patent License + +You grant to Us, to any Successor, and to recipients of the Project +distributed by Us, a perpetual, worldwide, non-exclusive, no-charge, +royalty-free, irrevocable (except as stated in this section) patent license +to make, have made, use, offer to sell, sell, import, and otherwise transfer +the Project. This license applies only to those patent claims licensable by +You that are necessarily infringed by Your Contribution alone or by +combination of Your Contribution with the Project. + +If any entity institutes patent litigation against You or any other entity +(including a cross-claim or counterclaim in a lawsuit) alleging that Your +Contribution, or the Project to which You have Contributed, constitutes +direct or contributory patent infringement, then any patent licenses +granted to that entity under this Agreement for that Contribution or +Project shall terminate as of the date such litigation is filed. + +## 4. You Are Legally Entitled to Grant These Licenses + +You represent that: + +- Each of Your Contributions is Your original creation, **or** +- Your Contribution includes complete details of any third-party license or + other restriction (including, but not limited to, related patents and + trademarks) of which You are personally aware and which are associated + with any part of Your Contribution. +- If Your employer has rights to intellectual property You create that + includes Your Contributions, You have received permission to make + Contributions on behalf of that employer, or Your employer has waived such + rights for Your Contributions to the Project. + +## 5. No Warranty + +You provide Your Contributions on an "AS IS" basis, without warranties or +conditions of any kind, either express or implied, including, without +limitation, any warranties of title, non-infringement, merchantability, or +fitness for a particular purpose. You are not expected to provide support +for Your Contributions, except to the extent You desire to provide support. + +## 6. Notification of Changes + +You agree to notify Us of any facts or circumstances of which You become +aware that would make the representations in Section 4 inaccurate in any +respect. + +## 7. Public Record + +You acknowledge that We will publicly record the fact that You have signed +this Agreement (typically via [CLA Assistant][cla-assistant]) alongside +Your GitHub username, so that other Contributors and users can verify that +Your Contributions are properly licensed. + +--- + +## How to sign + +Open any pull request against . The +CLA Assistant bot will comment with a one-click link to sign this +Agreement. Once signed, all Your present and future Contributions to the +Project are covered — You only need to sign once. + +If You prefer, You may also sign by sending an email to +**simpxx@gmail.com** with the subject `loopat CLA: ` +and the text "I agree to the loopat CLA, version 1.0" in the body. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..c0e6bc30 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +FROM oven/bun:1-slim AS base + +RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.tuna.tsinghua.edu.cn/debian|g' /etc/apt/sources.list.d/debian.sources \ + && sed -i 's|http://security.debian.org|http://mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list.d/debian.sources \ + && apt-get update && apt-get install -y --no-install-recommends \ + podman uidmap catatonit openssh-client git git-crypt ca-certificates fish vim sudo curl \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -r loopat \ + && useradd -m -g loopat -G sudo -s /bin/bash loopat \ + && echo "loopat ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/loopat \ + && chmod 0440 /etc/sudoers.d/loopat + +# Rootless podman needs subuid/subgid mappings for the loopat user. +RUN echo "loopat:100000:65536" >> /etc/subuid \ + && echo "loopat:100000:65536" >> /etc/subgid + +# Install mise (dev tool version manager) +RUN curl -fsSL https://mise.run | MISE_INSTALL_DIR=/usr/local/bin sh + +WORKDIR /app + +FROM base AS build +COPY . . +# Full workspace install so web/node_modules (vite, the pinned TypeScript) +# is in place — build:web's `bunx tsc/vite` must resolve the project's +# tools, not download newer ones. +RUN bun install --frozen-lockfile +RUN bun run build:web + +FROM base AS release +COPY package.json bun.lock* ./ +COPY server/package.json server/ +COPY web/package.json web/ +RUN bun install --frozen-lockfile --production +COPY server server +COPY --from=build /app/web/dist web/dist + +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV LOOPAT_SERVE_HOST=0.0.0.0 + +RUN chown -R loopat:loopat /app + +# Pre-create the data dir owned by loopat so the VOLUME (and any named +# volume mounted onto it) initializes writable by the non-root user. +RUN mkdir -p /home/loopat/.loopat && chown loopat:loopat /home/loopat/.loopat + +USER loopat + +EXPOSE 10001 7788 + +VOLUME ["/home/loopat/.loopat"] + +CMD ["bun", "run", "server/src/index.ts"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..578e2598 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may accept and charge a + fee for, acceptance of support, warranty, indemnity, or other + liability obligations and/or rights consistent with this License. + However, in accepting such obligations, You may act only on Your + own behalf and on Your sole responsibility, not on behalf of any + other Contributor, and only if You agree to indemnify, defend, + and hold each Contributor harmless for any liability incurred by, + or claims asserted against, such Contributor by reason of your + accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 simpx and loopat contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing + permissions and limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..996e29f7 --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +loopat +Copyright 2026 simpx and loopat contributors + +This product includes software developed by +the loopat project (https://github.com/simpx/loopat). diff --git a/README.md b/README.md new file mode 100644 index 00000000..f7fdab3f --- /dev/null +++ b/README.md @@ -0,0 +1,221 @@ +# 🧶 loopat + +> **Self-hosted AI workspace built around context management — works solo, scales to teams** + +

+ npm version + GHCR image + License +

+ +

+ loopat — Loop view with chat, workdir, terminal, and team DM +

+ +⭐ [Star on GitHub](https://github.com/simpx/loopat) · +🚀 [Quick start](#quick-start) · +📖 [Architecture](docs/architecture.md) + +--- + +When humans collaborate with AI, three things only humans can bring: + +- **Drive** — pushing the work forward. AI has no desires, no + ambition of its own; momentum has to come from a human. +- **Attention** — what matters now, what to ignore. AI doesn't know + what's worth your time. +- **Entropy reduction** — turning noise into structured knowledge. + AI generates tokens but won't spontaneously simplify. + +loopat is built around managing these three as first-class concepts: +**Loop** (drive) · **Focus** (attention) · **Context** (entropy +reduction). A fourth concept — **Chat** — coordinates the team on the +sync axis. + +

+ loopat architecture +

+ +The agent itself is the [Claude Agent SDK][sdk]; what makes loopat +distinct is the **context architecture around it** — how chat, code, +memory, and knowledge interlock so context doesn't get lost across +sessions or teammates. + +[sdk]: https://github.com/anthropics/claude-agent-sdk + +--- + +## What makes loopat different + +- **End-to-end context management.** Team chat (IM) threads, code + edits, agent decisions, memory — all live in the same context graph + and all flow into the next loop. Most AI tools make you copy-paste + from Slack into the AI to give it situational context; loopat treats + team chat as **a first-class context source** — spawn a loop from + any chat thread and that thread becomes part of the loop's context + automatically. +- **Works solo, scales to teams.** Same workspace whether you're + alone or onboarding teammates. Solo, it's a personal AI workspace; + with a team, shared `knowledge/` and `notes/` git repos sync across + members, loops auto-commit their work, and observations promote + upward through a distillation pipeline. Most AI tools force you to + pick between solo CLI and team SaaS — loopat is one tool at any scale. +- **Reproducible loops.** Every loop runs in its own sandbox with a + versioned toolchain and a pinned credential vault. Spawn the same + loop tomorrow on a different machine and get the same starting + state. No "works on my machine" for AI sessions. +- **Self-hosted, data you own.** All artifacts live in plain git + repos you fully control; vault secrets are git-crypt encrypted. + BYO API key — nothing leaves your machine except the model API call + itself. + +## How loopat compares + +| | Claude Code | Cursor | opencode | Codex | **loopat** | +|---|---|---|---|---|---| +| Form factor | CLI | IDE | TUI | Web (hosted) | **Web (self-hosted)** | +| License | proprietary | proprietary | MIT | proprietary | **Apache 2.0** | +| Team IM integration | external (manual paste) | external (manual paste) | external (manual paste) | external (manual paste) | **built-in, threads ingested into loop context** | +| Memory management | personal (`CLAUDE.md`) | personal (rules + memories) | personal (`AGENTS.md`) | none | **personal + team-shared, with distillation pipeline** | +| Multi-user | single user | per-seat | single user | per-account | **shared workspace** | +| Shared team knowledge | individual config | individual config | individual config | individual config | **git-synced across team** | +| Per-session sandbox | process-level | process-level | process-level | OpenAI-managed | **bwrap (default) · Docker (planned)** | +| Toolchain pinning | host runtime | host runtime | host runtime | fixed (hosted env) | **per-loop versioned** | +| Per-task credential isolation | shared (env vars) | shared (subscription) | shared (env vars) | account-managed | **per-loop vault overlay** | +| Data location | local files | cloud | local files | OpenAI servers | **git repos you control** | +| Secrets storage | env vars (plaintext) | cloud-managed | env vars (plaintext) | platform-managed | **git-crypt encrypted vault** | +| Agent engine | proprietary (Anthropic) | proprietary (multi-model) | pluggable | proprietary (OpenAI) | **Claude Agent SDK** | + +--- + +## Quick start + +```sh +npx loopat +``` + +Open . The first run bootstraps `~/.loopat/`, +prints a checklist, and prompts you to set your API key in +`~/.loopat/config.json`. Restart — done. + +> **Needs:** [Node][node] to launch — the [Bun][bun] runtime is fetched +> automatically, so you don't install it yourself. The terminal / chat +> sandbox additionally needs a Linux host with [podman][podman]. Change +> the port with `PORT=8080 npx loopat`. macOS / Windows is via Docker +> (see below). + +### From source (for development) + +```sh +git clone https://github.com/simpx/loopat.git +cd loopat && bun install +bun run dev +``` + +> Needs [bun][bun] + [bubblewrap][bwrap] + [mise][mise] on the host. For +> team setups with shared knowledge/notes git repos and full bootstrap +> details, see the [installation guide](docs/install.md). + +### Setup guides + +Loopat splits configuration along role lines — read whichever applies: + +- **[Admin setup](docs/setup-admin.md)** — provision the workspace: + knowledge / notes repos, team sandboxes, MCP, operator mounts. Run + this once per workspace. +- **[User setup](docs/setup-user.md)** — join an existing workspace: + personal credential repo, providers, vaults, sandbox mounts. Run + this once per member, per machine. + +[bwrap]: https://github.com/containers/bubblewrap +[mise]: https://mise.jdx.dev/ +[bun]: https://bun.sh/ +[node]: https://nodejs.org/ +[podman]: https://podman.io/ + +## Deployment + +### Docker (recommended) + +Pull the prebuilt image from GHCR: + +```sh +docker run -d --privileged -p 20001:10001 ghcr.io/simpx/loopat:latest +``` + +Or, to build from source and persist the workspace in a named volume: + +```sh +docker compose up -d +``` + +Open (note: **20001**, not 10001 — the host +port is remapped to avoid collision with a local dev server; see +[`docker-compose.yml`](docker-compose.yml)). Workspace persists in +the `loopat-data` volume. Needs `SYS_ADMIN` + unconfined AppArmor for +bwrap mount namespaces. + +### From source (Linux) + +```sh +bun run build # installs deps + builds frontend → web/dist/ +PORT=10001 bun run server/src/index.ts +``` + +Single Hono process serves API + static SPA + websocket on one port. +Put a reverse proxy in front and proxy `/api` + `/ws` to the server. + +## Documentation + +- **[Admin setup](docs/setup-admin.md)** — workspace config, knowledge / + notes repos, sandboxes, MCP, operator mounts. +- **[User setup](docs/setup-user.md)** — personal repo, providers, + vaults, sandbox mounts. +- **[Installation guide](docs/install.md)** — host install, system deps, + environment variables. +- **[Architecture](docs/architecture.md)** — the read/write path, layered + context model, distillation pipeline, Claude config injection paths. +- **[Context flow](docs/context-flow.md)** — the horizontal working model: a + loop is a git worktree, shared context is `main`, and loops exchange it over + two edges — pull and promote. +- **[Identity](docs/identity.md)** — who a loop acts as: the credential chain + (deploy key → git-crypt → vault), and how loopat integrates with your git + host so onboarding is one authorization. +- **[.claude composition](docs/composition.md)** — how team / profile / + personal / repo `.claude/` tiers merge into the loop runtime, and what + you can put in each tier. +- **[Sandbox](docs/sandbox.md)** — bwrap mount mechanics, three-tier mount + authority, what stops the agent from escaping. +- **[Troubleshooting](docs/troubleshoot.md)** — chat won't start, banner + errors, common pitfalls. + +## Contributing + +Issues and PRs welcome. Before opening a non-trivial PR, please skim +[`docs/architecture.md`](docs/architecture.md) so the change lands in the +right layer (sandbox / vault / loop / chat). + +Contributors are asked to sign the [Contributor License Agreement](CLA.md) +on their first PR — the [CLA Assistant][cla-assistant] bot prompts you +with a one-click link. This keeps future licensing options open (e.g. +moving to a business-friendly license) without having to re-collect +permission from every contributor. + +[cla-assistant]: https://cla-assistant.io/ + +## Acknowledgments + +loopat is built on top of: + +- [Claude Agent SDK][sdk] — the agent runtime +- [assistant-ui](https://github.com/assistant-ui/assistant-ui) — React + components for the chat interface +- [Hono](https://hono.dev/) — the HTTP + WebSocket server +- [bubblewrap](https://github.com/containers/bubblewrap) — sandbox mount + namespaces +- [mise](https://mise.jdx.dev/) — per-loop toolchain installs + +## License + +[Apache License 2.0](LICENSE). See [`NOTICE`](NOTICE) for required +attributions and [`CLA.md`](CLA.md) for contribution terms. diff --git a/behavior/01-install-uninstall.md b/behavior/01-install-uninstall.md new file mode 100644 index 00000000..8354f5b8 --- /dev/null +++ b/behavior/01-install-uninstall.md @@ -0,0 +1,28 @@ +# 01 — install / uninstall + +## 证明什么 + +`npx loopat` 装出来的一切都能被 `loopat uninstall` 清干净、**零残留**;而且多个 +`LOOPAT_HOME` 并存时各删各的,卸载一个绝不误伤另一个(即便名字是前缀包含关系)。 + +## Fixture + +- 临时的、相互前缀包含的两个 `LOOPAT_HOME`(`/tmp/loopat-e2e-a`、`/tmp/loopat-e2e-a2`)。 +- 每个都 provision 一个带真实 sandbox 容器的 workspace(与 server 同代码路径)。 +- bun + podman;首次会真建一次 sandbox 镜像。 + +## 步骤 + 断言 + +1. 两个 workspace 各起容器后 → 都有 container + image + network + data。 +2. `uninstall` 掉 `a` → `a` 的 container / image / network / data **全为 0**。 +3. **隔离 + 前缀歧义**:`a2` 仍完好(container/image/network/data 都在),尽管 `a` 是 `a2` 的前缀 + —— 因为删除按 `loopat.workspace` label,不是名字 glob。 +4. `uninstall` 掉 `a2` → 也全清。 + +## 实现 + +`scripts/e2e/install-uninstall.sh` + +## 状态 + +✅ 已自动化 — 本机实跑 PASS,零残留。 diff --git a/behavior/02-personal-permissions.md b/behavior/02-personal-permissions.md new file mode 100644 index 00000000..83605e80 --- /dev/null +++ b/behavior/02-personal-permissions.md @@ -0,0 +1,40 @@ +# 02 — personal 权限 + +## 证明什么 + +权限跟着 **personal 走,与 host 无关**:host 能访问 kn/notes/personal,**不代表** loop 能工作。 +loop 的每个 git 操作只认 personal 自己的 vault key,没有就失败 —— 所以一个"空 personal"的 +账号即使在一台 host 权限齐全的机器上,也跑不动 loop。 + +## 前提行为(本 case 驱动的实现约束) + +- **bootstrap**(首次 clone + 解密 personal repo 本身)→ 用 host deploy-key。 +- **loop 工作**(kn/notes/repos/personal 的 fetch/clone/promote/pull)→ **只用 vault key; + 没有就失败,绝不 fallback host**。这是 case 成立的前提(否则会蹭 host 权限)。 + +## Fixture + +- 一个 git-over-ssh server,3 个 repo:`kn`、`notes`、`personal`,**各自独立授权**(per-repo)。 +- host 的 key 授权访问全部 3 个(模拟"host 有权限")。 +- 用户的 vault key 分阶段授权,以制造下面的三档失败/成功。 + +## 步骤 + 断言 + +1. **启动** → host key 展示 clone `kn`/`notes` 成功,能看到初始化(host 有权限)。 +2. 建账号,**空 personal**(无 vault key)→ 创建 loop **失败**(连不上,不蹭 host)。 +3. vault 配好 key + personal config 声明 `kn`/`notes`,但 `personal` repo 尚未授权该 key + → 创建 loop **依旧失败**(personal 自身连不上)。 +4. 三个 repo 都给该 vault key 授权 → 创建 loop **成功**。 + +→ 整条链证明:能不能跑 loop,只取决于 personal 这把 key 在各 repo 的授权,host 无关。 + +## 实现 + +`scripts/e2e/personal-permissions.ts` + 多账号 `git-ssh-server`(三个 ssh 账号 +`git-kn`/`git-notes`/`git-personal`,各自 authorized_keys + bare repo → per-repo 授权)。 +进程默认 ssh 故意设成 host key,以证明 loop 工作仍只认 vault key(不蹭 host)。 + +## 状态 + +✅ 已自动化 — 三档 PASS:空 personal 全失败 → 授权 kn/notes(personal 仍失败)→ 全授权成功。 +- `sshCommandForUser` 已收紧为 vault-key-only;host deploy-key 仅 `bootstrapSshCommand` 用。 diff --git a/behavior/03-context-flow.md b/behavior/03-context-flow.md new file mode 100644 index 00000000..f6fea9af --- /dev/null +++ b/behavior/03-context-flow.md @@ -0,0 +1,41 @@ +# 03 — context flow + +## 证明什么 + +在 loop 里(由 AI)做的改动,promote 后能在远端 repo 看到;反过来,外部对 repo 的改动, +新建 loop 时能 pull 看到。`notes` / `knowledge` / `personal` 三层都成立。这是 +docs/context-flow.md 的两条边(① pull / ② promote)在真实 loop 上的端到端验证。 + +## Fixture + +- git-over-ssh server,3 个 context repo(`kn`/`notes`/`personal`)作 fixture,已 seed 初始内容。 +- vault key 授权;personal config 声明三者的 ssh url(自描述)。 +- **真实 loop**:Claude SDK + podman 沙箱(需 API key)。 + +## 步骤 + 断言 + +1. 起 loop A,**让 AI 在沙箱里修改** `notes`/`kn`/`personal` 各一处 + promote。 + → assert:远端 repo(三层)能看到该改动。 +2. 在远端 repo 直接做一处改动(模拟别的编辑者已 promote)。 +3. 起 loop B(新 worktree from origin)。 + → assert:loop B 能看到步骤 2 的改动(三层)。 + +## 决策 + +"loop 里让 AI 修改" = **真跑 AI**(Claude SDK + 沙箱),不是模拟 git。 + +## 实现 + +`scripts/e2e/context-flow-ai.sh` — 黑盒:`npx loopat@latest` 起 server + v1 REST API +(register → token → createLoop → messages SSE)driving 真 loop;idealab 真 AI 在 podman +沙箱里改 notes + promote;ssh fixture(`git-ssh-server`)+ vault key。 + +rootless podman 下沙箱与 host 看 ssh server 的地址不同(沙箱只能容器名、host 只能 published +port),所以两条边各用自己可达的 url 指向同一 repo —— 纯 fixture artifact,生产中 ssh host +对所有人同一地址。 + +## 状态 + +✅ 已自动化(本机 npx 0.1.7)— ② promote ✓ [AI WAS HERE]、① pull ✓ [EXTERNAL EDIT]。 +注:`scripts/e2e/context-flow.ts`(本地 file://,无 AI)+ `context-flow-ssh.ts`(vault key) +是机制层对照;本 case 证真 AI 端到端。 diff --git a/behavior/README.md b/behavior/README.md new file mode 100644 index 00000000..66eeeed6 --- /dev/null +++ b/behavior/README.md @@ -0,0 +1,42 @@ +# behavior — 行为测试目录 + +每个 `NN-.md` 描述**一个 case**:一段 loopat 必须满足的端到端行为,以及它**证明了什么**。 +描述与实现分离 —— 规格在这里,可执行实现在 `scripts/e2e/`。 + +## 约定 + +- 文件名 `NN-.md`,两位编号。 +- 每个 case 写清:**目的 / 证明什么** → **fixture(前置)** → **步骤 + 断言** → **实现** → **状态**。 +- 状态:✅ 已自动化 · 🟡 半自动/手动 · ⬜ 待实现。 +- case 的实现尽量自包含、安全(临时 `LOOPAT_HOME`、用完即清),不碰真实 workspace。 + +## Cases + +| # | case | 证明 | 状态 | +|---|------|------|------| +| 01 | install / uninstall | 装完能清干净、零残留;workspace 之间隔离 | ✅ | +| 02 | personal 权限 | 权限跟着 personal 走,与 host 无关 | ✅ | +| 03 | context flow | loop 内改动→外部可见,外部改动→新 loop 可见 | ✅ 真 AI(notes 层) | + +## 共享测试基建 + +- `scripts/e2e/git-ssh-server/` — 最小 key-only git-over-ssh 服务器(podman),用于真测 ssh 凭证。 + +## 环境前提 / 部署注意 + +- podman:本机 rootless,或 macOS 的 `podman machine`(applehv VM)。 +- **公司内网 macOS 的坑**:podman VM 只能访问内网,拉不到公网 docker.io —— 主机的 + VPN/加速(`/etc/hosts` → 公网加速 IP)**不被 VM 继承**。sandbox base 是 `ubuntu:24.04` + (docker.io),所以 VM 必须有**内网可达的 docker mirror**(registries.conf 配 mirror + + 可能 `podman login`),否则沙箱建不起来 —— **loopat 本身在这种 mac 上也跑不了沙箱**, + 与测试无关。配好内网 mirror 后,case 1/2/3 才能在 mac 上双平台跑。 +- 真 AI 的 case(03)还需 idealab API key(`IDEALAB_KEY`)。 + +## mac 双平台实测结论(0.1.15,2026-06-01) + +- **沙箱 image blocker 可绕过**:本机 `podman build` → `podman save` → scp → VM `podman load`;loopat 检测 hashTag(`loopat-sandbox--`)存在即跳过 build。两机 Containerfile hash 一致。VM ping 公网加速 IP 通但 443 refused,模仿 `/etc/hosts` 无效——只能 load。 +- **LOOPAT_HOME 必须在 `$HOME` 下**:podman machine 只挂 `$HOME`,`/tmp` 不挂 → bind workdir `statfs … no such file`。e2e 默认 `/tmp/...` 在 mac 不适用。 +- **01** ✅ mac PASS(零残留 + label 隔离),与本机一致。 +- **02** 🟡 mac 与本机逐字一致;stage3 失败是脚本过时(Model B/per-user:personal 走 deploy key),非 mac、非 loopat bug。 +- **03** ✅ mac 真 AI 跑通(0.1.22):沙箱内 linux claude(2.1.159)调 idealab → 回复 "hello from mac"。linux claude 由 loopat **启动时** `--force` 自动装(npx 不跑 postinstall);docker base image 用 save/load;idealab key 手动配。 +- e2e 在 mac 跑的适配:rsync repo + `npm i -g bun` + 预 load image + 给临时 workspace `podman build FROM --label loopat.workspace=`(无网络)注入带 label 的 base,让 setup 跳过 build。 diff --git a/bin/loopat.mjs b/bin/loopat.mjs new file mode 100755 index 00000000..e00748a6 --- /dev/null +++ b/bin/loopat.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// loopat launcher — runs the Bun server bundled in this package. +// +// loopat's server is written against the Bun runtime (bun:sqlite, Bun.serve, +// bun-pty FFI, …), so it cannot run on plain Node. We depend on the `bun` npm +// package, which downloads a platform Bun binary at install time; this Node +// shim locates that binary and hands off to `bun server/src/index.ts`. +// +// `npx loopat` therefore works on any machine that has Node — the user never +// has to install Bun themselves. +import { spawn } from "node:child_process" +import { createRequire } from "node:module" +import { existsSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, join } from "node:path" + +const require = createRequire(import.meta.url) +const here = dirname(fileURLToPath(import.meta.url)) +const pkgRoot = join(here, "..") +const serverEntry = join(pkgRoot, "server", "src", "index.ts") + +// Subcommand routing. `loopat uninstall` runs a standalone cleanup entry (never +// boots the server); everything else starts the server. Kept here in the Node +// shim so the uninstall path doesn't pull in any server-side init. +const SUBCOMMANDS = { + uninstall: join(pkgRoot, "server", "src", "uninstall.ts"), +} +const sub = process.argv[2] +const subEntry = Object.prototype.hasOwnProperty.call(SUBCOMMANDS, sub) ? SUBCOMMANDS[sub] : null +const entry = subEntry ?? serverEntry +const forwardArgs = subEntry ? process.argv.slice(3) : process.argv.slice(2) + +function resolveBun() { + // 1. Explicit override. + if (process.env.LOOPAT_BUN && existsSync(process.env.LOOPAT_BUN)) { + return process.env.LOOPAT_BUN + } + // 2. The `bun` dependency's installed binary. The package ships bin/bun.exe + // (a cross-platform shim) and, after its postinstall, the real binary. + try { + const bunPkg = dirname(require.resolve("bun/package.json")) + for (const candidate of [ + join(bunPkg, "bin", "bun"), + join(bunPkg, "bin", "bun.exe"), + ]) { + if (existsSync(candidate)) return candidate + } + } catch { + /* bun package not resolvable from here — fall through */ + } + // 3. npm-created bin shim alongside our package. + for (const candidate of [ + join(pkgRoot, "..", ".bin", "bun"), + join(pkgRoot, "node_modules", ".bin", "bun"), + ]) { + if (existsSync(candidate)) return candidate + } + // 4. A bun already on PATH. + return "bun" +} + +const bun = resolveBun() +const child = spawn(bun, [entry, ...forwardArgs], { + stdio: "inherit", + env: process.env, +}) + +child.on("error", (err) => { + console.error(`[loopat] failed to launch bun (${bun}): ${err.message}`) + console.error("[loopat] set LOOPAT_BUN to a bun binary, or install bun: https://bun.sh") + process.exit(1) +}) +child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal) + else process.exit(code ?? 0) +}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..83065873 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + loopat: + build: . + image: ghcr.io/simpx/loopat:latest + privileged: true + ports: + - "20001:10001" + - "17788:7788" + volumes: + - loopat-data:/home/loopat/.loopat + # - ~/.ssh:/home/loopat/.ssh:ro # 可选:挂载 SSH 密钥用于 git 操作 + environment: + - LOOPAT_HOME=/home/loopat/.loopat + - HOST=0.0.0.0 + - LOOPAT_SERVE_HOST=0.0.0.0 + # External Runtime Gateway SSE — tokens are managed per-user via + # Settings > Gateway Tokens in the loopat UI. No env vars needed. + restart: unless-stopped + +volumes: + loopat-data: diff --git a/docs/account-model.md b/docs/account-model.md new file mode 100644 index 00000000..95fb9802 --- /dev/null +++ b/docs/account-model.md @@ -0,0 +1,272 @@ +--- +title: Account Model +tags: [loopat, account, agent, identity] +status: design doc — NOT IMPLEMENTED (reverted 2026-05-27) +--- + +> **状态:仅设计文档,未实现。** 这一版多账号实现做完后回滚了 —— 想多个身份就直接注册多个 user 即可,先不引入 ownership 模型。本文留作未来重新启用时的参考。 + +# Account Model + +> **Loopat 的身份模型 = OA 系统里"一个人挂多账号 / 公共账号"模式的直接复用。** 没有独立的 agent 数据类型;只是 account 加了"归属"这一个字段,就把"人 / 公共账号 / agent / bot" 全部覆盖。 + +## 类比:OA 系统里的多账号模式 + +几乎所有面向组织的 OA 系统都解决过同一个问题 —— **一个自然人需要管理 / 操作多个账号,但这些账号不是同一种东西**。共同抽象: + +| OA 系统 | 个人侧 | 非个人侧 | 负责机制 | +|---|---|---|---| +| **Alibaba BUC** | 工号 / 员工账号 | 业务系统账号、服务账号 | 工号挂多业务账号 | +| **钉钉** | 个人钉钉号 | 公共账号、机器人 | 公共账号有"管理员" | +| **企业微信** | 员工 | 应用账号、客服账号 | 应用绑负责人 | +| **飞书** | 个人 | 机器人、自定义应用 | 机器人有 owner | +| **AWS IAM** | IAM User(人)| IAM User with access keys / Service-linked role | 由 root / admin 创建并负责 | +| **GCP** | Google account | Service Account | "owner" 字段指向 user/group | +| **GitHub** | Personal account | Bot account / machine user | 由人 fork / 维护 | + +**共同点**: + +1. 都没有发明"非人实体"类型 —— 它们都是 account,只是用法不同 +2. 都有一个**所有权字段**指向"负责人" +3. 都有两种登录路径 —— 人登(密码 / SSO)+ 程序登(API key / Service token) +4. **公共账号本身有自己的资源**(自己的邮箱、自己的会话、自己的存储),跟个人账号同构 + +Loopat 走完全相同的路线。**OA 系统验证过几十年的设计,我们没必要发明新词。** + +## 核心模型 + +整个 loopat 只有一种身份名词:**account**(代码里仍叫 `user`,是历史遗留,不影响概念)。两种 account: + +- **个人账号** — 一个自然人的账号,用 password 登录 +- **公共账号** — 由某个个人账号"负责"的账号,没有人坐在键盘前直接用它,只能通过 API token 访问 + +> **"公共" 的含义沿用 OA 系统**:不是"多人共有"(公共账号有且仅有一个负责人),而是**"非个人用途"** —— 给程序用、给业务流程用、给共享业务用。 + +```typescript +type Account = { + id: string // 全局唯一,扁平 (alice, alice-coderev, ...) + ownerId: string | null // null = 个人账号;非空指向负责人 account.id + authMethod: "password" | "token-only" + personalRepoUrl?: string // 每个 account 都有自己的 personal repo + chatBotMode?: { // 任意 account 可开 + enabled: boolean + defaultLoopId?: string + } + // ...其他字段同现状(status, role, createdAt 等) +} +``` + +**所有变化都收敛在两个字段上:`ownerId` 和 `chatBotMode`。** + +| | 个人账号 | 公共账号 | +|---|---|---| +| `ownerId` | `null` | 指向负责人 account.id | +| 登录方式 | password + cookie | **不能 web 登录**,只能 Bearer token | +| 谁能创建 | 自助 register / admin 邀请 | 由它的负责人通过 API/UI 创建 | +| 谁能颁发 token | 自己(cookie 认证后) | 它的负责人(不能自我繁殖)| +| 能否拥有其他账号 | ✓ | ✗(不套娃)| +| 出现在 User 列表 | ✓ | ✗(在 "Agents" / "公共账号" 视图)| +| 资源结构 | 完整的 personal repo / vault / .claude / memory / loops | 同左,完全同构 | +| OA 类比 | 工号 / 员工账号 / 个人钉钉号 | 业务账号 / 公共账号 / 机器人 / 服务账号 | + +**结构上同构** 这一点非常重要:任何对账号生效的代码路径(spawn sandbox、注入 vault、composeLoopClaudeConfig、metadata 注入等)**完全不区分**两种账号,它们走同一份代码。区别只在认证入口的几行: + +``` +auth.ts: human 走 password 校验 + session cookie +api-tokens.ts: 公共账号走 Bearer token 解析 + +之后所有路径同一套 +``` + +## 每个 account 拥有什么 + +不管是个人还是持有,每个 account 都自带: + +| 资源 | 物理位置 | 用途 | +|---|---|---| +| **Personal repo** | `personal//` | 全部用户数据的根(用 git 跟踪可同步)| +| **Vault** | `personal//.loopat/vaults//` | 凭证(API keys、SSH key、env)| +| **.claude overlay** | `personal//.loopat/.claude/` | 个性化 settings、CLAUDE.md、skills | +| **Memory** | `personal//memory/` | 跨 loop 持久化的笔记 | +| **Loops** | `loops//` (createdBy = account.id) | 这个 account 创建的对话 | +| **Tokens** | `api-tokens.json` 里 entries with `userId = account.id` | 公共账号常用;个人账号也可以颁发给自己 | + +Persistent state 一律绑在 account 上 —— 这是把"账号 = 一个完整工作者身份"的设计承诺。 + +## "Agent" 这个词怎么用 + +**数据模型里没有 agent**。"Agent" 是公共账号在不同语境的别名 —— 跟 OA 系统里"公共账号"在不同地方叫不同名字一样(钉钉里叫"机器人",企业微信里叫"应用账号",AWS 里叫"Service Account")。 + +| 场景 | 内部 | 对外文案 | +|---|---|---| +| Settings 页面里"我负责的非个人账号" | account where ownerId = me | "My Agents" / "My Bots" / "公共账号" | +| 公开市场 | public account templates | "Agent Marketplace" | +| 文档 | account with token-only auth | "agent account" / "service account" | +| API 集成方读 | `POST /api/v1/loops` with token | "drive an agent" | +| 后台 / 管理员视图 | account where ownerId IS NOT NULL | "公共账号管理" | + +**双面神**:营销层完全自由("loopat is an agent platform"),实现层零成本(schema 没新概念)。**底层叫 account,UI 跟着语境叫 agent / bot / 公共账号,三者无差别**。 + +## 行为差异(细节) + +### 创建账号 + +- **个人账号** 通过 `POST /api/auth/register` 或 admin 邀请创建。需要 username + password。 +- **公共账号** 通过 `POST /api/v1/me/accounts` 创建(cookie auth,必须是个人账号操作)。 + - body:`{ id, label?, profiles?[], initialVault? }` + - 创建后服务端 scaffold 它的 personal repo 骨架 + +### Token 颁发 + +- 个人账号给自己颁发 token:`POST /api/v1/me/tokens` (cookie auth) +- 个人账号给自己拥有的公共账号颁发 token:`POST /api/v1/me/tokens` body 加 `forAccount: "alice-coderev"` +- 公共账号**不能**给自己或别人颁发 token,即使有 token 也不行 + +### Loop 操作 + +- 个人账号的 loop:`createdBy = alice`,driver 是 alice +- 公共账号的 loop:`createdBy = alice-coderev`,driver 是 alice-coderev +- 个人账号能不能"以公共账号身份创建 loop"?**MVP 不做**。如果要,走公共账号自己的 token。 + +### 操作权限 + +- 个人账号能改自己资源(vault、.claude、memory) +- 个人账号能改自己拥有的公共账号的资源(owner 全权) +- 公共账号**默认只读自己的配置资源**(vault、.claude)—— "agent 不能自我改写",避免失控 + - 可由 owner 显式开启 "self-mutable" 标志,未来再说 + +## UI 呈现 + +UI 分组是**纯视觉**,不影响 schema: + +``` +Settings +├── Personal Repo (我自己的个人账号设置) +├── AI Providers +├── ... +├── API Tokens (我的 token,可挑给哪个账号用) +└── My Agents / 我的公共账号 ← 列出 ownerId = 我 的所有公共账号 + ├── alice-coderev (已颁发 token, bot mode on) + ├── alice-slack + └── + New ← 创建一个新的公共账号 + +Admin +└── All Accounts (admin 才看到全部 —— 个人账号 + 所有公共账号) +``` + +跟 OA 系统的视图惯例一致: +- 普通员工面板只能看到自己的"主账号"和"我管理的公共账号" +- HR / 管理员能看到全员账号 +- 公共账号有独立 tab,不和员工列表混排 + +`WHERE ownerId IS NULL` 是默认过滤;切到 admin 视图 / 切到 "公共账号管理" tab 才会改变这个过滤。 + +## Chat Bot 能力(未来) + +`chatBotMode` 字段附在任何 account 上,被 chat 子系统识别: + +``` +chat 服务收到一条 DM → 目标 account = X + ↓ +查 X.chatBotMode.enabled + disabled → 普通投递(人会看到) + enabled → 把消息 fork 到 X 的 defaultLoopId (或新建一个 loop) + loop 的 assistant 输出 post 回 chat 当 X 的回复 +``` + +**任何 account 都能开**: +- alice (个人) 度假时开 bot mode → "out of office, my agent answers" +- alice-coderev (公共账号) 永久开着 → 永久 bot +- 临时把 alice-coderev 加进一个 channel → channel 里它就是 bot + +这是个**几乎纯属 chat 子系统的 feature**,账号模型上只新增一个 nullable 字段即可承载。等 chat 真要开放再实现。 + +## API 表面(v1 增量) + +``` +POST /api/v1/me/accounts 创建一个我拥有的公共账号 + body: { id, label?, profiles?[], initialVault?{ envs?, mountsHome? } } + returns: 该账号的 Account 对象 + +GET /api/v1/me/accounts 列出我拥有的公共账号 + returns: [{ id, label, createdAt, ... }] + +DELETE /api/v1/me/accounts/{id} 删除(soft delete) + +POST /api/v1/me/tokens 支持新字段 forAccount?: string + 默认 forAccount = caller 自己 + forAccount = 我拥有的某个账号 → token 解析回那个账号 + forAccount = 我不拥有的账号 → 403 +``` + +剩下所有 `/api/v1/loops/*` 等端点都**不需要变** —— 它们已经按 `c.get("userId")` 路由资源,那个 userId 是 account.id,本来就工作。 + +## 这个 model 的优点 + +1. **零新概念**。schema 加 1 个字段(ownerId)。MVP 之外的 chatBotMode 是单独字段。 +2. **结构同构**。任何 account 处理逻辑不需要 if/else 区分人/agent。 +3. **凭证隔离**。每个 account 自己有 vault,没有跨 account 借凭证这种事 —— 公共账号泄露不波及 owner 的 secrets。 +4. **市场叙事就绪**。未来 marketplace 上架的 "agent" 就是公开的公共账号模板,install = 给 owner clone 一份。 +5. **chat bot 顺手**。bot 模式是 account 的一个属性开关,跟 account 类型解耦。 +6. **跟现有代码兼容**。`createdBy / driver / userId` 这些字段在代码里早就存在,含义只是从"user"扩展到"account"。 + +## 实施序列 + +按从小到大、可以分多次 ship 排: + +1. **`Account.ownerId` 字段** + DB/JSON 迁移(现有所有 user 设为 `ownerId: null`) +2. **`POST /api/v1/me/accounts`** + **`GET /api/v1/me/accounts`** + scaffold 公共账号的 personal repo +3. **Token 颁发支持 `forAccount`**(owner 给自己拥有的账号发 token) +4. **Web: Settings → My Agents tab** 列表 + 创建 + 删除(先不做编辑,编辑通过修改对应账号的 personal repo 完成) +5. **`DELETE /api/v1/me/accounts/{id}`** +6. **(独立任务)chat bot mode 字段 + chat 子系统识别** —— 等 chat 子系统启动时一起 +7. **(远期)公开 marketplace** + +## 跟之前讨论的回顾 + +我们之前为了"agent" 这个概念前前后后讨论过五版: + +1. **不要 agent**("user 就是 agent")—— 简,但无法表达一个人多套配置 +2. **agent = 命名 profile 组合**(轻)—— 没有真 identity,最终跟 profile 没区分 +3. **agent = profile 组合 + vault 引用**(中)—— 凭证寻址别人 namespace,有歧义 +4. **agent = 独立实体(自有 vault/memory)** —— 有点重,又怕引入新概念 +5. **本文:account 多账号 + 文案叫 agent** —— 概念最少,能力最足 + +第 5 版是终态。前面四版各解决了不同片面,最后这版把它们都吸进同一个模型里。 + +## 改这个模型时记得 + +- **schema 加字段先来这里更新**,再去改 `auth.ts` + `loops.ts` 的 Account/User 类型 +- **代码内不要新增 "agent" 类型**,只用 account + ownerId 判别 +- **"agent" 这词只能出现在 UI 文案、API 文档对外表述、营销文案** —— 内部 code/comment/doc 用 "account" 或 "owned account" +- **chatBotMode 不要绑定到 account 类型** —— 个人账号也能开 +- **公共账号的 personal repo 跟个人的同构** —— 不要造特殊路径 + +## FAQ + +**Q: 跟 OA 系统里的"公共账号"完全一一对应吗?** +A: 概念上是。差别只在 OA 系统的公共账号可能被多人共用同一登录凭证(一个账号多人登),loopat 的公共账号不允许 web 登录、只能 token 访问。**所以 loopat 公共账号的"共用"是"我和我的 bot 共用",不是"多个人共用"。** + +**Q: 用户能把自己的个人账号转换成公共账号吗?** +A: MVP 不允许(password 取消 + ownerId 设置是个复杂迁移)。如果真有需要,未来加个 "transfer ownership" 操作 —— 跟 OA 系统里"员工离职,账号转交公共"是同种动作。 + +**Q: 公共账号能拥有公共账号吗?** +A: 不能。`ownerId` 必须指向 `ownerId IS NULL` 的 account。代码层强校验。OA 系统也没听说哪个支持公共账号套娃,理由一致:负责人必须是真人。 + +**Q: Admin 能管理别人的公共账号吗?** +A: 能查看(admin 视图),但不能默认操作 —— 公共账号的最终责任在 owner。Admin 通过用户管理删除 owner 时,可选 cascade 删除其公共账号(跟"员工离职清理其 owned 账号"同种 ops)。 + +**Q: 一个公共账号能同时被多个人负责(co-owner)吗?** +A: 不能。`ownerId` 是单值字段。"团队共享 agent" 通过**公开 / 共享到 workspace knowledge** 实现 —— 别人 install 时各自 clone 一份属于自己的,不共享底层账号。 + +**Q: 公共账号被删后,它创建的 loop 怎么办?** +A: soft delete 模式下 loop 继续存在但只读。Hard delete 时连带 loops、vault、memory 一并清掉(admin 操作)。 + +**Q: Token 颁发为什么必须 cookie auth?** +A: 公共账号自我繁殖会让 token 泄露后无法收敛。Cookie auth = "必须有个真人在键盘前",是安全边界。这跟 OA 系统里"机器人 token 必须由管理员(非机器人)颁发"是同一种 design。 + +**Q: 跟 "subuser" / "subaccount" 这种说法有区别吗?** +A: 概念上同源(一个 owner 拥有多个账号),但 loopat 文档里**刻意不用 sub-** 前缀,理由: +- "sub" 暗示等级低 / 缩水,但公共账号在资源结构上跟个人账号**完全同构**,没"少"任何东西 +- OA 系统的实践里也用"公共账号 / 服务账号 / 机器人账号"这些平级词,不会自降身份说成"subuser" +- 跟代码里继续用 `user` table、`ownerId` 字段一致 —— 没有任何"sub"语义 diff --git a/docs/api-v1.md b/docs/api-v1.md new file mode 100644 index 00000000..347ec526 --- /dev/null +++ b/docs/api-v1.md @@ -0,0 +1,527 @@ +--- +title: Loop API v1 +tags: [loopat, api, loop, sse] +status: living doc +--- + +# Loop API v1 + +> **Loopat 的对外 API 第一版,只承载"和 loop 对话"。** 外部程序(机器人框架、自动化脚本)通过这套 API 创建并驱动 loop,体验和 web 上"打开一个 loop 聊天"完全一致。Web 自己的"NewLoopDialog"和 ChatInterface 也将基于这套 API 实现,使 web 成为这套 API 的 reference client。 + +## 定位 + +Loopat 不区分"人"和"agent"。所谓"agent" = "由程序通过 API token 操作的 user"。要造一个机器人:在 web 上注册一个 user → 配 profile/vault → 拿一个 token → 用这套 API 操作它的 loop。 + +v1 API **只**包含和 "loop 对话" 直接相关的能力。所有 web UI 的辅助功能(loop 列表小红点、看板、终端、token 用量进度条等)**不**进 API,继续走各自的 WebSocket。 + +``` +v1 API surface = "聊天对话" +其它 = 内部 WS / 内部 REST,归 web 应用专用 +``` + +判别一件事是否该进 v1 API 的硬标准:**它是不是对话本身的语义?** + +## 基本约定 + +- **Base URL**:`https:///api/v1` +- **Content type**:`application/json`(SSE 端点是 `text/event-stream`) +- **命名**:字段一律 `snake_case`,时间戳一律 RFC 3339 UTC,ID 带类型前缀(`loop_`、`turn_`、`choice_`) +- **版本**:本 spec 是 `v1`,破坏性变更上 `v2` + +## 认证 + +两种方式,都解析成同一个 `user_id`: + +| 调用方 | 方式 | +|---|---| +| 外部程序 | `Authorization: Bearer la_` | +| Web(同源) | Session cookie | + +Token 由 web Settings 页面创建/撤销 —— 见 [`/me/tokens`](#me-tokens-token-管理-cookie-only)。Bot **不能**用自己的 token 创建新 token(防止泄露横向扩散)。Token 落盘前做 SHA-256,明文只在创建时返回一次。 + +认证失败:`401` + 标准 error body(见[错误格式](#错误格式))。 + +## Loop 生命周期(重要) + +**Turn 是异步的。** API 连接的开关**不**影响 turn 的运行: + +- 关掉 SSE 流 ≠ 取消 turn。Turn 继续跑。 +- 调用方稍后可以通过 `GET /events` 或带相同 `Idempotency-Key` 重新 POST 来重新订阅。 +- 没人在听 SSE 不会让 loopat 提前结束 turn。 + +Turn 只在以下情况终止: + +1. 正常完成 → `event: done` +2. Agent 自身的错误 → `event: error` +3. 调用方主动 `POST /interrupt` → `event: interrupted` +4. 沙箱被回收(见下)→ `event: error code=sandbox_evicted` + +**沙箱闲置回收**:loop 在 30 分钟内没有任何 turn 活动(包括等 choice 应答这种"被动空闲"),沙箱被回收以节省资源。下一条消息会触发冷启动(`started.cold_start = true`)。 + +这是 v1 spec 在生命周期上**唯一**承诺的行为;不存在"permission 等多久会超时"、"question 等多久会超时"、"turn 跑多久会被砍"这种 API 层的截止时间。 + +## 资源模型 + +### Loop + +```jsonc +{ + "id": "loop_3a91...", + "title": "kanban refactor", + "created_at": "2026-05-25T20:00:00Z", + "created_by": "user_abc", + "archived": false, + "archived_at": null, + "metadata": { "slack_thread": "C123:1234" }, + + // 创建时确定的配置 + "profiles": ["base", "coding"], + "vault": "default", + "repo": "myproject", + + // 运行时状态(list 不返回,GET /loops/{id} 才返回) + "busy": true, + "queue_depth": 1, + "turn_count": 5, + "current_turn": { + "turn_id": "turn_xyz", + "started_at": "2026-05-25T20:05:11Z", + "pending_choice_id": null + } +} +``` + +| 字段 | 说明 | +|---|---| +| `id` | UUIDv4,前缀 `loop_` | +| `title` | ≤ 200 字符 | +| `metadata` | 自由 `Record`,总大小 ≤ 16 KB。**Loopat 不解析;沙箱内的 agent 也看不到。**纯粹是 caller 自己的 escape hatch | +| `profiles` | 创建时选定的 profile 列表 | +| `vault` | 创建时选定的 vault 名 | +| `repo` | 创建时选定的 repo(loop workdir 从 `context/repos/` 派生),可为 `null` | +| `busy` | 当前是否有 turn 进行中或排队中 | +| `queue_depth` | 排在当前 turn 后面的消息数 | +| `current_turn` | `busy` 为 true 时出现 | + +## 端点 + +### 创建 loop + +``` +POST /api/v1/loops +``` + +```jsonc +{ + "title": "kanban refactor", // 可选,默认 "untitled" + "metadata": { "slack_thread": "..." }, // 可选 + "profiles": ["base", "coding"], // 可选,默认 = user 的 default_profiles + "vault": "default", // 可选,默认 = user 的 default_vault + "repo": "myproject" // 可选;为空则 workdir 为空目录 +} +``` + +`201 Created` 返回完整 Loop 对象。 + +字段语义与 web 上的"New Loop"对话框完全等价 —— web 也调用这个端点。 + +### `/me/tokens` — Token 管理(cookie-only) + +这三个端点的 path 在 `/api/v1` 下,但**只接受 session cookie**,不接受 Bearer token。这是有意的安全边界:bot 拿到 token 不能自己增殖出更多 token。 + +``` +POST /api/v1/me/tokens { label } → 201 { tokenId, token, label, createdAt } +GET /api/v1/me/tokens → 200 { tokens: [{ tokenId, label, createdAt, lastUsedAt? }] } +DELETE /api/v1/me/tokens/{tokenId} → 204 +``` + +- `token` 形如 `la_<48 hex>`,**只在创建响应里返回一次**,server 不留明文(SHA-256 哈希存储)。 +- `tokenId` 形如 `tok_<12 hex>`,稳定不变,列出 + 撤销用它。 +- 用 Bearer auth 调这三个端点 → `401`。 + +### 列出 loops + +``` +GET /api/v1/loops?limit=20&after=loop_xxx&archived=false +``` + +返回调用方自己的 loop(`created_by == user_id`),按最近活跃倒序。 + +```jsonc +{ + "data": [ { "id": "...", ... }, ... ], + "first_id": "loop_a", + "last_id": "loop_z", + "has_more": true +} +``` + +| 参数 | 默认 | 说明 | +|---|---|---| +| `limit` | 20 | 最大 100 | +| `after` | — | 游标(loop id),返回严格更老的 | +| `before` | — | 游标,返回严格更新的 | +| `archived` | `false` | 设 `true` 包含已 archive 的 | + +`busy` / `queue_depth` / `current_turn` 在 list 响应中**不**返回;如要这些字段,调用 `GET /loops/{id}`。 + +### 获取 loop + +``` +GET /api/v1/loops/{id} +``` + +返回完整 Loop 对象(含运行时状态)。 + +### Archive loop + +``` +DELETE /api/v1/loops/{id} +``` + +设置 `archived = true`,杀沙箱,释放磁盘。返回 `204`。 + +反 archive 和硬删走 web —— **不**在 API 内。 + +### 发送消息(并流式接收 turn) + +``` +POST /api/v1/loops/{id}/messages +Authorization: Bearer la_... +Idempotency-Key: # 强烈推荐 +Content-Type: application/json +Accept: text/event-stream + +{ + "content": "hello", // string,≤ 1 MB + "permission_mode": "bypassPermissions" // 可选;本 turn 临时覆盖 loop 的当前权限模式 + // 取值: default | acceptEdits | bypassPermissions | plan | dontAsk | auto +} +``` + +响应:`200`,`Content-Type: text/event-stream`。事件词表见 [SSE 事件参考](#sse-事件参考)。 + +语义: + +- loop **空闲**时,这条消息直接成为下一个 turn,流以 `event: started` 开始 +- loop **忙**时,消息入队,流以 `event: queued` 开始,轮到时再发 `event: started` +- **调用方关闭连接,turn 继续运行**。重连方式:用相同 `Idempotency-Key` 重新 POST,或者用 `GET /events` + +流开始**前**的错误(`401`、`404`、`409`)返回 JSON,**不是** SSE。流开始**后**的错误用 `event: error` 然后关流。 + +### 观察 loop(只读流) + +``` +GET /api/v1/loops/{id}/events +Accept: text/event-stream +``` + +附加到 loop 的实时事件流,**不发新消息**。用途: + +- 断线重连(手头没有 `Idempotency-Key`) +- 前端被动查看其他人驾驶的 loop +- 多个并行观察者 + +连接建立时,如果当前有 turn 正在跑,server 先发 `event: snapshot` 给出当前状态(含已累积的 assistant 文本和待应答的 choice),然后继续推 live 事件。关掉这个流**不影响** turn。 + +### 回答 choice + +``` +POST /api/v1/loops/{id}/choices/{choice_id} +Content-Type: application/json +``` + +`kind = "permission"`: +```jsonc +{ "allow": true } // 或 false +``` + +`kind = "question"`: +```jsonc +{ "answers": { "": "", ... } } +``` + +成功返回 `202 Accepted`;`choice_id` 不存在或已被应答返回 `404`。 + +未应答的 choice **不会主动超时**。Agent 会一直等,直到: + +- 有人通过这个端点(或 web)应答 → `event: choice_resolved` +- 沙箱因 30 分钟无活动被回收 → turn 失败,`event: error code=sandbox_evicted` +- 有人调 `POST /interrupt` → `event: interrupted` + +### 中断当前 turn + +``` +POST /api/v1/loops/{id}/interrupt +``` + +取消当前 turn,返回 `202`。所有打开的 SSE 流收到 `event: interrupted` 并关闭。 + +## SSE 事件参考 + +每个事件是 `event: \ndata: \n\n`,所有 payload 是 JSON object。`POST /messages` 和 `GET /events` 共用同一套词表。 + +| 事件 | 触发时机 | Payload | 终止流? | +|---|---|---|---| +| `queued` | POST 时已有人排队,最先发 | `{ "position": 1 }` | 否 | +| `started` | turn 开始(任何 delta 前必有)| `{ "turn_id", "cold_start": false }` | 否 | +| `snapshot` | 仅 `GET /events` 接入且 turn 已在跑 | `{ "turn_id", "assistant_text_so_far", "pending_choice_id" }` | 否 | +| `assistant_delta` | 可见文本增量 | `{ "text": "..." }` | 否 | +| `thinking_delta` | extended-thinking 增量(观察)| `{ "text": "..." }` | 否 | +| `tool_call` | agent 调工具(**只读观察**,不需要回应)| `{ "tool_use_id", "tool", "input_summary"? }` | 否 | +| `tool_result` | 工具调完 | `{ "tool_use_id", "ok": true }` | 否 | +| `requires_choice` | agent 被卡住,等决定 | `{ "choice_id", "kind", "payload" }` | 否 | +| `choice_resolved` | choice 被应答(通过 API 或 web)| `{ "choice_id", "source": "api"\|"web" }` | 否 | +| `done` | turn 正常完成 | `{ "turn_id" }` | **是** | +| `interrupted` | turn 被中断 | `{ "turn_id" }` | **是** | +| `error` | 不可恢复 | `{ "code", "message", "turn_id"? }` | **是** | +| `ping` | 心跳 | `{}` | 否,每 15s | +| `sdk_message` | **内部用** —— 原始 SDK 消息直传 | 原 SDK 消息对象(shape 由 Anthropic SDK 决定,**不稳定**)| 否 | + +**`sdk_message` 是 loopat web 自己用的逃生口**:web 的 chat 视图需要完整 SDK 消息形态(content_block_delta、tool_use、thinking 等)才能渲染富 UI,与其重写整个 dispatch pipeline,直接把 SDK 消息原样转发给同一通道。Bot 框架**不应**依赖这个事件 —— 它的 shape 跟着 Anthropic SDK 变。稳定的对外契约仍是 `assistant_delta` / `tool_call` / `requires_choice` 等。 + +**没有委托式 tool_call**:API 永远不要求调用方执行工具。`tool_call` / `tool_result` 是纯观察事件 —— 调用方没有"提交工具结果"的端点,因为这种端点不存在。所有工具都在 loop 沙箱里跑。 + +**`done` 不重发完整文本**:调用方自己累加 `assistant_delta`。Idempotency 重放会重新发同一序列的 delta,所以重建是确定性的。 + +**Choice payload 形态** —— + +`kind = "permission"`: +```jsonc +{ "tool": "Bash", "command": "rm -rf /tmp/x", "reason": "agent wants to..." } +``` + +`kind = "question"`: +```jsonc +{ "questions": [ + { "id": "q1", "question": "...", "options": [...], "multi_select": false }, + ... + ] } +``` + +## 幂等性 + +`POST /messages` 上加 `Idempotency-Key: <唯一 string,≤ 256 字符>`(其他写操作天然幂等)。 + +- Server 把 `(user_id, key) → 请求 hash + 事件缓冲` 保存 24 小时 +- **同 key + 同请求 hash**:重放缓冲的事件。如果 turn 还在跑,挂到 live 流上 +- **同 key + 不同请求 hash**:`409 conflict_error` + +这是 SSE 断流恢复的标准做法。 + +## 错误格式 + +非 SSE 错误: + +```jsonc +{ + "error": { + "type": "invalid_request_error", + "code": "loop_archived", + "message": "Cannot send messages to an archived loop" + } +} +``` + +| HTTP | `type` | `code` 举例 | +|---|---|---| +| 400 | `invalid_request_error` | `missing_content`、`content_too_large`、`loop_archived` | +| 401 | `authentication_error` | `invalid_token`、`missing_credentials` | +| 403 | `permission_error` | `not_loop_owner` | +| 404 | `not_found_error` | `loop_not_found`、`choice_not_found` | +| 409 | `conflict_error` | `idempotency_key_reused` | +| 429 | `rate_limit_error` | `too_many_requests` | +| 500 | `internal_error` | `sandbox_spawn_failed`、`sandbox_evicted` | + +流中错误(SSE 已开后)发 `event: error { code, message }` 然后关流。 + +## 上限 + +| 项目 | 限制 | +|---|---| +| `title` | 200 字符 | +| `metadata` | 16 KB(JSON 序列化后)| +| `content`(单条消息)| 1 MB | +| `Idempotency-Key` | 256 字符 | +| 幂等性窗口 | 24 小时 | +| 沙箱闲置回收 | 30 分钟 | +| 速率限制 | per-token,平台层配置(不在 spec 内)| + +## 示例 + +### 完整对话 + +``` +> POST /api/v1/loops +> { "title": "demo" } +< 201 { "id": "loop_a1", ... } + +> POST /api/v1/loops/loop_a1/messages +> Idempotency-Key: 0e92... +> { "content": "list files in /tmp" } +< 200 text/event-stream +< event: started data: { "turn_id": "turn_b2", "cold_start": true } +< event: tool_call data: { "tool_use_id": "tu_1", "tool": "Bash", "input_summary": "ls /tmp" } +< event: tool_result data: { "tool_use_id": "tu_1", "ok": true } +< event: assistant_delta data: { "text": "Here are " } +< event: assistant_delta data: { "text": "the files: ..." } +< event: done data: { "turn_id": "turn_b2" } +``` + +### 处理 permission choice + +``` +> POST /api/v1/loops/loop_a1/messages +> { "content": "now rm -rf /tmp/foo" } +< event: started ... +< event: requires_choice data: { + "choice_id": "choice_c3", + "kind": "permission", + "payload": { "tool": "Bash", "command": "rm -rf /tmp/foo" } + } + +# 在另一个连接里(或 web 上): +> POST /api/v1/loops/loop_a1/choices/choice_c3 +> { "allow": true } +< 202 + +# 回到流上: +< event: choice_resolved data: { "choice_id": "choice_c3", "source": "api" } +< event: tool_call ... +< event: done +``` + +### 断流重连 + +``` +# 原 POST 在 assistant_delta 中途断了 +# Bot 框架重新 POST,带同样的 Idempotency-Key: + +> POST /api/v1/loops/loop_a1/messages +> Idempotency-Key: 0e92... +> { "content": "list files in /tmp" } +< 200 text/event-stream +# Server 重放之前缓冲的事件 + 继续 live 流 +< event: started ... +< event: tool_call ... +< event: tool_result ... +< event: assistant_delta ... +< event: assistant_delta ... +< event: done ... +``` + +--- + +# 现状 (Implementation Status, 2026-05-26) + +> 这一节描述**当前实现**,区别于上面是**契约**。改 v1 实现前先看这里。 + +## 端点实现对照表 + +| 端点 | spec | 实现 | 单测 | web 已切到 v1? | 备注 | +|---|---|---|---|---|---| +| `POST /api/v1/loops` | ✅ | `api-v1.ts` | ✅ | NewLoopDialog | admin flag (knowledge_rw / mount_all_loops) 仍走 legacy `/api/loops` | +| `GET /api/v1/loops` | ✅ | `api-v1.ts` | ✅ | — | web sidebar 用 legacy 端点(带更多字段) | +| `GET /api/v1/loops/{id}` | ✅ | `api-v1.ts` | ✅ | — | 同上 | +| `DELETE /api/v1/loops/{id}` | ✅ | `api-v1.ts` | ✅ | — | web 用 legacy PATCH (archived=true) | +| `POST /api/v1/loops/{id}/messages` | ✅ | `api-v1.ts` | ⚠️ SSE 路径跳过 (无 provider) | enqueueMessage / onNew | `files` 字段 v1 不接受,有附件仍走 ws.send | +| `GET /api/v1/loops/{id}/events` | ✅ | `api-v1.ts` | ⚠️ SSE 路径跳过 | useLoopRuntime SSE 监听 | snapshot 只含当前 turn,不重放历史 | +| `POST /api/v1/loops/{id}/choices/{id}` | ✅ | `api-v1.ts` | ✅ | answerPermission, sendAnswers | | +| `POST /api/v1/loops/{id}/interrupt` | ✅ | `api-v1.ts` | ✅ | onCancel | | +| `POST/GET/DELETE /api/v1/me/tokens` | ✅ | `api-v1.ts` + `api-tokens.ts` | ✅ | SettingsPage | cookie-only 已强制 | + +测试覆盖:`server/test/api-v1.test.ts` 31 个,`e2e/loop.spec.ts` 含 2 个 v1 端到端(create + send/receive)。 + +## Web Hybrid 传输(当前 useLoopRuntime) + +为了不重写 ~1400 行的 SDK 消息 dispatch pipeline,当前是 hybrid: + +``` + ┌─ WS /ws/loop/:id ───────────────────────────────┐ +web ◄───receive ┤ │ + └─ SSE /api/v1/loops/{id}/events (sdk_message)────┘ + ↓ + uuid dedupe → dispatchMsg + +web ─────send ──── POST /api/v1/loops/{id}/messages (user text) + POST /api/v1/loops/{id}/interrupt (cancel) + POST /api/v1/loops/{id}/choices/{id} (permission/question answer) + +web ─── operator ── ws.send (set_goal, complete_goal, provider_select, + set_max_thinking_tokens, get_context_usage, + clear, queue_clear, queue_remove) +``` + +**uuid 去重**:WS + SSE 同时传 SDK 消息,按 `msg.uuid` 在 `dispatchMsg` 入口去重。非 uuid 消息(queue_update / viewers / provider)是幂等状态更新,重复 dispatch 没副作用。 + +**v1 SSE 的 `sdk_message` 事件**是 web 内部用的逃生口:把整个原始 SDK message 转发给 web。Bot 不应消费它(shape 不稳定)。 + +## 不在 v1 spec 内的活跃端点 + +| 端点 | 用途 | 现在谁在用 | +|---|---|---| +| `/api/loops/*` (legacy REST) | admin 创建 (knowledge_rw 等) / list / patch | web sidebar、admin 操作 | +| `/api/loops/:id/chat-history` | DM 历史 | 暂未启用 chat 功能 | +| `/ws/loop/:id` | history 回放 + initial state + operator 出站 | useLoopRuntime | +| `/ws/loop-status` | sidebar 状态点 | useLoopStatus | +| `/ws/kanban` | 看板 | useKanbanWebSocket | +| `/ws/loop/:id/term` | 终端 PTY | Terminal.tsx | +| `/ws/chat` | DM/频道 | useChatWebSocket(功能未开放)| +| `/api/personal/*`, `/api/admin/*`, `/api/serve/*` | web 私有功能 | SettingsPage、admin UI | + +## 已知 gap(待办,按优先级) + +1. **解耦 useLoopRuntime 的 WS 读** — 让 v1 SSE 成为唯一接收通道。需要先建: + - `GET /api/loops/:id/initial-state` 内部端点,返回 `{ provider, permission_mode, goal, history[] }` + - useLoopRuntime 用 fetch 拿 initial state → dispatch → 再开 SSE listen live + - 移除 WS read,WS 仅作 operator 出站 +2. **operator 功能 REST 化** — `clear / queue_clear / queue_remove / set_goal / complete_goal / set_max_thinking_tokens / get_context_usage / provider_select` 各 1 个内部 endpoint。web 完全去 WS。 +3. **`files` 字段加进 POST /messages** — 当前附件场景仍走 WS。需要 spec 增项:`files?: [{ path, content }]`。 +4. **`sdk_message` 事件计划淘汰** — 当上面 1+2+3 完成,web 可以直接消费 bot-facing v1 事件。届时 `sdk_message` 从 spec 移除。 +5. **Idempotency 持久化** — 当前 `Map` 在进程内存,server 重启丢。落 JSON 文件或 SQLite。 +6. **GET /loops/{id}/messages 历史** — 当前 spec 无此端点,bot 想看历史只能保存自己的事件流副本。要不要加?取决于是否有 bot 需求。 +7. **Token 创建支持 Bearer?** — 当前 spec 锁定 cookie-only,反对意见可重新讨论。 +8. **GET /events 的 snapshot 不包含历史 turn** — 只有当前在进行的 turn。如果 bot 重连想看刚结束的 turn,看不到。 + +## 关键文件 + 行数 + +| 文件 | 内容 | 行数 | +|---|---|---| +| `server/src/api-v1.ts` | 所有 v1 路由 + SSE 流 + idempotency + ID prefixing | ~620 | +| `server/src/api-tokens.ts` | SHA-256 hashed token store | ~140 | +| `server/src/session.ts` | LoopSession:`onMessage`/`notifyListeners`/`sendUserText`/`interrupt`/`answerPermission`/`answerQuestions`/`isBusy`/`getQueueLength`/`hasPendingPermission`/`hasPendingQuestion` | ~1420 | +| `server/src/loops.ts` | `createLoop` / `patchLoopMeta` / `getLoop` / `listLoops`;LoopMeta 类型含 `metadata` 字段 | ~1540 | +| `server/test/api-v1.test.ts` | bun 集成测试 31 个 | ~320 | +| `web/src/api.ts` | fetch-based v1 client(`createLoop` / `listApiTokens` / etc.)+ legacy LoopMeta shape 翻译层 | ~1750 | +| `web/src/useLoopRuntime.tsx` | hybrid WS+SSE 消费 + chat 出站 POST | ~1450 | +| `web/src/pages/SettingsPage.tsx` | API tokens 管理 UI(用 `/me/tokens`)| 大 | +| `e2e/loop.spec.ts` | Playwright 11 个测试,含 2 个 v1 端到端 | ~210 | +| `docs/api-v1.md` | 本文 | — | + +## 改这套东西的时候记得 + +1. **改 spec → 同步更新本节"已知 gap"和"实现对照表"**。 +2. **加 v1 endpoint** → spec 段先描述契约,再实现,再写测试,最后 web 集成。顺序很重要 —— 别先写实现再补 spec。 +3. **去掉 hybrid(任务 1)时**,注意 reconnect 路径:现在 WS reconnect 触发完整 history 回放;SSE-only 后需要 initial-state fetch + 单独的 history 回放或重订阅。 +4. **v1 spec 字段命名 = `snake_case`**,但**内部 LoopMeta 仍用 `camelCase`**。`metaToApi` 在 `api-v1.ts` 做翻译。新加字段时两边都要改。 +5. **`metadata` 不能给 sandbox 内的 agent 看见**(spec 承诺)—— 写 metadata 处理代码时确认它没被注入 sandbox env / prompt。 + +## 与 web 的关系(已部分实现) + +Web 的 chat 体验逐步迁到 v1: + +- ✅ "New Loop" 对话框 → `POST /api/v1/loops` +- ✅ 用户消息 → `POST /api/v1/loops/{id}/messages` +- ✅ 权限/问题弹窗 → `POST /api/v1/loops/{id}/choices/{id}` +- ✅ 取消按钮 → `POST /api/v1/loops/{id}/interrupt` +- ✅ Live SDK 事件 → `GET /api/v1/loops/{id}/events` SSE(与 WS 并行,uuid 去重) +- ❌ 历史回放、initial state、operator 写入 → 仍是 WS + +Web **永远不会迁**到 v1 的部分(per spec 边界): + +- Loop 列表小红点 / 状态指示(`/ws/loop-status`) +- 看板(`/ws/kanban`) +- 终端 PTY(`/ws/loop/:id/term`) +- Token 用量进度条(如果保留) +- DM / 频道(`/ws/chat`) +- 各种 admin / personal repo / 文件浏览端点 diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 00000000..b2123364 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,864 @@ + + + + +loopat architecture — the loop and its layers + + + + +
+ +

loopat architecture

+

+ A loop = context + AI + workdir, bound together in a per-loop bwrap sandbox. + Every path the agent sees is composed from a few host-side sources. This is the map. +

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
Operator host
+
~/.dashscope/config.json
+
workspace mounts (any host path)
+
host-secrets/<user>/
+
deploy-key, git-crypt.key
+
not bound into sandbox
+
+ + +
+
Knowledge team git · ro
+
context/knowledge/<docs>/
+
.loopat/claude/CLAUDE.md — L2 doctrine
+
.loopat/claude/skills/ — team skills
+
.loopat/claude/claude.json — team mcp
+
.loopat/sandboxes/<name>/ — toolchain catalog
+
+ + +
+
Notes team git · rw
+
inbox.md — workspace scratch
+
focus/<name>.md — task trees
+
memory/MEMORY.md — team memory (deliberate)
+
auto-commit on save
+
+ + +
+
Personal user git · git-crypt
+
memory/ — personal mem (SDK auto-recall)
+
.loopat/config.json — providers, mounts, shell
+
.loopat/vaults/ — catalog (hidden in sandbox)
+
default/ baseline
+
dev/ prod/ test/ … exactly ONE selected per loop
+
+ + +
+
Chat separate
+
threads with their own agent
+
no workdir, no sandbox
+
on /spawn-loop:
+
thread.jsonl → new loop's
+
/context/chat/<id>/ (ro)
+
+ +
+ +
composed into one mount namespace by bwrap
+ + +
+
+ Sandbox (one per loop, ephemeral) + paths visible to the agent +
+ +
+ + +
+
rw/loopat/loop/<id>/
+
this loop's ephemeral instance
+
+
workdir/ — git worktree, branch loop/<slug>-<id6>
+
.claude/ — SDK session state
+
.claude/CLAUDE.md ◀ ro-bind L2 from knowledge
+
.claude/skills/ ◀ ro-bind from knowledge
+
+
+ + +
+
ro/loopat/context/knowledge/
+
team git, read-only inside sandbox (AI never edits)
+
+ + +
+
rw/loopat/context/notes/
+
team git, auto-commit on save
+
+
inbox · focus · memory/MEMORY.md
+
+
+ + +
+
rw/loopat/context/personal/
+
user's personal git, auto-commit
+
+
memory/ — personal memory
+
.loopat/config.json — per-user config
+
.loopat/vaults/tmpfs catalog hidden
+
.loopat/vault/overlay selected vault's files
+
+
+ + +
+
rw/loopat/context/repos/<name>/
+
workspace's registered code repos; worktree origin
+
+ + +
+
ro/loopat/context/chat/<id>/
+
only when spawned from a chat thread — seed history
+
+ + +
+
tmpfs$HOME
+
fresh tmpfs + member-mounts re-overlaid
+
+
e.g. .loopat/vault/.ssh$HOME/.ssh
+
+
+ + +
+
ro/usr /etc /lib /bin /sbin
+
host system paths (so binaries work). /tmp shared.
+
+ +
+ + +
+
◆ Agent — driving this loop
+
+ + +
+

▼ READ sources concatenated each turn

+
L1 doctrine bundled templates/CLAUDE.md
+
L2 team knowledge/.loopat/claude/CLAUDE.md
+
L3 project workdir/CLAUDE.md
+
L4 runtime server-computed (id, title, vault, sandbox)
+
skills team knowledge/.loopat/claude/skills/
+
mcp tools team claude.json
+
personal mem SDK auto-recalls each turn
+
team mem reads MEMORY.md on complex turns
+
credentials /personal/.loopat/vault/* (one vault only)
+
chat history /context/chat/<id>/ (if spawned from chat)
+
+ + +
+

▲ WRITE destinations (server auto-commits)

+
workdir/* auto-commit on loop branch
+
notes/inbox append to team scratch
+
notes/focus task trees
+
notes/memory agent auto-promotes from personal when team-relevant
+
personal/memory SDK-managed observations
+
/vault/* rare — credential helper refresh
+
+ never writes + + knowledge/, other repos/<x> +
+
+ +
+
+
+ + +
+

distillation — knowledge condenses upward

+
+
+
personal/memory/
+
private observations
+
+
+ + auto-promote
+ or curate +
+
+
notes/memory/
+
team-shared, deliberate
+
+
+ + distill
+ loop +
+
+
knowledge/
+
canonical, human-reviewed
+
+
+
+ and separately: loop/workdir/repos/<name>/ (merge the loop's branch back) +
+
+ +
+ + +
+
ro read-only bind — agent sees, cannot write
+
rw read-write bind — agent's writes hit disk
+
tmpfs empty fs mounted on top to hide what's underneath
+
overlay multiple host files composed into one sandbox path
+
+ + +

Loop = Sandbox × Vault

+

The one thing to internalize. Two orthogonal axes, picked independently at spawn.

+ +
+
+

Sandbox

+
"what tools the loop can use"
+
owned by: admin / team (knowledge git)
+
contains: mise toolchain · shell · mcp servers
+
versioned by: knowledge git commit
+
+
frontend — node, bun, jira-mcp, figma-mcp
+
backend — go, python, redis-cli
+
sre — kubectl, aws-cli, datadog-mcp
+
+
+
×
+
+

Vault

+
"with what credentials it runs"
+
owned by: member / individual (personal git, git-crypt)
+
contains: apiKey · ssh · tokens · provider keys
+
isolated by: bwrap overlay (other vaults invisible)
+
+
default — shared baseline
+
dev — dev env credentials
+
prod — prod credentials (you specifically have)
+
+
+
+ + +

Read path — what the agent learns from

+ + + + + + + + + + + + + + + + +
LayerSource on hostLoaded byScope
L1 doctrineserver/templates/CLAUDE.mdsystem-prompt builder, always-onsandbox basics, path conventions
L2 teamknowledge/.loopat/claude/CLAUDE.mdro-bind to .claude/CLAUDE.md, SDK loadsworkspace conventions
L3 projectworkdir/CLAUDE.mdSDK loads from cwdrepo-specific conventions
L4 runtimeserver-computed (id, vault, sandbox, …)concat into system promptper-turn variables
skillsknowledge/.loopat/claude/skills/ro-bind to .claude/skills/, SDK discoverscallable procedures
mcpknowledge/.loopat/claude/claude.jsonpassed to SDK at spawnjira, github, datadog, …
personal memorypersonal/memory/SDK auto-recall (per .claude/settings.json)your habits, user facts
team memorynotes/memory/doctrine tells agent to read MEMORY.mdgotchas, conventions
chat threadchat/<tid>/history.jsonlro-bind into sandbox if loop spawned from chatseed conversation
credentialspersonal/.loopat/vaults/<v>/walked & overlay-mounted at .loopat/vault/apiKey, ssh, tokens
+ + +

Write path — where the agent's output lives

+ + + + + + + + + + + + + + +
PathPersistenceNotes
workdir/*auto-commit on loop/<slug>-<id6>the loop's actual work product
notes/inbox.mdauto-commit to team notes gitappend-only scratchpad
notes/<focus>.mdauto-commitsmall markdown task trees
notes/memory/<name>.md + indexauto-commitagent auto-promotes from personal when topic is workspace-wide
personal/memory/<name>.mdSDK-managed, auto-commitprivate observations
/vault/*git-crypt encrypted at commitrare — credential rotation paths
knowledge/NEVER — flows back via distillation
repos/<x>/NEVER directly — only via the loop's workdir/
+ + +

Boundaries the sandbox enforces

+ + + + + + + + + + + + +
Agent attempts to …What stops it
read another user's secretspersonal/<other-user>/ isn't bound into this sandbox at all
read another vault's keyshost-side .loopat/vaults/ is tmpfs'd; only selected vault overlays as /.loopat/vault/
escape via a symlink in the vaultwalkVaultFiles checks realpath against personal/<user>/ and refuses targets outside
modify team knowledgeknowledge/ is ro-bind; writes return EROFS
commit into a sibling repo's mainlinerepos are rw but workflow rules + worktree-branch isolation steer commits onto loop/… only
see the host filesystem outside /loopatsandbox root is fresh tmpfs; only explicitly-bound paths exist
+ + +

Why this shape

+
+
+

Filesystem-first, no DB

+

Every artifact is a file. State of a loop, vault contents, memory, branch — all ls-able. No "what does the DB say" debugging.

+
+
+

Loop ephemeral, context persistent

+

/loopat/loop/<id>/ dies with the loop. /loopat/context/ survives — branch + memory + notes remain.

+
+
+

Capability ⊥ identity

+

Sandbox × vault. Same engine powers "alice testing the frontend" and "carol fighting a prod fire" — different cells of the matrix.

+
+
+

Read down, write up — slowly

+

Knowledge flows downward (consumed). Writing back to knowledge/ takes a distill loop, not a one-line append. Friction is intentional.

+
+
+

Sandbox is the membrane

+

Nothing crosses implicitly. Every path the agent sees is a --bind line in buildBwrapArgs. The host can sleep through misbehavior.

+
+
+ + +

Where to look in the code

+ + + + + + + + + + + + + + +
ConceptFile(s)
sandbox composition (buildBwrapArgs)server/src/bwrap.ts
vault catalog + symlink validationserver/src/vaults.ts
loop lifecycle + auto-initserver/src/loops.ts
L1 doctrine (bundled)server/templates/CLAUDE.md
memory recall configserver/src/loops.ts (.claude/settings.json per loop)
auto-commit on writesserver/src/workspace.ts (vaultWrite)
chat → loop spawnserver/src/chat.ts
sandbox toolchain specserver/src/sandboxes.ts, knowledge/.loopat/sandboxes/<name>/
+ +
+ + + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..fd275e5b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,283 @@ +--- +title: loopat architecture +tags: [loopat, architecture, overview] +status: living doc +--- + +# loopat architecture + +> **Loop = context + AI + workdir**, bound together in a per-loop bwrap sandbox. +> Every path the agent sees is composed from a few host-side sources. + +## The big picture + +**The centerpiece diagram lives in [architecture.html](./architecture.html)** — +a single self-contained HTML page with the layered overlay visualization, +the orthogonal `sandbox × vault` axes, and all the arrows wired up. +Open it in any browser; no build step, no dependencies. + +This markdown file holds the **textual supplements** to that diagram: +read/write path tables, code map, and the philosophy notes — things that grep +better than they render. + +For the **`.claude/` composition model** specifically — how team / profile / +personal / repo tiers merge into the loop's `.claude/` and how the SDK reads +it — see [composition.md](composition.md). + +--- + +## Loop = Profiles × Vault (in words) + +The one abstraction to internalize: + +| Axis | What it picks | Who owns it | Storage | +|---|---|---|---| +| **Profiles** | the **`.claude/` tiers** to compose (toolchain + plugins + MCP + skills + agents + CLAUDE.md) | admin / team | `knowledge/.loopat/profiles//.claude/` (opt-in, 0..N per loop) | +| **Vault** | the **credentials** the loop runs as (apiKey, ssh, tokens) | individual / member | `personal//.loopat/vaults//` | + +See [composition.md](composition.md) for the full five-tier merge story (workspace · profiles · personal · project · local). + +Examples that fall out: + +- alice spawns `[frontend] × dev` — frontend profile tools, her dev credentials +- alice spawns `[frontend] × test` — same profile, different identity +- bob spawns `[frontend, oncall] × test` — two profiles unioned, his own test creds (alice can't see) +- carol spawns `[sre] × prod` — different profile, her prod credentials + +Same engine, four cells of the matrix. + +--- + +## Read path — what the agent learns from + +Per turn, the agent's effective "context" is assembled from layered sources: + +| Layer | Source on host | Loaded by | Scope | +|---|---|---|---| +| **L1 doctrine** | `server/templates/CLAUDE.md` | system-prompt builder, always | sandbox basics, path conventions | +| **L2 team** | `knowledge/.loopat/.claude/CLAUDE.md` | ro-bind to `.claude/CLAUDE.md`, SDK loads as user-tier | workspace conventions | +| **L3 project** | `workdir/CLAUDE.md` | SDK loads from cwd | repo-specific conventions | +| **L4 runtime** | server-computed (loop id, title, branch, driver, vault, sandbox) | concatenated into system prompt | per-turn variables | +| **skills** | `knowledge/.loopat/.claude/skills/` | ro-bind to `.claude/skills/`, SDK auto-discovers | callable procedures | +| **mcp** | `knowledge/.loopat/.claude/settings.json` | passed to SDK at spawn | external tools (jira / github / …) | +| **personal memory** | `personal/memory/*.md` | SDK auto-recall via `.claude/settings.json` | your habits, user-specific facts | +| **team memory** | `notes/memory/MEMORY.md` + files | doctrine tells the agent to read on complex turns | gotchas, conventions | +| **chat thread** | `chat//history.jsonl` | ro-bound at `/context/chat//` (only when spawned from chat) | seed conversation | +| **credentials** | `personal/.loopat/vaults//*` | walked + overlay-mounted at `.loopat/vault/` | apiKey, ssh, tokens | + +The L1/L2/L3 stack is concatenated identically across loops on the same +workspace, maximizing prompt-cache hit rate. + +--- + +## Write path — where the agent's output lands + +| Path | Persistence | Notes | +|---|---|---| +| `workdir/*` | auto-commit on `loop/-` branch | the loop's actual work product | +| `notes/inbox.md` | auto-commit to team notes git | append-only scratchpad | +| `notes/.md` | auto-commit | small markdown task trees | +| `notes/memory/.md` + index | auto-commit | **agent auto-promotes** from personal when topic is workspace-wide | +| `personal/memory/.md` | SDK-managed, auto-commit | private observations | +| `/vault/*` | git-crypt encrypted at commit | rare — credential rotation paths | + +**Never writes:** `knowledge/` (ro by design) and other `repos//` (only the +loop's own `workdir/`). These restrictions are mechanical (`--ro-bind` for +knowledge, behavior rules + worktree-branch isolation for repos) — not +trust-based. + +--- + +## Distillation — knowledge condenses upward + +Three deliberate promotions, each more friction'd than the last: + +| From | To | Trigger | +|---|---|---| +| `personal/memory/` | `notes/memory/` | the agent auto-promotes when an observation generalizes; you can also curate manually | +| `notes/*` | `knowledge/` | you spawn a *distill loop* — its job is to read accumulated notes and propose `knowledge/` edits; you review like a PR | +| `loop/workdir/` | `repos//` | you merge the loop's branch back when work is done | + +Continuous capture into ephemeral surfaces; deliberate promotion into durable +ones. AI fills the bottom; humans curate upward. + +--- + +## Boundaries the sandbox enforces + +| Agent attempts to … | What stops it | +|---|---| +| read another user's secrets | `personal//` isn't bound into this sandbox at all | +| read another vault's keys | host-side `.loopat/vaults/` is tmpfs'd; only the selected vault overlays as `/.loopat/vault/` | +| escape via a symlink in the vault | `walkVaultFiles` checks `realpath` against `personal//` and refuses targets outside | +| modify team knowledge | `knowledge/` is `ro-bind`; writes return EROFS | +| commit to another repo's mainline | repos are rw but workflow rules + worktree-branch isolation steer commits onto `loop/…` only | +| see the host filesystem outside `/loopat` | sandbox root is a fresh tmpfs; only explicitly-bound paths exist | + +The first three are vault-specific; the last three are baseline. + +--- + +## Why this shape (philosophy) + +1. **Filesystem-first, no DB.** Every artifact is a file. Loop state, vault + contents, memory, branch — all readable with `ls` and `cat`. + +2. **Loop ephemeral, context persistent.** `/loopat/loop//` dies with the + loop. Everything under `/loopat/context/` survives — branch + memory + + notes remain. + +3. **Capability ⊥ identity.** Sandbox × vault. Same engine powers + "alice testing the frontend" and "carol fighting a prod fire" — different + cells of the same matrix. + +4. **Read down, write up — slowly.** Knowledge flows downward (everyone + consumes shared knowledge). Writing back to `knowledge/` requires a + distillation loop, not a one-line `echo >>`. The friction is intentional. + +5. **The sandbox is the membrane.** Nothing crosses implicitly. Every path + the agent sees is a `--bind` line in `buildBwrapArgs`. The host can sleep + through any AI misbehavior because the agent's horizon is a 12-line argv + list. + +--- + +## Where to look in the code + +| Concept | File(s) | +|---|---| +| sandbox composition (`buildBwrapArgs`) | `server/src/bwrap.ts` | +| vault catalog + symlink validation | `server/src/vaults.ts` | +| loop lifecycle + auto-init | `server/src/loops.ts` | +| L1 doctrine (bundled) | `server/templates/CLAUDE.md` | +| memory recall config | `server/src/loops.ts` (`.claude/settings.json` per loop) | +| auto-commit on writes | `server/src/workspace.ts` (`vaultWrite`) | +| chat → loop spawn | `server/src/chat.ts` | +| `.claude/` tier composition | `server/src/compose.ts`, `server/src/profiles.ts` | +| profile catalog | `knowledge/.loopat/profiles//.claude/` | + +See `docs/sandbox.md` for deeper bwrap mechanics and the three-tier mount authority detail. + +--- + +## Team Claude config + injection paths + +How CLAUDE.md / skills / MCP servers / OAuth credentials reach the Claude +process running inside a loopat sandbox. + +### Where team Claude config lives + +All team-shared Claude Code config lives under the reserved namespace +`knowledge/.loopat/.claude/`: + +``` +LOOPAT_HOME/ +├── config.json # workspace runtime config (knowledge/notes/repos) +├── personal//.loopat/ +│ ├── config.json # per-user config (providers, default, shell) +│ └── vaults// # one or more named credential bundles (default, dev, prod, ...) +│ ├── envs/ # auto-injected env var; also feeds ${VAR} in apiKey +│ └── mounts/home//... # auto-bound at sandbox $HOME//... +└── context/knowledge/ + └── .loopat/claude/ # team-shared Claude config + ├── CLAUDE.md (optional) # team prompt supplement + ├── claude.json (optional) # { mcpServers, ... } + └── skills/ (optional) # team SKILL.md folders +``` + +Rules: + +- Everything under `.loopat/` (in either `knowledge/` or `personal//`) is + **platform-conventioned**: loopat knows the path and the semantics. Content + ownership is still the user/team's; `.loopat/` only marks "loopat will look + here and do something." +- Everything else under `knowledge/` is plain team docs; everything else under + `personal//` is user-owned freeform space. +- Workspace `config.json` is **team-shared** (knowledge/notes/repos URLs only). + Per-user fields (providers, default, shell) live in + `personal//.loopat/config.json`. There is no `mounts` or `envs` field — + those are conventional, derived from vault filesystem layout (see next bullet). + Operator `mounts` live in workspace `config.json` and can name any host path + (cross-user shared caches like `/etc/pki/ca-trust`). +- `personal//.loopat/vaults//` has two convention directories that + drive automatic sandbox delivery at spawn time: + - `envs/` — file content is injected as env var `$NAME` (also feeds + `${VAR}` substitution in provider `apiKey`). + - `mounts/home//...` — each top-level entry is `--bind`'d into the + sandbox at `$HOME//...`. + Each loop picks one active vault (`meta.config.vault`, default `"default"`). + No symlink, no `/loopat/context/vault` entrypoint — AI sees a configured + machine, not a "vault" concept. + +### Five injection paths + +Five distinct pieces, three injection mechanisms. + +| Piece | Source | How it reaches sandbox claude | Loaded by | +|---|---|---|---| +| **Platform doctrine** (always) | `server/templates/CLAUDE.md` | `systemPrompt.append` via SDK | loopat | +| **Team CLAUDE.md** (optional) | `knowledge/.loopat/.claude/CLAUDE.md` | ro-bind → `$CLAUDE_CONFIG_DIR/CLAUDE.md` | Claude Code (user-tier) | +| **Project CLAUDE.md** (optional) | `/CLAUDE.md` | nothing — exists in workdir | Claude Code (project-tier) | +| **Skills** (optional) | `knowledge/.loopat/.claude/skills/` | ro-bind → `$CLAUDE_CONFIG_DIR/skills/` | Claude Code (user-tier) | +| **MCP servers** (optional) | `knowledge/.loopat/.claude/settings.json` | server reads → SDK `mcpServers` option (as-is) | loopat | +| **MCP OAuth tokens** (optional) | host `~/.claude/.credentials.json` | ro-bind → `$CLAUDE_CONFIG_DIR/.credentials.json` | Claude Code | +| **Runtime block** (always) | computed | `systemPrompt.append` via SDK | loopat | + +Mechanism summary: + +1. **`systemPrompt.append`** — server reads file, concatenates into the SDK's + `query({ systemPrompt: { type: 'preset', preset: 'claude_code', append } })`. + Used for content loopat must always inject (platform doctrine, runtime). +2. **ro-bind to `$CLAUDE_CONFIG_DIR/*`** — bwrap mount; Claude Code natively + auto-discovers as if those files were in `~/.claude/`. Used for team + supplements that don't need transformation. +3. **SDK option pass-through** — server reads, passes object via + `query({ mcpServers })`. Currently only used for team MCP config; could + move to bind path if we never need server-side transformation. + +`settingSources: ["user", "project"]` is what makes Claude Code load both +`$CLAUDE_CONFIG_DIR/CLAUDE.md` (user) and `/CLAUDE.md` (project). + +### MCP server config schema + +`knowledge/.loopat/.claude/settings.json` shape (mirrors `.claude.json`): + +```json +{ + "mcpServers": { + "": { + "type": "http" | "sse" | "stdio", + "url": "...", // http/sse + "command": "...", "args": [...], "env": {...}, // stdio + "headers": { "Authorization": "Bearer " } // http/sse + } + } +} +``` + +Auth styles: + +- **Static (API key / PAT)** — put the literal token in `env`/`headers`. + Since this file lives in `knowledge/` (team-shared), only commit + shareable tokens. For per-user static tokens, future work is a personal + MCP config at `personal//.loopat/claude/claude.json`. +- **OAuth** — no static token. Claude Code's built-in MCP OAuth client + uses `~/.claude/.credentials.json` (host driver's file, ro-bound into + the sandbox). On first MCP request, Claude Code reads existing tokens; + refresh-on-expiry currently fails because the bind is ro (separate + flow needed). + +### Permission model gotcha (canUseTool) + +Built-in tools (Read/Bash/...) run under `permissionMode: "bypassPermissions"` ++ `allowDangerouslySkipPermissions: true` — they skip the `canUseTool` +callback entirely. + +**MCP tools always route through `canUseTool`**, regardless of bypass flags. +The callback must return a SDK-schema-valid result: + +- `{ behavior: "allow", updatedInput: }` — echo the input back +- `{ behavior: "deny", message: }` + +A bare `{ behavior: "allow" }` works for built-ins (which skip the callback) +but trips a ZodError the first time an MCP tool is invoked. diff --git a/docs/assets/logo-orig.png b/docs/assets/logo-orig.png new file mode 100644 index 00000000..dacd8372 Binary files /dev/null and b/docs/assets/logo-orig.png differ diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 00000000..d15f3491 Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/composition.md b/docs/composition.md new file mode 100644 index 00000000..60001c50 --- /dev/null +++ b/docs/composition.md @@ -0,0 +1,367 @@ +--- +title: .claude composition +tags: [loopat, .claude, composition] +status: living doc +--- + +# `.claude` composition + +> **Loopat extends Claude Code's `.claude/` config model with two extra tiers +> for team sharing — same files, same fields, more layers.** Every artifact +> Claude Code natively understands works in loopat without changes; you just +> get four more places to put them. + +

+ five .claude tiers compose into one loop runtime +

+ +When a team shares an AI workspace, configuration multiplies fast: each role +needs different skills, each on-call rotation wants different MCP servers, each +person has their own credentials and habits. The standard answer is "everyone +runs their own CLI with their own `~/.claude/`" — fine for one person, painful +across a team. + +Loopat's answer: **don't invent a new config system; just add tiers to the one +Claude Code already has.** Skills, agents, plugins, MCP servers, hooks — all +live in `.claude/` directories at every tier, and loopat merges them before the +SDK starts. The agent sees a single CC-native `.claude/` and doesn't know there +were ever five sources. + +--- + +## Mental model in one line + +Claude Code ships with **three setting source tiers — user · project · local**. +**Loopat adds two more — workspace · profile.** Same `.claude/` shape at every +tier. The first three are merged by loopat into the SDK's user tier; the last +two are read by the SDK directly from the workdir. + +| Tier | Native to | Lives at | Scope | How it reaches the SDK | +|---|---|---|---|---| +| **workspace** | loopat | `knowledge/.loopat/.claude/` | the whole team | merged into user tier by loopat | +| **profile** | loopat | `knowledge/.loopat/profiles//.claude/` | opt-in role / mode | merged into user tier by loopat | +| **user (personal)** | Claude Code | `personal//.loopat/.claude/` | one team member | merged into user tier by loopat | +| **project** | Claude Code | `workdir/.claude/` | one repo | read directly by SDK | +| **local** | Claude Code | `workdir/.claude/*.local.*` | one local checkout | read directly by SDK | + +Override order is **strongest at the bottom of the table** — `local` beats +everything, and within the merged user tier `personal` beats every other +loopat-managed source. + +--- + +## CC-compatible by design + +If you have ever configured Claude Code, you have already learned loopat. + +- **Same directory layout.** `.claude/settings.json`, `.claude/CLAUDE.md`, + `.claude/skills//SKILL.md`, `.claude/agents/.md` — everywhere. +- **Same setting fields.** `enabledPlugins`, `mcpServers`, + `extraKnownMarketplaces`, `hooks`, `permissions`, `model` — they all + behave exactly like Claude Code's docs describe. +- **Same conventions for "drop in to enable".** A `SKILL.md` in any + `.claude/skills//` works. An `.md` in any `.claude/agents/` works. + No new schemas. + +Loopat invents nothing inside `.claude/`. The only thing loopat invents is +**where additional `.claude/` directories can live** — namely the workspace +and profile tiers. Once merged, the output is a perfectly ordinary `.claude/` +that any Claude Code reader (CLI, SDK, docs example) understands. + +The practical payoff: when CC adds a new field to `.claude/settings.json`, +loopat picks it up for free. When you onboard a teammate, they already know +half the config from prior CC experience. + +--- + +## How the SDK sees `.claude/` + +A common assumption: *"the SDK is a thin wrapper; it doesn't read filesystem +config."* That is wrong — **the Claude Agent SDK is built on the same engine +as the Claude Code CLI, and it fully understands `.claude/`**. The +`settingSources` option controls which tiers it reads: + +```ts +query({ + options: { + settingSources: ["user", "project", "local"], + // SDK auto-loads $CLAUDE_CONFIG_DIR/* (user tier), + // /.claude/* (project tier), and *.local.* (local tier). + } +}) +``` + +Loopat's job is therefore **not** to feed config into the SDK. Loopat's job +is to **assemble the merged `.claude/` directory and point `CLAUDE_CONFIG_DIR` +at it**. The SDK then walks the directory the same way the CLI would. Two +narrow channels stay outside this filesystem path — both because the sandbox +isolates host state that would otherwise carry the data: + +- **MCP server credentials.** The MCP server *configuration* (transport, + command, headers, env keys) lives in `.claude/settings.json` like + everything else — it gets merged and lands on disk in + `loops//.claude/settings.json`. But **credentials** (api keys, OAuth + bearer tokens) come from the loop's selected vault at spawn time. Loopat + reads the merged server list, injects the vault credentials into each + server's `env` / `headers`, and passes the augmented list via the + `mcpServers:` SDK option. Secrets never get written to settings.json on + disk. See the MCP deep-dive below. +- **The loopat builtin plugin.** Loopat ships one bundled plugin that lives + inside the loopat install directory, not in CC's plugin cache. It's + passed via the `plugins:` SDK option. All other plugins are declared in + `enabledPlugins` and resolved natively by the SDK from + `~/.claude/plugins/` (ro-bound into the sandbox). + +Everything else — skills, agents, hooks, `CLAUDE.md`, marketplace plugin +selection — is read by the SDK directly from the composed `.claude/`. + +--- + +## How the merge works + +Each tier is a complete, standalone `.claude/` directory. Loopat walks them +in order (workspace → profile-1 → … → profile-N → personal) and merges by +content type: + +| Content | Merge rule | +|---|---| +| `settings.json` | Deep shallow union per top-level key. `enabledPlugins`, `mcpServers`, `extraKnownMarketplaces`, `hooks` merge by sub-key — **later tier wins per key**. So a personal tier can flip `enabledPlugins["foo@bar"]` to `false` and override a team default. | +| `CLAUDE.md` | Concatenated in tier order, with `## ` section headers. Each tier's doctrine layers on top of the previous. | +| `skills//` | Symlink union. Same-name skill in a later tier shadows the earlier one. | +| `agents/.md` | Symlink union, same rule. | +| `mise.toml` / `mise.lock` | TOML table-level union — `[tools]` and `[env]` sections each merge by key. | + +The result is written to `loops//.claude/` **once, when the loop +is created** — and from then on **the snapshot is immutable**. Later admin +pushes to knowledge don't change what an existing loop sees. This is what +makes loops reproducible: spawn the same loop tomorrow and it loads the +same plugin set, the same skills, the same CLAUDE.md, with the same +contents as the day it was created. (See ["Plugin version lock"](#plugin-version-lock-the-loop-snapshot) +below for how the snapshot also pins specific plugin versions, not just +which plugins.) + +**Inside the sandbox, two `.claude/` directories actually exist** — and +that's by design: + +1. **`loops//.claude/`** — the merged user tier, the loopat-composed + source of truth, **frozen at loop creation**. Mounted at the SDK's + `CLAUDE_CONFIG_DIR`. +2. **`/.claude/`** — the repo's own `.claude/`, read as project + tier (and local tier for `*.local.*` files) directly by the SDK at every + spawn. Loopat does not merge this; the repo contributes whatever it + contributes, and editing it takes effect on the next spawn. + +There is no third `.claude/` — **the sandbox does not see your host +machine's `~/.claude/`**. The sandbox `$HOME` is a fresh overlay with an +empty lower layer, so any host-side CC configuration you have outside of +the workspace stays outside. This is intentional: loops are reproducible +because they don't depend on whatever happens to be in your home directory. + +(There is one host-side exception: the directory `~/.claude/plugins/` is +ro-bound wholesale so the SDK can resolve marketplace plugins. Sibling +files like `~/.claude.json` and `~/.claude/.credentials.json` stay +invisible — see the next section.) + +--- + +## Plugin version lock: the loop snapshot + +`enabledPlugins` in `settings.json` only carries an **on/off switch** — it +doesn't say *which version* of each plugin to load. Without a version pin, +a member running `claude plugin update` on the host would silently change +what an already-created loop sees on its next spawn. That violates +reproducibility. + +CC already provides the right primitive: **`~/.claude/plugins/installed_plugins.json`**. +It records, per spec, the `version`, the marketplace's `gitCommitSha` at +install time, and the local `installPath`. Personal CC users don't think +of it as a lockfile, but it is one — it's the only place the *specific +code* of each installed plugin is identified. + +Loopat treats `installed_plugins.json` as a **CC-native lockfile** and +brings it into the tier merge: + +- **`knowledge/.loopat/.claude/plugins/installed_plugins.json`** — team + lock, committed by admin +- **`personal//.loopat/.claude/plugins/installed_plugins.json`** — + personal override, never pushed to team +- merged per-spec, last-wins (same rule as `enabledPlugins`) +- snapshot written to **`loops//.claude/plugins/installed_plugins.json`** + at loop creation, never changes +- bwrap file-binds this snapshot **over** the sandbox's host installed + state, so the SDK reads pinned versions + +### What "version" and "sha" mean in this model + +| Field | Role | +|---|---| +| `version` | **canonical identifier** — used by CC to name the cache directory (`~/.claude/plugins/cache////`) and to decide "is this already installed?". | +| `gitCommitSha` | **audit metadata** — records what marketplace commit produced this install. Used for warnings and bug-triage, not lookups. | + +This mirrors CC's own design intent: **plugin authors are trusted to bump +the version when code changes**. If they don't, two different shas can +share a version label — the second `install` overwrites the first in +cache. Loopat doesn't try to police this contract; if authors break it, +sha-mismatch warnings surface during spawn so users can investigate. + +### Three principles, one mechanism + +1. **Old loops never change** — the snapshot at `loops//.claude/plugins/installed_plugins.json` + is immutable. Even after admin pushes a new lock or member runs + `claude plugin update`, an existing loop's pinned versions don't move. + The sandbox bind ensures the SDK reads the snapshot's `installPath`, + which points into the host's cache (`~/.claude/plugins/cache/...//`). + As long as that cache directory survives (which it does unless someone + explicitly `claude plugin uninstall`'s it), the loop runs the same code + forever. +2. **Admin gates team-wide use** — without admin's commit to + `knowledge/.loopat/.claude/plugins/installed_plugins.json` (or to + `settings.json`'s `enabledPlugins`), no member's *new* loop will install + a new plugin. Old loops are already frozen. +3. **Personal can override locally** — a user can put their own + `personal/.loopat/.claude/plugins/installed_plugins.json` to pin a + different version of any spec; their own future loops use it, the team + stays unaffected. + +### What happens when the host can't honor the pin + +Spawning a loop whose lock says `cicd@dashscope-skills version 0.1.0`: + +- **Host has 0.1.0 in cache** → silent fast path. SDK reads snapshot → + loads cache/.../0.1.0/. ✓ +- **Host doesn't have 0.1.0** (member's marketplace clone has advanced; + CC's `install` would now produce a different version) → loopat runs + `claude plugin install`, then checks the resulting `version`. If it + doesn't match the pin, **spawn fails with a clear message** telling the + user how to recover (admin bumps the team lock, or member manually + restores the pinned version via marketplace clone checkout). This is + **fail-loud, not auto-heal** — option (a) in our design discussions. +- **Future enhancement**: option (b), where loopat performs the + marketplace checkout dance automatically to install the exact sha. Not + yet implemented; the manual recovery path is fine as long as version + drift is rare. + +--- + +## What about `~/.claude.json`? + +`~/.claude.json` is Claude Code's host-side application state file — +**different from `~/.claude/settings.json`**. It tracks: + +- account / OAuth state, onboarding completion, notification history +- top-level `mcpServers` — globally-registered MCP servers +- `projects.` — per-directory state including `mcpServers` (what + `claude mcp add` writes), `allowedTools`, trust-dialog acceptance, + session usage stats + +In other words, it's the file CC mutates as you use the CLI on your host +machine: every `claude mcp add`, every project you've ever opened, every +permissions choice. **Loopat never reads or writes `~/.claude.json`**, and +the loopat sandbox can't see it (sandbox `$HOME` is an empty overlay). + +What this means for your mental model: + +- **Adding an MCP server via `claude mcp add` on your host does NOT make + it available in loops.** That command writes to `~/.claude.json`, which + the sandbox doesn't see. +- **To use an MCP server in a loop, declare it in some `.claude/settings.json` + tier** (workspace / profile / personal). Loopat will merge it and the SDK + will start it. +- **Host CC and loopat loops have disjoint MCP sets.** This is a feature: + loops are reproducible regardless of what host CC happens to know about. + +The one thing that *is* shared between host and sandbox is the **plugin +install cache** (`~/.claude/plugins/` — a directory, not the +`.claude.json` file). Loopat ro-binds it so the SDK inside the sandbox can +resolve `enabledPlugins` natively. Plugin install state is a small, +file-tree-shaped global; mixing it doesn't compromise the +reproducibility story the way MCP-on-host-CLI would. + +--- + +## Putting things in `.claude/`: what gets tiered + +Anything that lives inside `.claude/` automatically gets the five-tier +treatment. That includes everything Claude Code natively supports, plus +small loopat-side extensions: + +- **Skills** — `.claude/skills//SKILL.md` — reusable named procedures + invoked as `/`. Drop one in any tier; it becomes available + to every loop that selects that tier. +- **Subagents** — `.claude/agents/.md` — delegated agents the main + Claude can hand work to. Frontmatter declares `description`, `tools`, + `model`; body is the agent's system prompt. +- **MCP servers** — declared in `.claude/settings.json` under `mcpServers`. + Loopat injects vault credentials at spawn so secrets never sit in + config files. +- **Plugins** — declared in `.claude/settings.json` under `enabledPlugins` + and (when needed) `extraKnownMarketplaces`. The plugin itself stays in + Claude Code's standard plugin cache; loopat ensures it gets installed + on the host and visible to the sandbox. +- **Hooks** — declared in `.claude/settings.json` under `hooks`. Scripts + triggered on `SessionStart`, `PreToolUse`, `PostToolUse`, etc. +- **`CLAUDE.md`** — team doctrine, role expectations, system prompt + fragments. Concatenated across tiers. **Profile authors: lead with a + one-line description** so the New Loop dialog and per-loop header can + surface what each profile is for. Two formats supported: + + ```markdown + --- + description: ML training oncall — sls + spectrum + dashscope CLI ready + --- + + # ML Test 角色 + ... + ``` + + Or the legacy form (kept for backward compat): + + ```markdown + # ML Test 角色 — generate and verify mock data + ... + ``` + + Frontmatter `description:` wins when present; otherwise loopat falls + back to the first heading text (stripped of `#`). Frontmatter is the + recommended form: it survives doctrine edits that rewrite the heading. +- **Mise toolchain** — `.claude/mise.toml` + `.claude/mise.lock`. Loopat's + own addition: pins the version of every tool the loop's shell will see. + Team can pin Node, profile can add Python, personal can override a single + version — same merge model. +- **Other CC fields** — anything else in `.claude/settings.json` + (`permissions`, `model`, output styles, statusline, future fields) + gets the same per-key tier union for free. + +The rule of thumb: **want to share something across the team or selectively +across roles? Express it as a `.claude/` artifact and put it in the tier +that owns it.** Loopat handles the rest. + +--- + +## Capability reference + +For each capability: what it is, where its definition lives, how it gets +turned on, how credentials are handled (if any), how plain CC / the SDK / +loopat each activate it, and where it lands inside the sandbox. + +| | **Skill** | **Subagent** | **MCP server** | **Plugin** | **Hook** | **Mise toolchain** | +|---|---|---|---|---|---|---| +| **What it is · when to use it** | A named procedure invocable as `/`. Stable, repeated workflows you want the human to trigger by name. | A delegated agent with its own prompt, tool restrictions, and model. The main agent hands work off via the `Task` tool. | A long-running process that exposes external tools (Jira, GitHub, internal APIs) to Claude over stdio / HTTP / SSE. | A distributable bundle of skills + agents + MCP + hooks, with marketplace metadata for cross-team sharing. | A script triggered on lifecycle events (`SessionStart`, `PreToolUse`, `PostToolUse`, …). | Pinned tool versions (Node, Python, etc.) so every loop's shell sees the same toolchain. *(loopat extension)* | +| **Where to define** | `.claude/skills//SKILL.md` plus optional siblings in the same directory. | `.claude/agents/.md` — single file, frontmatter + body. | `.claude/settings.json` → `mcpServers.: { type, command, args, headers, env, … }`. | `.claude/settings.json` → `enabledPlugins["foo@market"]: true` (+ `extraKnownMarketplaces` if not built in). | `.claude/settings.json` → `hooks.: [{ matcher, hooks: […] }]`. | `.claude/mise.toml` (versions) + `.claude/mise.lock` (lockfile). | +| **How to enable** | Presence-based. Drop the directory in; it's enabled. | Presence-based. Drop the file in; it's enabled. | Presence-based, key by key. Listing the server under `mcpServers` enables it; remove the key to disable. | **Explicit flag required.** `enabledPlugins["foo@market"]: true`. Files in `~/.claude/plugins/` alone don't enable anything. | Presence-based per event. Listing a hook under `hooks.` enables it. | Presence-based. Listing a tool in `[tools]` enables it for the loop's shell. | +| **How auth works** | None — skills are just markdown. | None — agents are just markdown + prompt. | **Loopat reads the selected vault and injects credentials into `env` / `headers` at spawn**; the augmented config is passed via the `mcpServers:` SDK option. Plain CC stores OAuth in `~/.claude/.credentials.json`; loopat instead manages tokens per-vault. | Marketplace install may need git auth (SSH key, HTTPS PAT) — runs on host, uses host's git creds. Plugins themselves usually don't carry their own auth (their bundled MCPs do, see above). | None — hooks are just scripts; whatever creds they need they read themselves. | None — `mise install` runs in the host with whatever creds it already has (rare). | +| **How plain CC activates it** | CC scans `/skills/` at session start; available immediately. | CC scans `/agents/`; subagents listed via Task tool. | CC reads `mcpServers` from each settings tier and starts each server at session init. | Resolves spec → `~/.claude/plugins/installed_plugins.json` → loads installPath. Requires the user to have run `claude plugin install ` first. | CC registers the hooks at session init; invokes the script when the matching event fires. | Not a CC concept. (Mise activates outside of CC.) | +| **How the SDK activates it** | Discovered via `settingSources` (`'user'`, `'project'`). Narrowing option: `skills: 'all' \| string[]`. | Discovered via `settingSources` *or* defined programmatically via `agents: { : { ... } }`. | Either via `settingSources` (settings.json) *or* directly via the `mcpServers:` option (loopat uses this so it can inject credentials). | Either via `settingSources` (CC plugin cache resolution) *or* programmatically via `plugins: [{type:"local", path:...}]`. | Either via `settingSources` *or* programmatically via the `hooks:` option. | Not an SDK concept. | +| **How loopat activates it** | Drop into any tier's `.claude/skills/`. Merged into `loops//.claude/skills/` as a symlink union; SDK discovers via `settingSources: 'user'`. | Drop into any tier's `.claude/agents/`. Same merge mechanism. | Add to any tier's `.claude/settings.json` `mcpServers`. Compose merges by key; loopat then reads the selected vault, injects credentials, and passes the augmented map via the `mcpServers:` SDK option. | Add to any tier's `.claude/settings.json` `enabledPlugins`. Compose merges. `ensureLoopPluginsInstalled` runs `claude plugin install` on host for anything missing; bwrap ro-binds `~/.claude/plugins/` wholesale so SDK resolves natively. | Add to any tier's `.claude/settings.json` `hooks`. Standard `settingSources` discovery. | Add to any tier's `.claude/mise.toml`. Bwrap runs `mise install` + `mise env --json` on the merged file before sandbox spawn and injects `PATH` / env via `--setenv`. | +| **Where it lands in the sandbox** | `loops//.claude/skills//` — a symlink to the source tier's host path. | `loops//.claude/agents/.md` — symlink to the source tier's host path. | Server config lives in `loops//.claude/settings.json` (no creds). Augmented config (with creds) reaches the SDK in memory; the running server is a regular host process the SDK talks to. | Plugin code is at `~/.claude/plugins/marketplaces//plugins//` (ro-bound wholesale into the sandbox). Activation is via the loop's merged `enabledPlugins`. | `loops//.claude/settings.json` hooks field; script lives at its source tier's host path (covered by the workspace / personal binds). | `loops//.claude/mise.toml` + injected env vars; tool binaries from host `~/.local/share/mise/` (also bound in). | +| **Version lock** | The file contents are themselves the "lock" — a skill is just markdown. compose symlinks point to a specific host path; renaming or rewriting the source file changes any loop's spawn-time view. (Frozen for the loop only if the source file itself stops changing.) | Same as skill — the `.md` file is the lock. | The `mcpServers` entry (transport / command / args / env-keys) IS the spec; it's deep-merged into the loop's `settings.json` snapshot at creation, so the loop sees the merged config forever. Credentials are injected fresh from the active vault at each spawn (intentionally not pinned). | **`.claude/plugins/installed_plugins.json`** — CC-native, same shape host writes. Merged across tiers (per-spec last-wins), snapshotted into `loops//.claude/plugins/installed_plugins.json` at creation, file-bound over the host's at spawn. Pins both `version` (used for cache resolution) and `gitCommitSha` (audit). | The script path in `settings.json` is the "lock". As with skills, the script *content* is whatever is at that path at spawn time. (Hooks pointing into team-managed source dirs are effectively pinned by the source not being rewritten.) | `.claude/mise.lock` — CC-extended, mise-native. Compose merges per-tool last-wins; snapshot frozen with the loop. `mise install` uses the lockfile to resolve identical versions across machines. | + +--- + +## Where to look next + +- [`architecture.md`](architecture.md) — sandbox / vault model, read & + write paths, full bwrap layout. +- [`composition.svg`](composition.svg) — the diagram on its own. +- Source of truth: [`server/src/compose.ts`](../server/src/compose.ts) and + [`server/src/profiles.ts`](../server/src/profiles.ts). diff --git a/docs/composition.svg b/docs/composition.svg new file mode 100644 index 00000000..ef35e289 --- /dev/null +++ b/docs/composition.svg @@ -0,0 +1,256 @@ + + + loopat .claude composition + Five .claude tiers stacked with strongest (last-wins) at top. Workspace, profiles and personal are merged by loopat into the loop's user tier. Workdir (project + local) is read by the SDK directly. Inside the sandbox the SDK sees two .claude sources of truth plus the wholesale-bound plugin cache. + + + + + + + + + + + + + + + + + + + .claude composition + five host-side tiers · merged into one loop · read by the SDK as user + project + local + + + + + + + + SOURCES + TOP = LAST-WINS + + + + + o + v + e + r + r + i + d + e + s + + + + READ DIRECTLY BY SDK + + + + + L + local + workdir/.claude/*.local.* + per-checkout · not committed + + + + + + W + project (workdir) + workdir/.claude/ + repo-native · per repo + + + + + + + MERGED BY LOOPAT → USER TIER + + + + + P + personal + personal/<user>/.loopat/.claude/ + per-user · last word on user tier + + + + + + PN + profile N + knowledge/.loopat/profiles/…/.claude/ + opt-in bundles (role / mode) + + + + + 0..N profiles selected at loop creation + + + + + P1 + profile 1 + knowledge/.loopat/profiles/…/.claude/ + + + + + + T + workspace + knowledge/.loopat/.claude/ + always on · whole team + + + + + EACH .claude/ MAY CONTAIN + settings.json + — enabledPlugins · mcpServers · hooks + CLAUDE.md + — always-on doctrine + skills/ + — /command procedures + agents/ + — subagent prompts + mise.toml + — toolchain pins (loopat ext.) + + — any future CC .claude/ field + All keys deep-merge per-key, last-wins. + + + + + + + + + + + + + + + + + + compose + server/src/compose.ts + + + MERGE RULES + + + settings.json + shallow key union; sub-keys last-wins + CLAUDE.md + concat in tier order, ## headers + skills/, agents/ + symlink union, last-wins per name + mise.toml + TOML [tools]/[env] table union + + + + + + + + + + + + + + SANDBOX VIEW + PER-LOOP · ISOLATED + + + + + SDK + settingSources + [u,p,l] + + + + + + user tier (loopat-composed) + loops/<id>/.claude/ + SDK env: CLAUDE_CONFIG_DIR + + ├── settings.json + ├── CLAUDE.md + ├── skills/ → symlinks + ├── agents/ → symlinks + ├── mise.toml + └── mise.lock + + + + + + + project + local tier + workdir/.claude/ (= cwd) + read directly by SDK, not merged + + ├── settings.json + ├── settings.local.json (local tier) + ├── CLAUDE.md + └── skills/ · agents/ · … + + + + + + + plugin cache (ro-bound wholesale) + ~/.claude/plugins/ + SDK resolves enabledPlugins natively from + settings.json + installed_plugins.json. + loopat builtin is the only plugin passed + via the plugins: SDK option. + + + + + + + + + + + + + + + + + + no host ~/.claude/ visible — sandbox $HOME is an overlay with an empty lower layer + + + + five tiers · top = strongest · grouped by who reads them + workspace + profiles + personal deep-merge by tier order + SDK reads composed user tier + workdir; auth injected at spawn + diff --git a/docs/context-flow.md b/docs/context-flow.md new file mode 100644 index 00000000..b7040e04 --- /dev/null +++ b/docs/context-flow.md @@ -0,0 +1,213 @@ +--- +title: context flow — the loopat working model +tags: [loopat, context, git, flow, workflow] +status: living doc +--- + +# context flow + +> **A loop is a git worktree; the shared context is its `main`. Each loop +> *pulls* the consensus it starts from, and *promotes* the work worth sharing +> back into it — its own AI resolving any conflict along the way.** If you know +> `git`, you already know the model. + +

+ loops exchange context with remotes over two edges: pull and promote +

+ +Context accumulates in many places at once — working notes, distilled knowledge, +personal memory, code. Loopat keeps each in a plain git repo and lets a loop +work directly against it: pull what you start from, promote what's worth sharing. +The loop, with its AI, is the only moving part. + +This is the **horizontal** companion to [`architecture.md`](architecture.md), +which covers the **vertical** axis — *distillation*, how context condenses upward +(`workdir → notes → knowledge`). Distillation promotes *across layers*; flow +aligns *one layer across places*. + +--- + +## Mental model in one line + +A **loop is a local directory** — a bundle of git **worktrees**, each tracking +its own remote over two edges: + +| Edge | Direction | Driven by | When | +|---|---|---|---| +| **① pull** | remote → loop | the runtime | once, at loop creation | +| **② promote** | loop → remote | the loop (its AI, or you in the UI) | when work is worth sharing | + +--- + +## The two edges + +### ① pull — start from consensus + +At creation the loop opens its worktree from `origin/main`: + +```sh +git fetch origin +git worktree add loops//context/notes -b loop/ origin/main +``` + +After that the loop is **isolated** — it does not keep pulling until it +promotes (which folds in everyone's latest). So "fresh" is guaranteed at the +creation instant, by design; pulling mid-loop is always possible by hand, it +just isn't automatic. + +### ② promote — share what's worth keeping + +Promoting is not a plain push: to land on `main` you first reconcile with +where `main` is now. So promote **inherently absorbs everyone else's latest** — +that's the one moment a loop takes in others' work, and it's by design. + +```sh +git fetch origin +git merge origin/main # conflicts → the loop's AI resolves them +git push origin HEAD:main # ungated: straight into consensus +``` + +Promote is **deliberate** — the loop's AI decides *when* work is worth sharing, +not every turn. When a context is **gated**, promote opens a PR instead: + +```sh +git push origin HEAD:refs/heads/loop/ +gh pr create --base main --head loop/ # gated: review, then merge +``` + +--- + +## Every context operation is a loop — even the settings UI + +> **`origin` is the source of truth. Pull from it, work locally, merge back — +> origin always wins.** + +A loop is just **a checkout of `origin/main` + a worker**. Almost always the +worker is an AI in a sandbox. **The settings UI is the one loop without an AI:** +opening it pulls your personal context, you edit, it pushes back — the same two +edges. Nothing reaches a remote except through a loop, AI-driven or not. + +The only thing that differs by loop is **who resolves a conflict**: + +- **AI loop** (the common case) — the loop's AI three-way-merges onto `main`; + resolved in-loop, invisible to others until it lands. +- **UI loop** (no AI) — fast-forward only; a conflict it can't rebase away is + *held back and surfaced for you* (see [Conflicts](#conflicts)). + +Three consequences: + +- **Want to sync? Open a loop** — there is nothing else to run, and it doubles + as the escape hatch for anything tricky. +- **A device not running a loop simply isn't current**; the next loop's ① pull + catches it up. +- **Going solo → team is itself a promote** — attach a remote, open a loop, and + it does `fetch → merge → push`. No migration step. + +The "loop" generalizes past settings: **any no-AI editing session is a UI loop** +— one browser tab editing notes is a worktree like any other. So **multi-user on +one server and multi-user across servers are the same thing**: every session is +an independent worktree converging on `origin`, whether two tabs share one +server's object store or sit on machines apart. A server is never +authoritative — it's a disposable replica of `origin` plus a gateway. It may run +a **local change feed** (push a just-landed commit to its own tabs so they skip +the poll), but that's a best-effort shortcut carrying only what's already in +git — correctness always rests on `origin`. + +--- + +## A loop is a worktree, not a pushed branch + +`loop/` is a **worktree-local ref** (git worktrees must sit on some ref) — +the git carrier of "a loop is a directory," not a unit of sync: + +- **ungated** (notes · personal) — promote pushes `HEAD:main` and leaves no + branch behind; the ref dies with the loop. +- **gated** (knowledge · repos) — the branch is pushed as a PR's source. + +Many loops on a device share one object store via worktrees — N loops are N +checkouts, not N clones (add `--filter=blob:none` to keep even that lean). + +--- + +## The four kinds of context + +Same skeleton everywhere — per-loop worktree, ① pull / ② promote, conflicts +resolved by whoever runs the loop (its AI, or you in the UI loop). They differ +only in **which remote**, **who writes**, and **how**. The **gate is an optional +modifier on promote**, not a fixed trait. + +| | **notes** | **knowledge** | **personal** | **repos** | +|---|---|---|---|---| +| **remote** | team origin | team origin | your private remote | each repo's remote | +| **who writes** | any loop | a distill loop | only you | any loop (own `workdir`) | +| **how** | ad-hoc capture | explicit *distill* | ad-hoc | work product | +| **in the loop** | worktree (rw) | worktree (ro to others) | worktree (rw) | worktree (rw) | +| **gate** | default none | add-able | default none | add-able | + +- **notes** — anyone records what's worth keeping; merges into consensus freely. +- **knowledge** — only a **distill loop** (its `knowledge` worktree writable) + reads notes, distills, and promotes. Curated and slow; read-only to all others. +- **personal** — *notes wired to a private remote.* Same shape, just yours. +- **repos** — *notes with a gate, on each repo's remote.* `workdir` is a loop's + checkout; promote merges its branch back (`workdir → repos`). + +The principle: **the more shared and important a layer is, the slower it flows +and the higher its gate.** *Read down, write up — slowly.* + +--- + +## Conflicts + +Everything reconciles **toward `origin`**, the source of truth. Inside a loop a +human is never *required* — a graceful chain handles it: + +1. **Structure first (no AI, ~99%).** One-file-per-item + index, per-author / + per-loop directories, append-only surfaces → different writers, different + files → git auto-merges. This is what keeps everything else cheap at scale. +2. **The loop's AI.** A real same-spot conflict is a three-way merge by the + loop's AI — always a **merge, never a rebase**, so a bad merge stays + revertible. +3. **You, if you want.** Open a loop and resolve it together. + +Concurrent promotes serialize naturally: git rejects the losing push, that loop +`fetch → merge → push`es again. + +**Outside a loop there is no AI to call on**, so a write from the settings UI +(personal config) uses the bluntest rule that cannot go wrong: + +- **Fast-forward only.** If the remote hasn't moved, the push just lands. +- **If it moved, rebase the local edit on top.** Different files merge cleanly + and your change survives untouched — the common case, and invisible to you. +- **Only a real same-spot conflict stops here**, and it is *surfaced, not + swallowed*: your change is **kept locally and never auto-discarded**, the push + is held back, and you're told. An unresolved conflict **does not count as + reaching `origin`.** + +Resolving it is then your call — **keep yours** (by hand, or in a loop) or +**take the remote** (drop this edit). Two invariants, both the point: **nothing +lands on the SoT with a conflict buried inside it, and nothing of yours is +dropped without you saying so.** + +--- + +## Solo and team are one mechanism + +The remote is **optional**, so it's one model at every scale: + +- **solo** — loopat hosts the remote itself: a bare repo per context repo on the + same machine (`git init --bare`) is the `origin`. The edges run `pull`/`push + origin` against it — no external git platform, no credentials needed. +- **team** — point `origin` at a remote git host instead; the same edges run, + with everyone converging on it. + +*Works solo, scales to teams* — at the context layer, not two systems but one. + +--- + +## Where to look next + +- [`architecture.md`](architecture.md) — distillation, read/write paths, sandbox + & vault model. +- [`composition.md`](composition.md) — how `.claude/` config tiers compose into + a loop. +- [`context-flow.svg`](context-flow.svg) — the diagram on its own. diff --git a/docs/context-flow.svg b/docs/context-flow.svg new file mode 100644 index 00000000..e3b08399 --- /dev/null +++ b/docs/context-flow.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + loopat context flow + loops exchange context with a remote over two edges + + + + + commit + + + ① pull / ② promote + + + direct merge (notes · personal) + + + + + + + PR-gated (knowledge · repos) + + + + + origin / main + team consensus + + loop · feat-x + worktree + + loop · feat-y + worktree + + distill loop + notes → knowledge + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PR + + + + + + + time + + + every edge runs inside a loop, so its own AI resolves any conflict — the same two edges run solo or across a team. + diff --git a/1001-mvp.md b/docs/design/1001-mvp.md similarity index 100% rename from 1001-mvp.md rename to docs/design/1001-mvp.md diff --git a/1001-story.md b/docs/design/1001-story.md similarity index 100% rename from 1001-story.md rename to docs/design/1001-story.md diff --git a/docs/design/api-e2e-tests.md b/docs/design/api-e2e-tests.md new file mode 100644 index 00000000..adac7b44 --- /dev/null +++ b/docs/design/api-e2e-tests.md @@ -0,0 +1,193 @@ +--- +title: API e2e tests (loop API + sandbox) +tags: [loopat, api, test, e2e, sandbox] +status: spec +date: 2026-05-27 +--- + +# API e2e tests — design + +> **Goal**: end-to-end test the v1 loop API by driving real user behaviors +> against the real SDK + real claude binary + real podman sandbox, with +> zero token cost and deterministic outcomes. Sandbox state observed via +> `podman exec` probe (terminal WS is out of scope here). + +## What we test, what we mock + +| Component | Real or mock | +|---|---| +| Anthropic API (`/v1/messages`) | **mock** — scripted SSE responses dispatched by user-text marker | +| `claude` binary (CC agent) | **real** — same binary the production loop uses | +| Claude Agent SDK in `session.ts` | **real** | +| Loopat v1 API surface | **real** | +| Podman sandbox + tool execution (Bash, Write, Read, BashOutput) | **real** | +| Loop session state, queue, idempotency, SSE framing | **real** | +| Web UI / WS terminal | **not tested here** (covered by e2e/loop.spec.ts) | + +The mock plays the **model's role**: emit `tool_use` content blocks → CC +dispatches tools for real → CC sends `tool_result` back → mock emits the +final assistant text. Mock is stateless; dispatch is keyed on the first +user message's marker (`[[scenario-name]]`), turn index derived from +`messages.length`. + +## File layout + +``` +server/test/api-e2e/ + mock-anthropic.ts ← Bun.serve, scenario registry, Anthropic SSE framing + helpers.ts ← env setup, mock singleton, user/loop API helpers, podman exec probe + hello.test.ts ← smoke: text-only response + file-roundtrip.test.ts ← Bash tool_use writes, probe verifies; cc reads back + cross-surface.test.ts ← cc ↔ probe bidirectional; background http server lifecycle + multi-turn.test.ts ← 3-turn iteration on one file + api-edges.test.ts ← interrupt, archive, GET /events viewer, idempotency replay +``` + +## Networking + +`server/src/podman.ts:362` uses `--network host` for loop containers, so +CC inside the container reaches the mock by `http://127.0.0.1:`. +The mock picks a free port at process start (port=0 → read back actual). + +## Process model + +bun:test runs all files in one process (verified). One mock server, +one `LOOPAT_HOME = /tmp/loopat-api-e2e-${pid}`, one registered test user +shared across files. Per-test isolation is at the loop level (each test +calls `api.createLoop()` → fresh loopId → fresh podman container). + +## Mock API protocol + +### Request handling + +``` +POST /v1/messages → 200 text/event-stream +* → 404 +``` + +Strategy: +1. Find scenario by matching `firstUserText(messages)` against `marker` substring +2. Determine turn index from `Math.floor(messages.length / 2)` +3. Call `scenario.respond(req, turn)` → iterable of `MockBlock` +4. Stream them as Anthropic SSE events (`message_start` → `content_block_*` → `message_delta` → `message_stop`) + +```ts +type MockBlock = + | { type: "text"; text: string } + | { type: "tool_use"; name: string; input: unknown; id?: string } + | { type: "end"; stop_reason: "end_turn" | "tool_use" } +``` + +### Turn-index math + +Anthropic `messages` shape grows by 2 per round trip: + +| Turn # | `messages.length` | Last role | +|---|---|---| +| 0 | 1 | user (string content) | +| 1 | 3 | user (tool_result content) | +| 2 | 5 | user (tool_result content) | + +So `turn = floor(messages.length / 2)`. + +### Fallback scenario + +Marker `""` matches anything. Yields `{ text: "ack" }` + `{ end: end_turn }`. +Catches "forgot to register a scenario" so tests fail loudly instead of +hanging. + +### What we ignore from the request + +- `system` prompt (whatever CC sends) +- `cache_control` headers +- `temperature`, `max_tokens`, `thinking`, `top_p`, `top_k` +- `metadata`, `service_tier`, `stream` flag (always stream) +- model name validation (always claim to be `claude-mock`) + +## Test helpers API + +```ts +// helpers.ts — auto-init at top-level import + +// scenario registry +export const mock: { + register(s: Scenario): void + clear(): void // each test calls in afterEach +} + +// user/loop fixtures +export async function authedRequest(path: string, init: RequestInit): Promise +export async function createLoop(opts?: { title?: string }): Promise +export async function sendMessage(loopId: string, content: string, opts?: { idempotencyKey?: string }): Promise +export async function readSSE(r: Response, opts: { until: (e: SSEEvent) => boolean; timeoutMs?: number }): Promise + +// sandbox probe (bypasses ws/auth) +export async function inSandbox(loopId: string, command: string, opts?: { timeoutMs?: number }): Promise<{ stdout: string; stderr: string; code: number }> + +// scenario builder helpers +export const blocks: { + text: (t: string) => MockBlock + bash: (cmd: string, opts?: { run_in_background?: boolean }) => MockBlock + write: (path: string, content: string) => MockBlock + endTurn: () => MockBlock + endTool: () => MockBlock +} +``` + +## Scenario set (MVP — 6 files, ~15 tests) + +### hello.test.ts +- `T1 send "hi" → mock returns text → SSE: assistant_delta with "hi" + done` + +### file-roundtrip.test.ts +- `T2 cc creates /workdir/foo.txt via Bash tool_use → done → probe asserts file content` +- `T3 multi-turn: cc creates file (msg 1) → cc ls /workdir (msg 2) → second SSE assistant text contains "foo.txt"` (each msg = separate POST) + +### cross-surface.test.ts (core) +- `T4 cc writes → probe sees`: cc Bash `echo X > /workdir/a` → probe `cat /workdir/a` = "X" +- `T5 probe writes → cc reads`: probe `echo Y > /workdir/b` → cc Bash `cat /workdir/b`; assert tool_result delivered (cc final text contains "Y") +- `T6 cc starts background http server (run_in_background: true) → probe curl localhost:8765 → 200` +- `T7 after T6's server is running, send another loop message → loop responds normally; proves loop survives an outstanding bg process` + +### multi-turn.test.ts +- `T8 three messages, each adds to /workdir/add.py — final file contains all three additions (function body + type hints + docstring)` + +### api-edges.test.ts +- `T9 interrupt mid-tool: mock emits Bash sleep 30 → test POSTs /interrupt → SSE event interrupted` +- `T10 archive blocks send: DELETE /loops/:id then POST /messages → 400 with code loop_archived` +- `T11 GET /events read-only viewer parallel to POST /messages: both see assistant_delta events` +- `T12 idempotency replay: POST /messages twice with same key → second is replay (no second mock invocation)` + +## Lifecycle / cleanup + +- `helpers.ts` top-level (runs once on first import): pick mock port, set env, write provider config, register test user, start mock server +- Cleanup runs once at `process.on('beforeExit')` — NOT per-file afterAll. Per-file afterAll would tear down the shared mock + LOOPAT_HOME for subsequent files in the same `bun test` invocation +- Container idle stop: `LOOPAT_CONTAINER_IDLE_MS=60000` — long enough that two-message tests don't lose the container between turns, short enough that leftover bg processes from a failed test get reaped before the next file +- All tests gated by `describe.skipIf(!podmanAvailable)` like chat-integration + +## Mixed-suite env handling + +When `bun test` runs the whole repo, the api-e2e files inherit env vars from earlier-loaded test files: + +| Env var | Set by | Our handling | +|---|---|---| +| `LOOPAT_HOME` | api-v1.test.ts (alphabetically earlier) | adopt via `??=`; paths.ts has already cached it. Write our config.json on top | +| `LOOPAT_CLAUDE_BIN` | chat-integration.test.ts (points at mock-claude.sh) | `delete process.env.LOOPAT_CLAUDE_BIN` at top of helpers.ts so the real claude binary resolves and connects to our mock anthropic | +| `LOOPAT_CONTAINER_IDLE_MS` | unset by others | we set it via `??=` | + +## Known risks + mitigations + +| Risk | Mitigation | +|---|---| +| CC version bumps change `tools` schema field names (`run_in_background` renamed, etc.) | Mock reads `req.tools` at runtime; falls back to "best guess" + log warning. Tests catch on broken assertion | +| CC sends `/v1/messages/count_tokens` probe | 404; SDK falls back to estimation | +| Anthropic SSE event order subtle differences | Validate mock output once with `@anthropic-ai/sdk` parser in a meta-test | +| Stale `python -m http.server` from T6 blocks port 8765 in next file | Use a per-test port (loopId hash → port range) or wait for container removal | +| Long bg process from killed test leaks | `stopAllWorkspaceContainers` in `afterAll`; `LOOPAT_CONTAINER_IDLE_MS` short | + +## Out of scope (v1) + +- WS terminal coverage (already covered weakly by e2e/loop.spec.ts; deep coverage = separate task) +- Permission flow (`canUseTool` + permission_mode interaction) — mock currently always allows +- Real LLM smoke tests (backlog: `server/test/api-soak/` with LiteLLM + Qwen2.5-Coder) +- Multi-user driver/handoff scenarios (covered by `driver-handoff.test.ts` at unit level) diff --git a/docs/design/multi-tenant-secrets.md b/docs/design/multi-tenant-secrets.md new file mode 100644 index 00000000..b40b9fc0 --- /dev/null +++ b/docs/design/multi-tenant-secrets.md @@ -0,0 +1,236 @@ +--- +title: 多租户 secrets — 客户端加密 + git 原生 +tags: [loopat, design, security, secrets, multi-tenant] +status: future-work (not implemented; record only) +date: 2026-05-12 +--- + +# 多租户 secrets:客户端加密 + git 原生 + +把 loopat 从单租户(密钥明文落盘,机器主人即可见)演化为多租户(每个用户的密钥只有 ta 自己能看,server 不持久知道)的设计。 + +**当前状态**:未实现。本文是未来工作的记录,等 loopat 走向多租户/对外提供服务时回来落地。 + +## 1. 设计目标 + +1. **server 不持久知道任何用户的密钥** —— 库被偷、运维 root 都看不到 +2. **保留"git 管理一切"** —— 加密后的 secrets 仍然走 git commit/push/clone,跨设备同步靠 git +3. **sandbox 内仍然是明文** —— sandbox 里的 cc 直接读 apiKey 就能用,不需要它感知加密 +4. **不依赖第三方密钥服务**(Vault / KMS / SOPS server) + +## 2. 威胁模型 + +| 攻击者 | 拦得住吗 | 怎么 | +|---|---|---| +| 偷服务器磁盘 / 数据库 | ✅ | secrets 在 git 里全是密文,没用户密钥解不开 | +| 云厂商 at-rest 窥探 | ✅ | 同上 | +| 运维 root 翻文件 | ✅ | 用户没主动开 loop 时,server 进程内存里也没有密钥 | +| 拖库 + 钓鱼 1 个用户活跃 session | ⚠️ | 拿到那 1 个用户开 loop 时短暂在内存里的密钥 | +| loop 运行期间 server 被攻破 | ❌ | server 当时内存里有那个 user 的密钥,可 exfil | +| 用户浏览器被 XSS / 恶意扩展 | ❌ | localStorage 是明文,扩展可读 | +| TLS MITM | ❌ | 必须强制 TLS | + +**核心安全保证 = "server 在用户没主动开 loop 时不知道任何东西"**。这给 server 一个明确的"无知窗口",是单租户磁盘加密给不了的。 + +## 3. 架构总览 + +``` +┌─ Browser ──────────────────────────────────────────────────┐ +│ localStorage["loopat_key"] = AES-256 master key │ +│ │ +│ 写 secret: encrypt-in-JS → POST <密文> │ +│ 开 loop: WS 握手把 key 临时发给 server │ +└─────────────────────────────────────────────────────────────┘ + ↓ TLS +┌─ Server ────────────────────────────────────────────────────┐ +│ at-rest (git 管理): │ +│ personal//.loopat/vaults/default/envs/ANTHROPIC_API_KEY.enc │ +│ ← 永远是密文,git commit/push/pull 操作的就是这个 │ +│ │ +│ startLoop(id, key): │ +│ plain = decryptAll(<.enc files>, key) │ +│ tmpDir = loops//runtime-secrets/ (tmpfs, mode 0700) │ +│ write(tmpDir, plain) │ +│ bwrap --bind tmpDir → /loopat/context/personal/.loopat/secrets │ +│ onClose: shred(tmpDir); zero(key in process memory) │ +└─────────────────────────────────────────────────────────────┘ + ↓ bwrap bind +┌─ Sandbox (cc 进程) ──────────────────────────────────────────┐ +│ env: ANTHROPIC_API_KEY= │ +│ ← 看到明文(envs/ 文件解密后注入 spawn env) │ +│ loop 结束 → 进程退出 → env 消失 │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 4. 为什么这个设计能"git 原生" + +加密发生在**应用层**而非 git filter 层(不是 git-crypt 那种): + +- 浏览器把 plaintext 加密成 `.enc` 后才送达 server +- Server 拿到的就是 `.enc` 文件,直接 `git add` / `git commit` / `git push` +- 跨设备同步 = `git pull`,拉的是 `.enc`,本地解密发生在 sandbox 启动时 + +对比 git-crypt: + +| 维度 | git-crypt | 客户端加密 | +|---|---|---| +| 加密位置 | git filter(commit/checkout 时) | 浏览器 JS(写 secret 时) | +| 工作目录状态 | 明文(unlock 后) | 永远密文(除 sandbox tmpfs 解密目标) | +| Server 是否知道密钥 | unlock 状态下持有 `.git/git-crypt/keys/default` | 仅 loop 运行期内在内存 | +| 多租户隔离 | 不支持(all-or-nothing) | 天然支持(每用户一把 master key) | +| Git 原生 | ✅(filter 是 git 标准机制) | ✅(git 操作的是 .enc 文件) | + +**结论**:客户端加密是 git-crypt 的多租户演进,保留 git 原生属性,但加密边界从"机器"上移到"用户浏览器"。 + +## 5. 加密原语 + +- **AES-256-GCM** + - 浏览器:`crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext)`(WebCrypto API 原生,零依赖) + - 服务器:`crypto.createCipheriv("aes-256-gcm", key, iv)`(Node/Bun 内置) +- **文件格式**(每个 `.enc` 文件): + ``` + nonce(12 bytes) || ciphertext(N bytes) || auth-tag(16 bytes) + ``` +- 一个 user 一把 master key,加密 ta 所有 secrets + +不要自创格式,不要造轮子加密。 + +## 6. 密钥管理:三个方案 + +### A. 浏览器随机生成 + 备份短语(MVP 推荐) +``` +首次访问: + key = WebCrypto.generateKey("AES-GCM", 256) + localStorage["loopat_key"] = base64(key) + 显示 BIP39 24 词给用户抄下来 + +新设备: + 用户手动输入 24 词 → 还原 key +``` +- ✅ 强加密、零密码、可备份 +- ❌ 24 词抄写体验差 + +### B. 密码派生(用户体验最好) +``` +用户设密码 → Argon2id(password, salt) → AES key (永不存盘) +每次开 session 都重新 derive +``` +- ✅ 用户只需记密码,跨设备零拷贝 +- ❌ 密码弱 = 加密弱;忘密码 = 数据没了(除非接受 escrow) + +### C. Passkey PRF(最现代,等浏览器普及) +``` +WebAuthn passkey + PRF extension → AES key +依赖 Touch ID / Windows Hello / iCloud Keychain 同步 +``` +- ✅ 硬件支持,多设备自动同步 +- ❌ 浏览器支持还在 rolling out,复杂度高 + +**推荐路线**:先 A,B 做高级选项,C 等成熟。 + +## 7. 密钥发送给 server 的协议 + +不要每次 HTTP 请求都带,**只在 loop 启动时**送一次: + +``` +1. Browser → Server: WebSocket upgrade + first message: { type: "loop-key", id, key } +2. Server 解密所有 .enc → tmpDir → bwrap bind 进 sandbox +3. Loop 运行 +4. Loop 关闭 / 用户断开 → server umount tmpDir + 内存 key 清零 +``` + +key 在 server 内存的"驻留窗口" = loop 生命周期。窗口最小化是核心。 + +进阶:用 Linux `memfd_secret(2)`(kernel ≥ 5.14)创建内核保护的内存页存 key,连同机其他进程的 root 也读不到。Node 没原生绑定,要 FFI,未来再加。 + +## 8. Sandbox 边界设计选择 + +**选择 X:每 loop 一个 tmpfs**(用于大 secret) +``` +loops/<id>/runtime-vault/ ← tmpfs,0700,owner=server + mounts/home/.ssh/id_ed25519 ← 多行 key 解密后落 tmpfs +bwrap --bind runtime-vault/mounts/home/.ssh → $HOME/.ssh +``` +- 明文落 **tmpfs**(内存挂载,掉电即失) +- Loop 销毁触发 umount + rm -rf +- 没有 swap 风险(tmpfs 默认不 swap) + +**选择 Y:纯 env var 注入** +``` +bwrap --setenv ANTHROPIC_API_KEY <plain> +``` +- ✅ 零磁盘落地 +- ❌ env var 通过 `/proc/<pid>/environ` 暴露,进程列表泄漏,且不能放多行 secret + +**实际方案:混合** +- 小 secret(apiKey / token)走 env var——`envs/<NAME>` 解密后 `--setenv`(loopat 当前架构) +- 大 secret(多行 cert / ssh 私钥)走 tmpfs——`mounts/home/<rel>/...` 解密落 tmpfs 后 bind 到 sandbox $HOME + +## 9. 加密 secret 的写流程 + +``` +用户在前端粘贴 anthropic key: + ↓ +浏览器 JS: + nonce = crypto.getRandomValues(12) + cipher = AES-GCM(localStorage.key, nonce, plaintext) + blob = nonce || cipher || tag + ↓ +POST /api/vault-env/ANTHROPIC_API_KEY +body: <blob> + ↓ +服务器: + 写 personal/<user>/.loopat/vaults/default/envs/ANTHROPIC_API_KEY.enc + git add + commit + push(自动 / 用户触发) +``` + +**server 从未看到明文** —— 这是关键路径。任何会让 server 短暂触摸明文的"为了方便"功能都不能加(比如"server 帮你检查这个 key 格式对不对")。 + +## 10. 跟当前实现的差异 / 迁移路径 + +当前: +- secrets 在 `personal/<user>/.loopat/vaults/<v>/envs/<NAME>` 和 `vaults/<v>/mounts/home/<rel>/...` —— **明文**(at-rest 靠 git-crypt,运行时明文) +- `loadVaultEnvs(user, vault)` 直接读文件,session.ts 注入 spawn env +- 单租户假设,server 即用户 + +未来(多租户客户端加密): +- envs/mounts 文件改成 `.enc` 后缀(或保持无后缀但内容是密文,靠 magic bytes 识别) +- `loadVaultEnvs(user, vault)` **不再**直接 read,需要 key +- 新增 `decryptVault(user, vault, key) → tmpDir`,loop start 时调用 +- `session.ts` 在 `ensureStarted` 里从 WS 握手收 key,调用解密函数,把解密结果作为 env / bind tmpDir 进 sandbox + +迁移要做: +1. 前端加密 UI(输入 secret → 加密 → 上传) +2. 后端 `/api/vault-env/*` 路由(仅存密文,不解密) +3. 后端 `startLoop` 路径接受 key +4. session.ts / bwrap.ts 改成接收解密后的 envs / tmpDir +5. 一次性脚本把现有明文 vault 加密迁过去 + +迁移**不**做: +- 不写双写逻辑、不做兼容期 fallback —— 走 MVP 不兼容老数据原则,一次性切换 + +## 11. 开放设计决定(实施时再敲) + +1. **A/B/C 哪个先做**?我倾向 A +2. **要不要 server-side encrypted escrow**(忘密码能恢复)?削弱"server 不持久知道"但救小白用户。倾向"先不做,告知用户密钥丢失=数据丢失" +3. **粒度**:一个 user 一把 master key(简单) vs 每个 secret 独立 key(细 ACL 但复杂)?倾向前者 +4. **审计**:每次 server 解密留 log,给用户看"我的 key 何时被用过 N 次"?建议做 +5. **多并发 loop**:每个 loop 单独传 key vs WS session 期内 server 内存留一段时间?建议**单独**(一致性 > 一次性优化) +6. **密钥 rotation**:rotate master key 时怎么重新加密所有 .enc 文件?前端拉所有密文 → 解 → 用新 key 重新加密 → 推回。设计要预留 + +## 12. 不做的事 + +- 不做"server 端密钥托管 + 用户密码解锁"——那退化成密码强度问题,丢"server 不持久知道密钥"的卖点 +- 不引入 HSM / KMS / Vault —— 增加运维 + 锁定云厂商 +- 不做"AI 在 sandbox 里也能加密"—— sandbox 信任 server 给的明文就够,加这层只增加复杂度 +- 不做"加密浏览器历史 commit"—— 已经存在的明文 secret 走 git filter-repo 重写历史 + force push 即可(一次性操作) + +## 13. 跟单租户 git-crypt 的关系 + +短期(个人用,2026 当下):**git-crypt 够用**。机器主人就是用户,server 信任本机文件系统。 + +中期(开始有第二个用户):**git-crypt 撑不住**。git-crypt 是 all-or-nothing 单密钥,无法做 per-user 隔离。 + +长期(多租户):**切换到本文方案**。 +本文方案是 git-crypt 的**多租户演进**,思想一脉相承(git 原生 + 透明加密),实现层从 git filter 上移到应用层。 diff --git a/docs/identity.md b/docs/identity.md new file mode 100644 index 00000000..39482577 --- /dev/null +++ b/docs/identity.md @@ -0,0 +1,218 @@ +--- +title: identity — who a loop acts as +tags: [loopat, identity, credentials, git, auth, integration] +status: living doc +--- + +# identity + +> **loopat stores no accounts and no permissions.** Your **git platform** is the +> identity and authorization layer; loopat is a shell that acts with your git +> identity. A loopat account is almost empty — it only **points at your +> `personal/<user>` repo** and **holds the one key that unlocks it**. Everything +> else — who you are, what you may read or write — already lives in git. + +<p align="center"> + <img src="identity.svg" alt="loopat is a shell; the git platform is the identity layer; the credential chain is deploy key → git-crypt → vault" width="100%"> +</p> + +Most tools build their own user system: accounts, roles, permission tables. +loopat builds none of that. Identity, membership, and access already exist — in +the git platform your team already uses. loopat **integrates into that platform** +so that, at registration, a single authorization (an OAuth grant, or one token) +lets loopat set everything up *for* the user. The user never does fiddly git +plumbing, and the only thing they must understand is something they already +have: **their git account.** + +This is the identity companion to [`context-flow.md`](context-flow.md): +context-flow describes *how context moves* between a loop and its remotes; this +doc describes *who the loop acts as* when it does, *what unlocks* that identity, +and *how loopat integrates with the git host* to set it all up automatically. + +--- + +# Part 1 — the identity model + +## A loopat account is almost empty + +Strip a loopat account down and only two things remain: + +1. **a pointer** — which `personal/<user>` repo is yours, and +2. **the git-crypt key** — the one secret that decrypts that repo's vault. + +There is no password-protected profile, no permission matrix, no credential +store of loopat's own. Your real identity (a git account) and your real +credentials (inside your own repo) live in git; the loopat account is just a +pointer plus an unlock key. + +## The credential chain — three things, one line + +``` + deploy key ──► git-crypt key ──► vault credentials ──► every git op + (bootstrap) (decrypt) (your own git key) (pull / push) + + clone personal open the vault run as you personal · notes · + the first time inside personal thereafter knowledge · repos +``` + +| thing | where it lives | what it does | in the sandbox? | +|---|---|---|---| +| **deploy key** | host-side, **outside** `personal/` | `git clone` the personal repo — **bootstrap only** | no | +| **git-crypt key** | on the host today (see below) | decrypt the git-crypt'd **vault** inside `personal/` | no | +| **vault credentials** | inside `personal/`, git-crypt'd | **every runtime pull/push**, for every repo | yes (decrypted) | + +The deploy key pulls `personal/` down → the git-crypt key opens its vault → the +vault's credentials run all the git from then on. Three links, each one job. + +## Runtime is symmetric — everything uses the vault + +Once the chain has run, there is no special case. Every repo — `personal`, +`notes`, `knowledge`, every code repo — is pulled and pushed in the loop with +**the same vault credentials** (your own git key), against its own `origin`: + +- **personal is not an exception.** Its vault credentials push its own `origin` + like any other repo — not self-reference at runtime, because by then + `personal/` is already on disk and decrypted. +- **solo vs team is just the `origin` address** — a local repo path or a remote + team server. Same key, same commands, no mechanism difference. + +The loop acts **as you**, so git authorship is honest and access is your own +membership on each repo — granted and revoked in the git platform, per person, +with no shared master credential. + +## Bootstrap vs runtime — no chicken-and-egg + +The vault holds the credentials, but the vault lives *inside* `personal/`. The +first time, that loops: to read the credentials you need the repo, but to fetch +the repo you'd need a credential. It's broken by the **one key that lives +outside the box it opens** — the deploy key, kept host-side, never in +`personal/`. Its sole job is the first `git clone personal`; after that the vault +is in hand and the deploy key steps aside. Such a bootstrap secret is unavoidable +for any "no database, data in the user's own repo" design. + +## The git-crypt key: on the host today, one-shot tomorrow + +- **today (simple):** kept on the host, so loops just work. +- **tomorrow (secure):** the user supplies it **once, at loop creation** — loopat + decrypts and **forgets**, never persisting it at rest. + +Only the key's residency moves; the rest of the model is identical. + +--- + +# Part 2 — integrating with a git host + +Everything above assumes the per-user pieces already exist: a personal repo, a +deploy key on it, a runtime key the user's account trusts, and membership on the +team repos. **Integration is loopat doing all of that for the user**, driven by a +single authorization at registration. The model is platform-agnostic; what +differs per platform is only *how* each step is performed. + +## The integration contract + +To onboard a user, loopat needs the host to support five operations. Any platform +that offers these can be a loopat backend: + +| # | capability | loopat uses it to … | +|---|---|---| +| 1 | **authenticate a user** | turn a one-time OAuth grant / token into "this is user X" | +| 2 | **create a repo** in the user's namespace | make the private `personal/<user>` repo | +| 3 | **register a deploy key** on a repo | put loopat's **deploy key** on `personal` for bootstrap clone | +| 4 | **register an account key** for the user | trust loopat's generated **runtime key** so the loop can reach every repo the user may | +| 5 | **grant a member access** to a repo | add the user to `knowledge` / `notes` / project repos (usually admin-gated) | + +**Key generation is loopat's, registration is the platform's.** loopat generates +two ed25519 keypairs per user and registers the *public* halves through the +platform; the private halves go to their two homes from the chain: + +- a **deploy key** → registered on the `personal` repo (read) → private half + host-side (`host-secrets/<user>/`), for bootstrap. +- a **runtime key** → registered on the user's account (or as a deploy key on + each repo) → private half written into the **vault** (git-crypt'd inside + `personal/`), for all runtime git. + +So "how does the user's key get generated?" — loopat generates it; the +authorization just lets loopat *register* it. The user pastes nothing. + +## Example A — self-hosted / purely local git + +The simplest backend has **no platform at all**: loopat `git init --bare`s a repo +per context repo on the same machine and points each `origin` at it. With no +platform the whole chain collapses — `file://` clones need no deploy key, local +pushes need no credential, git-crypt is optional. Just loopat and git, zero +external dependencies. This is "works solo" at its most literal. + +Scaling that up, self-hosting takes two shapes depending on whether the host +has an API: + +- **A managed self-host (e.g. self-run GitLab):** it has a REST API and the same + five capabilities as a SaaS — integrate exactly like GitHub below, just against + your own URL and an admin/owner token. +- **A bare git server (sshd + bare repos, no API):** there's no "API", so the five + capabilities map to **server-side admin over ssh**: + - *create repo* → `git init --bare /srv/git/<...>.git` (personal, knowledge, …) + - *register key / grant access* → append the public key to the right + `~/.ssh/authorized_keys` (or a gitolite/`authorized_keys` rule) — on a bare + server, "a key" and "access" are the same thing. + - *authenticate* → there's no OAuth; the admin registers users out-of-band. + + loopat reaches the server with an **admin ssh credential** to run these, and + every user's runtime key is just another `authorized_keys` entry. Minimal, and + proof the model needs nothing fancy. + +## Example B — GitHub + +- **authenticate** → a **GitHub OAuth App**: the user clicks "Authorize", loopat + receives a scoped token (or the user pastes a PAT). This token does onboarding + chores and is **never** the runtime credential — it stays host-side. +- **create personal repo** → `POST /user/repos` (private). +- **deploy key** → `POST /repos/{owner}/personal/keys` (read-only). +- **runtime key** → `POST /user/keys` (account-level → reaches every repo the user + may), private half → vault. +- **grant team access** → `PUT /repos/{org}/{repo}/collaborators/{user}` — needs + admin on the team repos, so this step runs with an **org-admin** token at + team-setup time, not the user's own token. + +## Example C — an internal platform + +An internal platform (Aone, an in-house Gitee, etc.) is a loopat backend the +moment it can satisfy the five-capability contract. To integrate one, wire up: + +- **an OAuth app / SSO** so loopat can authenticate users (capability 1); +- **API endpoints** (or equivalent CLI/automation) for create-repo, register-key, + and add-member (capabilities 2–5); +- **an admin identity** loopat uses for the admin-gated step (5) — granting team + members access to `knowledge`/`notes`; +- **the base URL + auth scheme** in loopat's workspace config. + +There is nothing platform-specific in loopat's core — adding a new backend is +writing a small adapter that maps these five operations onto that platform's API. +In code this is the `GitHostProvider` interface (`server/src/git-host.ts`): a +second-party platform implements those five methods and adds one `import` line +to `server/src/providers.ts` — loopat core stays untouched. GitHub ships as the +built-in `githubProvider`. + +--- + +# Security boundary + +- **What a loop sees:** the decrypted **vault credentials** — a key that can only + *read and write git* on the repos your account already reaches. +- **What a loop never sees:** the **onboarding/admin token** (which could create or + delete repos and change permissions) and — in the secure mode — the + **git-crypt key** at rest. Account-shaping actions stay outside the loop. +- **Revocation:** the deploy key, the runtime key, and the granted memberships are + ordinary git-platform entries — revoke any of them, independently. + +--- + +# Relationship to context-flow + +| | [`context-flow.md`](context-flow.md) | this doc | +|---|---|---| +| answers | **how** context moves (① pull / ② promote) | **who** the loop acts as, what unlocks it, how it's set up | +| unit | the edge between loop and `origin` | the credential chain behind every edge | + +Together: a loop **pulls and promotes** context (context-flow) **as you, with +your own git credentials** (identity), set up by loopat **integrating with your +git host** — while loopat itself holds almost nothing. diff --git a/docs/identity.svg b/docs/identity.svg new file mode 100644 index 00000000..c248ae41 --- /dev/null +++ b/docs/identity.svg @@ -0,0 +1,87 @@ +<svg viewBox="0 0 960 450" xmlns="http://www.w3.org/2000/svg" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"> + <defs> + <marker id="arr" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto"> + <path d="M0,0 L10,5 L0,10 z" fill="#9ca3af"/> + </marker> + </defs> + <rect x="0" y="0" width="960" height="450" fill="#ffffff"/> + + <!-- title --> + <text x="480" y="38" text-anchor="middle" font-size="24" font-weight="700" fill="#111827">loopat identity</text> + <text x="480" y="60" text-anchor="middle" font-size="13" fill="#6b7280">loopat is a shell — your git platform is the real identity &amp; permission layer</text> + + <!-- ===== Card A: git platform ===== --> + <rect x="24" y="92" width="236" height="312" rx="12" fill="#f8fafc" stroke="#e6e8ec"/> + <text x="44" y="120" font-size="13" font-weight="700" fill="#2563eb" letter-spacing="0.5">GIT PLATFORM</text> + <line x1="44" y1="130" x2="240" y2="130" stroke="#eceef1"/> + <circle cx="56" cy="160" r="12" fill="#2563eb"/> + <text x="56" y="164" text-anchor="middle" font-size="12" font-weight="700" fill="#fff">G</text> + <text x="76" y="156" font-size="12.5" font-weight="600" fill="#1f2937">github · gitlab</text> + <text x="76" y="172" font-size="11.5" fill="#9ca3af">aone · bare git server</text> + <text x="44" y="208" font-size="12" fill="#6b7280">the real source of truth for:</text> + <text x="50" y="232" font-size="12" fill="#374151">— accounts &amp; identity</text> + <text x="50" y="252" font-size="12" fill="#374151">— repo access</text> + <text x="50" y="272" font-size="12" fill="#374151">— team membership</text> + <text x="44" y="384" font-size="11" font-style="italic" fill="#9ca3af">loopat stores none of this.</text> + + <!-- arrow A -> B --> + <line x1="262" y1="240" x2="300" y2="240" stroke="#9ca3af" stroke-width="1.6" marker-end="url(#arr)"/> + <text x="281" y="232" text-anchor="middle" font-size="9" fill="#9ca3af">authorize</text> + + <!-- ===== Card B: loopat shell ===== --> + <rect x="302" y="92" width="186" height="312" rx="12" fill="#f8fafc" stroke="#e6e8ec"/> + <text x="322" y="120" font-size="13" font-weight="700" fill="#ea580c" letter-spacing="0.5">loopat</text> + <text x="468" y="120" text-anchor="end" font-size="9" fill="#9ca3af" letter-spacing="0.5">THE SHELL</text> + <line x1="322" y1="130" x2="468" y2="130" stroke="#eceef1"/> + <circle cx="334" cy="160" r="12" fill="#ea580c"/> + <text x="334" y="164" text-anchor="middle" font-size="12" font-weight="700" fill="#fff">L</text> + <text x="354" y="164" font-size="12.5" font-weight="600" fill="#1f2937">account ≈ empty</text> + <text x="322" y="206" font-size="12" fill="#6b7280">it holds only:</text> + <text x="328" y="232" font-size="12.5" fill="#374151">① a pointer →</text> + <text x="344" y="250" font-size="11.5" font-family="ui-monospace, monospace" fill="#7c3aed">personal/&lt;user&gt;</text> + <text x="328" y="284" font-size="12.5" fill="#374151">② the git-crypt key</text> + <text x="344" y="302" font-size="11.5" fill="#9ca3af">unlocks the vault</text> + <text x="322" y="384" font-size="11" font-style="italic" fill="#9ca3af">a pointer + one key.</text> + + <!-- arrow B -> C --> + <line x1="490" y1="240" x2="524" y2="240" stroke="#9ca3af" stroke-width="1.6" marker-end="url(#arr)"/> + + <!-- ===== Card C: credential chain ===== --> + <rect x="526" y="92" width="410" height="312" rx="12" fill="#f8fafc" stroke="#e6e8ec"/> + <text x="546" y="120" font-size="13" font-weight="700" fill="#0f766e" letter-spacing="0.5">CREDENTIAL CHAIN</text> + <text x="916" y="120" text-anchor="end" font-size="9" fill="#9ca3af" letter-spacing="0.5">BOOTSTRAP → RUNTIME</text> + <line x1="546" y1="130" x2="916" y2="130" stroke="#eceef1"/> + + <!-- link 1: deploy key --> + <circle cx="560" cy="166" r="13" fill="#7c3aed"/> + <text x="560" y="170" text-anchor="middle" font-size="11" font-weight="700" fill="#fff">1</text> + <text x="584" y="162" font-size="13" font-weight="600" fill="#1f2937">deploy key</text> + <text x="584" y="178" font-size="11.5" fill="#9ca3af">host-side · outside personal · clone personal (bootstrap)</text> + <text x="916" y="166" text-anchor="end" font-size="10" fill="#b91c1c">✗ sandbox</text> + + <line x1="560" y1="184" x2="560" y2="216" stroke="#cbd5e1" stroke-width="1.6" marker-end="url(#arr)"/> + + <!-- link 2: git-crypt key --> + <circle cx="560" cy="236" r="13" fill="#d97706"/> + <text x="560" y="240" text-anchor="middle" font-size="11" font-weight="700" fill="#fff">2</text> + <text x="584" y="232" font-size="13" font-weight="600" fill="#1f2937">git-crypt key</text> + <text x="584" y="248" font-size="11.5" fill="#9ca3af">decrypts the vault inside personal</text> + <text x="916" y="236" text-anchor="end" font-size="10" fill="#b91c1c">✗ sandbox</text> + + <line x1="560" y1="254" x2="560" y2="286" stroke="#cbd5e1" stroke-width="1.6" marker-end="url(#arr)"/> + + <!-- link 3: vault creds --> + <circle cx="560" cy="306" r="13" fill="#16a34a"/> + <text x="560" y="310" text-anchor="middle" font-size="11" font-weight="700" fill="#fff">3</text> + <text x="584" y="302" font-size="13" font-weight="600" fill="#1f2937">vault credentials</text> + <text x="584" y="318" font-size="11.5" fill="#9ca3af">your own git key · every pull/push (runtime)</text> + <text x="916" y="306" text-anchor="end" font-size="10" fill="#16a34a">✓ sandbox</text> + + <!-- runtime targets --> + <line x1="546" y1="344" x2="916" y2="344" stroke="#eceef1"/> + <text x="546" y="368" font-size="11.5" fill="#6b7280">every repo runs as you, against its own origin:</text> + <text x="546" y="388" font-size="12" font-weight="600" fill="#0f766e">personal · notes · knowledge · repos</text> + + <!-- bottom note --> + <text x="480" y="436" text-anchor="middle" font-size="12" fill="#6b7280">one authorization sets it all up — loopat generates the keys, the platform registers them · solo vs team = just the origin address</text> +</svg> diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 00000000..9688e26f --- /dev/null +++ b/docs/install.md @@ -0,0 +1,119 @@ +# Installation guide + +The README's **Quick start** is enough for a solo dev on Linux. This page +covers everything else: system dependencies in detail, team setups with +shared knowledge/notes git repos, environment variables, and what the +bootstrap actually does on first run. + +## System dependencies + +```sh +sudo apt install bubblewrap openssh-client +curl -fsSL https://bun.sh/install | bash +curl -fsSL https://mise.run | sh +``` + +| Tool | Role | Notes | +|---|---|---| +| **bubblewrap** | per-loop sandbox (Linux only) | required; on macOS/Windows use Docker | +| **openssh-client** | deploy-key flow for `personal/` import | required if you bind external git repos into vaults | +| **bun** | runtime + bundler | required | +| **mise** | per-loop toolchain manager | required only for loops whose composed `.claude/mise.toml` is non-empty | + +### About `mise` + +When a loop's merged `.claude/mise.toml` (composed from team / profile / +personal tiers — see [composition.md](composition.md)) declares tools, the +server runs `mise install` on the host and binds the tool installs into the +sandbox. Without `mise` on PATH, such loops fail at spawn; loops whose +merged `mise.toml` is empty still work normally. + +mise data lives at `~/.local/share/mise/installs/`. loopat binds that path +read-only into each loop's sandbox, so tool installs are shared across loops +(install once, every loop sees it). + +If `https://mise.run` isn't reachable: + +- macOS: `brew install mise` +- Rust: `cargo install mise` +- Manual: grab a release from <https://github.com/jdx/mise/releases> and drop it on PATH + +## Clone and install + +```sh +git clone https://github.com/simpx/loopat.git +cd loopat +bun install # also pulls the platform-specific claude binary +``` + +## First run + +```sh +bun run dev # listens on 127.0.0.1 +bun run dev:host # listens on 0.0.0.0 (accessible from LAN) +``` + +On the very first run the server populates `LOOPAT_HOME` (default +`~/.loopat`) with: + +- `config.json` — self-describing manifest (apiKey + optional remote git URLs for `knowledge` / `notes`) +- `context/knowledge/` — cloned from `config.knowledge.git` if set, else empty dir +- `context/knowledge/loopat/CLAUDE.md` — sandbox doctrine, seeded from `server/templates/` if absent +- `context/notes/` — cloned from `config.notes.git` if set, else `git init`'d locally for auto-commit +- `context/repos/`, `personal/<user>/` — empty skeletons +- `personal/<user>/` gets `git init`'d so vault writes auto-commit + +It prints a checklist banner. The only thing you have to do manually is set +your API key: + +``` +✗ apiKey (<provider>) + → edit ~/.loopat/config.json → set providers.<provider>.apiKey +``` + +Open `config.json`, fill in your key, optionally set `knowledge.git` / +`notes.git` to your team's remote, then `bun run dev` again. Hand this +`config.json` to a clean machine and bootstrap reconstructs the same +workspace. + +When the banner ends with `ready.`, open <http://localhost:10001> and create +your first loop. + +## Team setup — shared knowledge and notes + +For a team that wants a shared `knowledge/` and `notes/` git repo, set +`knowledge.git` and `notes.git` in `config.json`: + +```json +{ + "knowledge": { "git": "git@github.com:your-team/loopat-knowledge.git" }, + "notes": { "git": "git@github.com:your-team/loopat-notes.git" }, + "providers": { "anthropic": { "apiKey": "sk-…" } } +} +``` + +The first run on each member's machine will clone these repos into +`$LOOPAT_HOME/context/`. Edits and commits made by loops auto-push to the +shared remote — every member sees the same evolving knowledge. + +Per-user credentials live in `personal/<user>/` and are **never** committed +to the shared repos (separate `personal/` git initialized locally). + +## Environment variables + +| var | default | use | +|---|---|---| +| `LOOPAT_HOME` | `~/.loopat` | workspace directory. Single workspace per loopat instance — to run a second workspace, start another loopat with a different `LOOPAT_HOME`. URL/display name = basename minus leading dots (`~/.loopat` → `loopat`). | +| `LOOPAT_USER` | `$USER` | active driver name; also where `personal/` lives | +| `HOST` | `127.0.0.1` | server bind address. Set to `0.0.0.0` to accept connections from LAN / ngrok. Also passed to Vite dev server. | +| `PORT` | `10001` | server port | + +## Verifying it works + +1. Banner ends with `ready.` +2. <http://localhost:10001> loads +3. Create a loop, send a message, see the agent respond +4. Check `$LOOPAT_HOME/context/repos/<name>/` — the loop's branch should + exist with auto-commits + +If any of these fail, see [troubleshoot.md](troubleshoot.md). diff --git a/docs/notes-realtime.md b/docs/notes-realtime.md new file mode 100644 index 00000000..2302461e --- /dev/null +++ b/docs/notes-realtime.md @@ -0,0 +1,72 @@ +--- +title: notes realtime — git as a consistency backend +tags: [loopat, notes, git, realtime, collaboration] +status: design (stage 0 implemented) +--- + +# notes realtime (git-as-database) + +Multi-user notes editing where **git is the single source of truth** and +**realtime is a best-effort layer on top**. The realtime layer never owns +correctness — it only makes conflicts *rarer* by keeping everyone close to the +latest. If it stalls, the worst case is more held-back conflicts, never lost +data or inconsistency. + +This is a concrete application of [`context-flow.md`](context-flow.md): notes is +edited by **no-AI UI loops**, one per user, all converging on `origin`. + +--- + +## Model + +- **Each browser session edits through a per-user UI-loop worktree** opened from + `origin/main`. A no-AI loop. (Session granularity is per-user for now; a + per-tab dimension can be added later without changing the model.) +- **Save is the loop-outside rule:** `commit → rebase onto origin/main → + ff-push HEAD:main`; a real same-spot conflict is **held back** — the local + edit is kept and surfaced, never silently merged or auto-discarded. +- **Data is one-item-per-file**, so the conflict unit is a file. Different people + editing different notes never collide; only the *same* note edited at once + conflicts — rare, and precise. The index (`MEMORY.md`-style) is the one shared + hot spot; keep it append-only / sort-normalized so it auto-merges too. +- **A server is a disposable replica** of `origin` plus a gateway — never + authoritative, rebuildable from `origin` at any time. So **multi-user on one + server and across servers are the same thing**. + +## Realtime = a change feed, two tiers + +Correctness is git's; the feed only propagates "something landed" faster. + +- **Server-internal (free, event-driven).** A save passes through the server, so + right after a successful push the server broadcasts to its *own* other + sessions — no poll, near-zero latency. +- **Cross-server (a `ChangeSource`).** The only shared point is `origin`, so each + server watches it. Pluggable, **poll first** (assume `origin` is remote; + interval tunable), webhook / platform-SSE / fs-watch later. "2s poll" is just a + parameter of the poll implementation, not the model. + +A change (internal event or `ChangeSource`) → server fetches → its worktree +ff-updates → it pushes the event to the page over **SSE** → the page pulls and +refreshes (guarding any in-flight local edit). + +## Stages + +- **Stage 0 — done** ([commit `50945bd`]). Per-user worktree + (`ensureUiNotesWorktree`) + explicit save (`syncUiNotes` = ff-only + rebase + + held-back). `vaultRoot("notes")` resolves to the worktree; `POST + /api/notes/save` triggers the push. Regression test: + `server/test/ui-notes-sync.test.ts`. +- **Stage 1.** `ChangeSource` interface + a `PollSource`; a per-repo server event + bus (emit on save success); `GET /api/notes/events` (SSE); the page subscribes + and refreshes on events. +- **Stage 2.** Conflict UX parity with personal (take-remote / resolve-in-a-loop); + multi-session validation. +- **Stage 3.** Quality-of-life: auto-merging index, "this note is being edited by + X" hints. + +## Non-goals (for now) + +- **Character-level realtime** (Google-Docs same-line co-typing) — needs CRDT + + git checkpoints. Out of scope unless block-level editing proves insufficient. +- **Implicit/debounced save** — explicit save chosen on purpose (user controls + when an edit lands on the shared remote). diff --git a/docs/overview.html b/docs/overview.html new file mode 100644 index 00000000..a4ff4246 --- /dev/null +++ b/docs/overview.html @@ -0,0 +1,330 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8" /> +<title>loopat — overview</title> +<style> + html, body { + margin: 0; + background: #fbf9f4; + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Helvetica Neue", system-ui, sans-serif; + color: #1c2733; + -webkit-font-smoothing: antialiased; + } + .wrap { max-width: 1280px; margin: 0 auto; padding: 36px 16px 56px; } + svg.hero { display: block; width: 100%; height: auto; } +</style> +</head> +<body> +<div class="wrap"> + +<svg class="hero" viewBox="0 0 1280 650" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"> + <title id="t">loopat overview</title> + <desc id="d">Three cards (context, loop, chat) + a repos bar. Four simple colored arrows connect K/N/P sources and chat to the loop; inside the loop, 5 color-matched pills mirror those sources. Distillation curves on the left of the context card; write-back arc loops back to personal.</desc> + + <defs> + <marker id="arr-K" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#3b5bdb"/></marker> + <marker id="arr-N" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#d4a017"/></marker> + <marker id="arr-P" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#9333ea"/></marker> + <marker id="arr-C" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#16a34a"/></marker> + <marker id="arr-write" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#d97706"/></marker> + <marker id="arr-dist" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#d97706"/></marker> + + <!-- Brand-flow gradient — applied to "loop at" in the tagline to mark + the brand wordplay (loop + at = loopat). Colors are loopat's own + palette (K · P · orange · N · C · teal), looped back to K so the + animation seamlessly wraps. spreadMethod="repeat" + translate over + one period (80px) = continuous flow. --> + <linearGradient id="brand-flow" gradientUnits="userSpaceOnUse" + x1="0" y1="0" x2="80" y2="0" spreadMethod="repeat"> + <stop offset="0" stop-color="#3b5bdb"/> + <stop offset="0.166" stop-color="#9333ea"/> + <stop offset="0.333" stop-color="#d97706"/> + <stop offset="0.5" stop-color="#d4a017"/> + <stop offset="0.666" stop-color="#16a34a"/> + <stop offset="0.833" stop-color="#0d9488"/> + <stop offset="1" stop-color="#3b5bdb"/> + <animateTransform attributeName="gradientTransform" type="translate" + from="0 0" to="80 0" dur="4s" repeatCount="indefinite"/> + </linearGradient> + + <filter id="soft" x="-10%" y="-10%" width="120%" height="120%"> + <feGaussianBlur in="SourceAlpha" stdDeviation="3"/> + <feOffset dx="0" dy="2" result="off"/> + <feComponentTransfer><feFuncA type="linear" slope="0.18"/></feComponentTransfer> + <feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge> + </filter> + </defs> + + <!-- ── TITLE ────────────────────────────────────────────────── --> + <text x="640" y="46" text-anchor="middle" font-size="40" font-weight="700" letter-spacing="-0.5" fill="#1c2733">loopat</text> + <text x="640" y="82" text-anchor="middle" font-size="16" fill="#5b6776"><tspan font-weight="600" fill="url(#brand-flow)">loop at</tspan> context, distill into knowledge</text> + + <!-- ══════════════════════════════════════════════════════════════ + LEFT CARD — CONTEXT (x=60..460, y=120..550) + ══════════════════════════════════════════════════════════════ --> + <g filter="url(#soft)"> + <rect x="60" y="120" width="400" height="430" rx="14" fill="#ffffff" stroke="#d8dde4" stroke-width="1"/> + </g> + <rect x="60" y="120" width="400" height="40" rx="14" fill="#eef2f7"/> + <rect x="60" y="146" width="400" height="14" fill="#eef2f7"/> + <text x="80" y="146" font-size="13" font-weight="700" fill="#5b6776" letter-spacing="2">CONTEXT</text> + <text x="440" y="146" font-size="11" text-anchor="end" fill="#7e8794" letter-spacing="1">PERSISTENT · VERSIONED</text> + + <line x1="76" y1="300" x2="444" y2="300" stroke="#eaecf1" stroke-width="1"/> + <line x1="76" y1="420" x2="444" y2="420" stroke="#eaecf1" stroke-width="1"/> + + <!-- KNOWLEDGE zone --> + <g> + <circle cx="92" cy="185" r="14" fill="#3b5bdb"/> + <text x="92" y="190" text-anchor="middle" font-size="12" font-weight="700" fill="white">K</text> + <text x="115" y="183" font-size="17" font-weight="600" fill="#1c2733">knowledge</text> + <text x="115" y="201" font-size="11" fill="#7e8794" letter-spacing="0.3">team-shared</text> + <g transform="translate(404, 173)" stroke="#3b5bdb" fill="#dfe6fb" stroke-width="1.2"> + <path d="M0,4 L8,4 L11,1 L28,1 L28,18 L0,18 z"/> + <line x1="4" y1="9" x2="24" y2="9" stroke="#3b5bdb" stroke-width="0.8"/> + <line x1="4" y1="13" x2="24" y2="13" stroke="#3b5bdb" stroke-width="0.8"/> + </g> + <g font-size="13" fill="#1c2733"> + <text x="125" y="226">docs</text> + <text x="125" y="242">skills</text> + <text x="125" y="258">mcp servers</text> + <text x="125" y="274">sandboxes</text> + </g> + <g stroke="#3b5bdb" stroke-width="1.5" fill="none"> + <path d="M 110 223 L 118 223"/> + <path d="M 110 239 L 118 239"/> + <path d="M 110 255 L 118 255"/> + <path d="M 110 271 L 118 271"/> + </g> + <g font-size="10" fill="#9ba3ad" font-style="italic"> + <text x="210" y="226">— curated articles</text> + <text x="210" y="242">— reusable procedures</text> + <text x="210" y="258">— jira / github / …</text> + <text x="210" y="274">— toolchain catalog</text> + </g> + </g> + + <!-- NOTES zone --> + <g> + <circle cx="92" cy="325" r="14" fill="#d4a017"/> + <text x="92" y="330" text-anchor="middle" font-size="12" font-weight="700" fill="white">N</text> + <text x="115" y="323" font-size="17" font-weight="600" fill="#1c2733">notes</text> + <text x="115" y="341" font-size="11" fill="#7e8794" letter-spacing="0.3">team-shared</text> + <g transform="translate(404, 313)" stroke="#a07807" fill="#fcefb6" stroke-width="1.2"> + <path d="M0,4 L8,4 L11,1 L28,1 L28,18 L0,18 z"/> + <line x1="4" y1="9" x2="24" y2="9" stroke="#a07807" stroke-width="0.8"/> + <line x1="4" y1="13" x2="24" y2="13" stroke="#a07807" stroke-width="0.8"/> + </g> + <g font-size="13" fill="#1c2733"> + <text x="125" y="368">inbox</text> + <text x="125" y="384">focus</text> + <text x="125" y="400">memory</text> + </g> + <g stroke="#a07807" stroke-width="1.5" fill="none"> + <path d="M 110 365 L 118 365"/> + <path d="M 110 381 L 118 381"/> + <path d="M 110 397 L 118 397"/> + </g> + <g font-size="10" fill="#9ba3ad" font-style="italic"> + <text x="210" y="368">— workspace scratch</text> + <text x="210" y="384">— task trees</text> + <text x="210" y="400">— shared team memory</text> + </g> + </g> + + <!-- PERSONAL zone --> + <g> + <circle cx="92" cy="445" r="14" fill="#9333ea"/> + <text x="92" y="450" text-anchor="middle" font-size="12" font-weight="700" fill="white">P</text> + <text x="115" y="443" font-size="17" font-weight="600" fill="#1c2733">personal</text> + <text x="115" y="461" font-size="11" fill="#7e8794" letter-spacing="0.3">yours · per-user</text> + <g transform="translate(404, 433)" stroke="#9333ea" fill="#f3e3fb" stroke-width="1.2"> + <path d="M0,4 L8,4 L11,1 L28,1 L28,18 L0,18 z"/> + </g> + <g transform="translate(413, 440)"> + <rect x="0" y="4" width="10" height="8" rx="1.2" fill="#9333ea"/> + <path d="M2,4 A 3,3 0 0 1 8,4" fill="none" stroke="#9333ea" stroke-width="1.6"/> + </g> + <g font-size="13" fill="#1c2733"> + <text x="125" y="488">memory</text> + <text x="125" y="504">config</text> + <text x="125" y="520">vaults</text> + </g> + <g stroke="#9333ea" stroke-width="1.5" fill="none"> + <path d="M 110 485 L 118 485"/> + <path d="M 110 501 L 118 501"/> + <path d="M 110 517 L 118 517"/> + </g> + <g font-size="10" fill="#9ba3ad" font-style="italic"> + <text x="210" y="488">— your private memory</text> + <text x="210" y="504">— providers · mounts</text> + <text x="210" y="520">— encrypted · one selected per loop</text> + </g> + <g transform="translate(189, 512)"> + <rect x="0" y="3" width="6" height="5" rx="1" fill="#9333ea"/> + <path d="M 1.2 3 A 1.8 1.8 0 0 1 4.8 3" fill="none" stroke="#9333ea" stroke-width="1.1"/> + </g> + </g> + + <!-- ── DISTILLATION curves on LEFT edge — both labeled "distill" ── --> + <g fill="none" stroke="#d97706" stroke-width="1.5" stroke-dasharray="3 4"> + <path d="M 60 460 C 18 440, 18 365, 60 348" marker-end="url(#arr-dist)"/> + <path d="M 60 325 C 18 305, 18 230, 60 213" marker-end="url(#arr-dist)"/> + </g> + <text x="34" y="408" text-anchor="middle" font-size="10" font-style="italic" fill="#d97706">distill</text> + <text x="34" y="272" text-anchor="middle" font-size="10" font-style="italic" fill="#d97706">distill</text> + + <!-- ══════════════════════════════════════════════════════════════ + MIDDLE CARD — LOOP (x=500..980, y=120..550) + Title simplified: "LOOP" + "EPHEMERAL". + Sandbox label drops "(bwrap)". + ══════════════════════════════════════════════════════════════ --> + <g filter="url(#soft)"> + <rect x="500" y="120" width="480" height="430" rx="14" fill="#ffffff" stroke="#d8dde4" stroke-width="1"/> + </g> + <rect x="500" y="120" width="480" height="40" rx="14" fill="#fef3c7"/> + <rect x="500" y="146" width="480" height="14" fill="#fef3c7"/> + <text x="520" y="146" font-size="13" font-weight="700" fill="#8a6418" letter-spacing="2">LOOP</text> + <text x="960" y="146" font-size="11" text-anchor="end" fill="#a47e2c" letter-spacing="1">EPHEMERAL · REPRODUCIBLE</text> + + <rect x="525" y="178" width="430" height="350" rx="10" fill="#fffbef" stroke="#8a6418" stroke-width="1.5" stroke-dasharray="6 4"/> + <text x="740" y="200" text-anchor="middle" font-size="11" fill="#8a6418" letter-spacing="3" font-weight="600">SANDBOX</text> + + <!-- context band — 4 separate pills, color-coded to outside cards --> + <text x="540" y="226" font-size="10.5" fill="#8a6418" font-style="italic">bound context</text> + <!-- 4 pills (K/N/P/C) — wider than 5-pill layout, evenly spaced --> + <g> + <rect x="545" y="234" width="90" height="32" rx="6" fill="#3b5bdb"/> + <text x="590" y="255" text-anchor="middle" font-size="13" font-weight="700" fill="white">K</text> + <rect x="645" y="234" width="90" height="32" rx="6" fill="#d4a017"/> + <text x="690" y="255" text-anchor="middle" font-size="13" font-weight="700" fill="white">N</text> + <rect x="745" y="234" width="90" height="32" rx="6" fill="#9333ea"/> + <text x="790" y="255" text-anchor="middle" font-size="13" font-weight="700" fill="white">P</text> + <rect x="845" y="234" width="90" height="32" rx="6" fill="#16a34a"/> + <text x="890" y="255" text-anchor="middle" font-size="13" font-weight="700" fill="white">C</text> + </g> + <g font-size="9" fill="#a47e2c" text-anchor="middle" letter-spacing="0.5"> + <text x="590" y="278">ro</text> + <text x="690" y="278">rw</text> + <text x="790" y="278">rw</text> + <text x="890" y="278">ro</text> + </g> + <text x="740" y="296" text-anchor="middle" font-size="10" fill="#8a6418" font-style="italic">mounted from outside · colors match source cards</text> + + <!-- agent --> + <line x1="540" y1="300" x2="940" y2="300" stroke="#e1d4b3" stroke-width="0.8" stroke-dasharray="2 3"/> + <g> + <circle cx="740" cy="350" r="34" fill="#fbbf24" stroke="#8a6418" stroke-width="1.8"/> + <text x="740" y="357" text-anchor="middle" font-size="16" font-weight="700" fill="#3d2c08">agent</text> + </g> + + <!-- /loopat/loop/<id>/ organ row — simplified copy --> + <line x1="540" y1="395" x2="940" y2="395" stroke="#e1d4b3" stroke-width="0.8" stroke-dasharray="2 3"/> + <text x="540" y="411" font-size="10.5" fill="#8a6418" font-style="italic">selected at spawn</text> + <g> + <rect x="545" y="419" width="124" height="80" rx="8" fill="#eef2ff" stroke="#3b5bdb" stroke-width="1.2"/> + <text x="607" y="446" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">workdir</text> + <text x="607" y="467" text-anchor="middle" font-size="11" fill="#3b5bdb">git worktree</text> + <text x="607" y="484" text-anchor="middle" font-size="11" fill="#3b5bdb">rw</text> + </g> + <g> + <rect x="677" y="419" width="124" height="80" rx="8" fill="#fce7f3" stroke="#be185d" stroke-width="1.2"/> + <text x="739" y="446" text-anchor="middle" font-size="14" font-weight="700" fill="#831843">vault</text> + <text x="739" y="467" text-anchor="middle" font-size="11" fill="#be185d">encrypted</text> + <text x="739" y="484" text-anchor="middle" font-size="11" fill="#be185d">one of many</text> + </g> + <g> + <rect x="809" y="419" width="124" height="80" rx="8" fill="#ccfbf1" stroke="#0d9488" stroke-width="1.2"/> + <text x="871" y="446" text-anchor="middle" font-size="14" font-weight="700" fill="#134e4a">toolchain</text> + <text x="871" y="467" text-anchor="middle" font-size="11" fill="#0d9488">cli + mcp</text> + <text x="871" y="484" text-anchor="middle" font-size="11" fill="#0d9488">managed catalog</text> + </g> + + <text x="740" y="518" text-anchor="middle" font-size="10.5" fill="#8a6418" font-style="italic">one mount namespace · isolated · disposable</text> + + <!-- ══════════════════════════════════════════════════════════════ + RIGHT CARD — CHAT (no spawn-loop label; one simple arrow) + ══════════════════════════════════════════════════════════════ --> + <g filter="url(#soft)"> + <rect x="1000" y="120" width="220" height="430" rx="14" fill="#ffffff" stroke="#d8dde4" stroke-width="1"/> + </g> + <rect x="1000" y="120" width="220" height="40" rx="14" fill="#e3f2eb"/> + <rect x="1000" y="146" width="220" height="14" fill="#e3f2eb"/> + <text x="1020" y="146" font-size="13" font-weight="700" fill="#1e6b3b" letter-spacing="2">CHAT</text> + <text x="1200" y="146" font-size="11" text-anchor="end" fill="#3d8458" letter-spacing="1">LIVE · STREAMING</text> + + <circle cx="1030" cy="185" r="14" fill="#16a34a"/> + <text x="1030" y="190" text-anchor="middle" font-size="12" font-weight="700" fill="white">C</text> + <text x="1055" y="183" font-size="17" font-weight="600" fill="#1c2733">chat</text> + <text x="1055" y="201" font-size="11" fill="#7e8794" letter-spacing="0.3">agents · live threads</text> + + <!-- 3 thread bubbles. Each bubble shows alternating speakers via + line color: green = a person, amber = an agent. The amber matches + the loop's agent circle (#fbbf24) — same agent color identity. --> + <g> + <!-- bubble 1: human asks, agent replies, human follow-up --> + <g> + <rect x="1018" y="222" width="186" height="42" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/> + <line x1="1030" y1="236" x2="1190" y2="236" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="244" x2="1170" y2="244" stroke="#d4a017" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="252" x2="1145" y2="252" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/> + </g> + <!-- bubble 2: agent opens, human answers, agent replies --> + <g> + <rect x="1018" y="276" width="186" height="42" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/> + <line x1="1030" y1="290" x2="1170" y2="290" stroke="#d4a017" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="298" x2="1180" y2="298" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="306" x2="1140" y2="306" stroke="#d4a017" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/> + </g> + <!-- bubble 3 --> + <g> + <rect x="1018" y="330" width="186" height="42" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/> + <line x1="1030" y1="344" x2="1150" y2="344" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="352" x2="1190" y2="352" stroke="#d4a017" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="360" x2="1130" y2="360" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/> + </g> + </g> + + <!-- legend: tiny key tying line colors to speakers (kept; explains the + bubble visuals). The subtitle "agents · live threads" already names + the dual-party nature, so no separate footer needed. --> + <g font-size="10" font-family="inherit"> + <line x1="1030" y1="394" x2="1042" y2="394" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round"/> + <text x="1046" y="397" fill="#1e6b3b">human</text> + <line x1="1100" y1="394" x2="1112" y2="394" stroke="#d4a017" stroke-width="2.5" stroke-linecap="round"/> + <text x="1116" y="397" fill="#a47e2c">agent</text> + </g> + + <!-- ══════════════════════════════════════════════════════════════ + READ ARROWS — 4 simple colored lines pointing AT the loop card + (general direction, not internal targeting). + ══════════════════════════════════════════════════════════════ --> + <!-- K knowledge → loop --> + <path d="M 460 185 C 480 185, 495 220, 510 245" + stroke="#3b5bdb" stroke-width="1.4" fill="none" marker-end="url(#arr-K)"/> + <!-- N notes → loop --> + <path d="M 460 325 C 482 325, 498 345, 510 350" + stroke="#d4a017" stroke-width="1.4" fill="none" marker-end="url(#arr-N)"/> + <!-- P personal → loop --> + <path d="M 460 445 C 484 445, 498 455, 510 455" + stroke="#9333ea" stroke-width="1.4" fill="none" marker-end="url(#arr-P)"/> + <!-- C chat → loop --> + <path d="M 1000 350 C 990 350, 982 350, 970 350" + stroke="#16a34a" stroke-width="1.4" fill="none" marker-end="url(#arr-C)"/> + + <!-- ══════════════════════════════════════════════════════════════ + WRITE-BACK arc (slim) + ══════════════════════════════════════════════════════════════ --> + <!-- Simpler arc now that repos is gone — gentle dip from loop bottom to + personal zone right edge. --> + <path d="M 600 545 C 545 605, 485 605, 460 490" + stroke="#d97706" stroke-width="1.5" fill="none" stroke-dasharray="2 5" + marker-end="url(#arr-write)"/> + <text x="525" y="625" text-anchor="middle" font-size="13" font-weight="600" fill="#d97706" letter-spacing="2">memory</text> + +</svg> + +</div> +</body> +</html> diff --git a/docs/overview.svg b/docs/overview.svg new file mode 100644 index 00000000..1b0e573b --- /dev/null +++ b/docs/overview.svg @@ -0,0 +1,307 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg viewBox="0 0 1280 650" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"> + <title id="t">loopat overview</title> + <desc id="d">Three cards (context, loop, chat) + a repos bar. Four simple colored arrows connect K/N/P sources and chat to the loop; inside the loop, 5 color-matched pills mirror those sources. Distillation curves on the left of the context card; write-back arc loops back to personal.</desc> + + <defs> + <marker id="arr-K" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#3b5bdb"/></marker> + <marker id="arr-N" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#d4a017"/></marker> + <marker id="arr-P" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#9333ea"/></marker> + <marker id="arr-C" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#16a34a"/></marker> + <marker id="arr-write" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#d97706"/></marker> + <marker id="arr-dist" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#d97706"/></marker> + + <!-- Brand-flow gradient — applied to "loop at" in the tagline to mark + the brand wordplay (loop + at = loopat). Colors are loopat's own + palette (K · P · orange · N · C · teal), looped back to K so the + animation seamlessly wraps. spreadMethod="repeat" + translate over + one period (80px) = continuous flow. --> + <linearGradient id="brand-flow" gradientUnits="userSpaceOnUse" + x1="0" y1="0" x2="80" y2="0" spreadMethod="repeat"> + <stop offset="0" stop-color="#3b5bdb"/> + <stop offset="0.166" stop-color="#9333ea"/> + <stop offset="0.333" stop-color="#d97706"/> + <stop offset="0.5" stop-color="#d4a017"/> + <stop offset="0.666" stop-color="#16a34a"/> + <stop offset="0.833" stop-color="#0d9488"/> + <stop offset="1" stop-color="#3b5bdb"/> + <animateTransform attributeName="gradientTransform" type="translate" + from="0 0" to="80 0" dur="4s" repeatCount="indefinite"/> + </linearGradient> + + <filter id="soft" x="-10%" y="-10%" width="120%" height="120%"> + <feGaussianBlur in="SourceAlpha" stdDeviation="3"/> + <feOffset dx="0" dy="2" result="off"/> + <feComponentTransfer><feFuncA type="linear" slope="0.18"/></feComponentTransfer> + <feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge> + </filter> + </defs> + + <!-- ── TITLE ────────────────────────────────────────────────── --> + <text x="640" y="46" text-anchor="middle" font-size="40" font-weight="700" letter-spacing="-0.5" fill="#1c2733">loopat</text> + <text x="640" y="82" text-anchor="middle" font-size="16" fill="#5b6776"><tspan font-weight="600" fill="url(#brand-flow)">loop at</tspan> context, distill into knowledge</text> + + <!-- ══════════════════════════════════════════════════════════════ + LEFT CARD — CONTEXT (x=60..460, y=120..550) + ══════════════════════════════════════════════════════════════ --> + <g filter="url(#soft)"> + <rect x="60" y="120" width="400" height="430" rx="14" fill="#ffffff" stroke="#d8dde4" stroke-width="1"/> + </g> + <rect x="60" y="120" width="400" height="40" rx="14" fill="#eef2f7"/> + <rect x="60" y="146" width="400" height="14" fill="#eef2f7"/> + <text x="80" y="146" font-size="13" font-weight="700" fill="#5b6776" letter-spacing="2">CONTEXT</text> + <text x="440" y="146" font-size="11" text-anchor="end" fill="#7e8794" letter-spacing="1">PERSISTENT · VERSIONED</text> + + <line x1="76" y1="300" x2="444" y2="300" stroke="#eaecf1" stroke-width="1"/> + <line x1="76" y1="420" x2="444" y2="420" stroke="#eaecf1" stroke-width="1"/> + + <!-- KNOWLEDGE zone --> + <g> + <circle cx="92" cy="185" r="14" fill="#3b5bdb"/> + <text x="92" y="190" text-anchor="middle" font-size="12" font-weight="700" fill="white">K</text> + <text x="115" y="183" font-size="17" font-weight="600" fill="#1c2733">knowledge</text> + <text x="115" y="201" font-size="11" fill="#7e8794" letter-spacing="0.3">team-shared</text> + <g transform="translate(404, 173)" stroke="#3b5bdb" fill="#dfe6fb" stroke-width="1.2"> + <path d="M0,4 L8,4 L11,1 L28,1 L28,18 L0,18 z"/> + <line x1="4" y1="9" x2="24" y2="9" stroke="#3b5bdb" stroke-width="0.8"/> + <line x1="4" y1="13" x2="24" y2="13" stroke="#3b5bdb" stroke-width="0.8"/> + </g> + <g font-size="13" fill="#1c2733"> + <text x="125" y="226">docs</text> + <text x="125" y="242">skills</text> + <text x="125" y="258">mcp servers</text> + <text x="125" y="274">sandboxes</text> + </g> + <g stroke="#3b5bdb" stroke-width="1.5" fill="none"> + <path d="M 110 223 L 118 223"/> + <path d="M 110 239 L 118 239"/> + <path d="M 110 255 L 118 255"/> + <path d="M 110 271 L 118 271"/> + </g> + <g font-size="10" fill="#9ba3ad" font-style="italic"> + <text x="210" y="226">— curated articles</text> + <text x="210" y="242">— reusable procedures</text> + <text x="210" y="258">— jira / github / …</text> + <text x="210" y="274">— toolchain catalog</text> + </g> + </g> + + <!-- NOTES zone --> + <g> + <circle cx="92" cy="325" r="14" fill="#d4a017"/> + <text x="92" y="330" text-anchor="middle" font-size="12" font-weight="700" fill="white">N</text> + <text x="115" y="323" font-size="17" font-weight="600" fill="#1c2733">notes</text> + <text x="115" y="341" font-size="11" fill="#7e8794" letter-spacing="0.3">team-shared</text> + <g transform="translate(404, 313)" stroke="#a07807" fill="#fcefb6" stroke-width="1.2"> + <path d="M0,4 L8,4 L11,1 L28,1 L28,18 L0,18 z"/> + <line x1="4" y1="9" x2="24" y2="9" stroke="#a07807" stroke-width="0.8"/> + <line x1="4" y1="13" x2="24" y2="13" stroke="#a07807" stroke-width="0.8"/> + </g> + <g font-size="13" fill="#1c2733"> + <text x="125" y="368">inbox</text> + <text x="125" y="384">focus</text> + <text x="125" y="400">memory</text> + </g> + <g stroke="#a07807" stroke-width="1.5" fill="none"> + <path d="M 110 365 L 118 365"/> + <path d="M 110 381 L 118 381"/> + <path d="M 110 397 L 118 397"/> + </g> + <g font-size="10" fill="#9ba3ad" font-style="italic"> + <text x="210" y="368">— workspace scratch</text> + <text x="210" y="384">— task trees</text> + <text x="210" y="400">— shared team memory</text> + </g> + </g> + + <!-- PERSONAL zone --> + <g> + <circle cx="92" cy="445" r="14" fill="#9333ea"/> + <text x="92" y="450" text-anchor="middle" font-size="12" font-weight="700" fill="white">P</text> + <text x="115" y="443" font-size="17" font-weight="600" fill="#1c2733">personal</text> + <text x="115" y="461" font-size="11" fill="#7e8794" letter-spacing="0.3">yours · per-user</text> + <g transform="translate(404, 433)" stroke="#9333ea" fill="#f3e3fb" stroke-width="1.2"> + <path d="M0,4 L8,4 L11,1 L28,1 L28,18 L0,18 z"/> + </g> + <g transform="translate(413, 440)"> + <rect x="0" y="4" width="10" height="8" rx="1.2" fill="#9333ea"/> + <path d="M2,4 A 3,3 0 0 1 8,4" fill="none" stroke="#9333ea" stroke-width="1.6"/> + </g> + <g font-size="13" fill="#1c2733"> + <text x="125" y="488">memory</text> + <text x="125" y="504">config</text> + <text x="125" y="520">vaults</text> + </g> + <g stroke="#9333ea" stroke-width="1.5" fill="none"> + <path d="M 110 485 L 118 485"/> + <path d="M 110 501 L 118 501"/> + <path d="M 110 517 L 118 517"/> + </g> + <g font-size="10" fill="#9ba3ad" font-style="italic"> + <text x="210" y="488">— your private memory</text> + <text x="210" y="504">— providers · mounts</text> + <text x="210" y="520">— encrypted · one selected per loop</text> + </g> + <g transform="translate(189, 512)"> + <rect x="0" y="3" width="6" height="5" rx="1" fill="#9333ea"/> + <path d="M 1.2 3 A 1.8 1.8 0 0 1 4.8 3" fill="none" stroke="#9333ea" stroke-width="1.1"/> + </g> + </g> + + <!-- ── DISTILLATION curves on LEFT edge — both labeled "distill" ── --> + <g fill="none" stroke="#d97706" stroke-width="1.5" stroke-dasharray="3 4"> + <path d="M 60 460 C 18 440, 18 365, 60 348" marker-end="url(#arr-dist)"/> + <path d="M 60 325 C 18 305, 18 230, 60 213" marker-end="url(#arr-dist)"/> + </g> + <text x="34" y="408" text-anchor="middle" font-size="10" font-style="italic" fill="#d97706">distill</text> + <text x="34" y="272" text-anchor="middle" font-size="10" font-style="italic" fill="#d97706">distill</text> + + <!-- ══════════════════════════════════════════════════════════════ + MIDDLE CARD — LOOP (x=500..980, y=120..550) + Title simplified: "LOOP" + "EPHEMERAL". + Sandbox label drops "(bwrap)". + ══════════════════════════════════════════════════════════════ --> + <g filter="url(#soft)"> + <rect x="500" y="120" width="480" height="430" rx="14" fill="#ffffff" stroke="#d8dde4" stroke-width="1"/> + </g> + <rect x="500" y="120" width="480" height="40" rx="14" fill="#fef3c7"/> + <rect x="500" y="146" width="480" height="14" fill="#fef3c7"/> + <text x="520" y="146" font-size="13" font-weight="700" fill="#8a6418" letter-spacing="2">LOOP</text> + <text x="960" y="146" font-size="11" text-anchor="end" fill="#a47e2c" letter-spacing="1">EPHEMERAL · REPRODUCIBLE</text> + + <rect x="525" y="178" width="430" height="350" rx="10" fill="#fffbef" stroke="#8a6418" stroke-width="1.5" stroke-dasharray="6 4"/> + <text x="740" y="200" text-anchor="middle" font-size="11" fill="#8a6418" letter-spacing="3" font-weight="600">SANDBOX</text> + + <!-- context band — 4 separate pills, color-coded to outside cards --> + <text x="540" y="226" font-size="10.5" fill="#8a6418" font-style="italic">bound context</text> + <!-- 4 pills (K/N/P/C) — wider than 5-pill layout, evenly spaced --> + <g> + <rect x="545" y="234" width="90" height="32" rx="6" fill="#3b5bdb"/> + <text x="590" y="255" text-anchor="middle" font-size="13" font-weight="700" fill="white">K</text> + <rect x="645" y="234" width="90" height="32" rx="6" fill="#d4a017"/> + <text x="690" y="255" text-anchor="middle" font-size="13" font-weight="700" fill="white">N</text> + <rect x="745" y="234" width="90" height="32" rx="6" fill="#9333ea"/> + <text x="790" y="255" text-anchor="middle" font-size="13" font-weight="700" fill="white">P</text> + <rect x="845" y="234" width="90" height="32" rx="6" fill="#16a34a"/> + <text x="890" y="255" text-anchor="middle" font-size="13" font-weight="700" fill="white">C</text> + </g> + <g font-size="9" fill="#a47e2c" text-anchor="middle" letter-spacing="0.5"> + <text x="590" y="278">ro</text> + <text x="690" y="278">rw</text> + <text x="790" y="278">rw</text> + <text x="890" y="278">ro</text> + </g> + <text x="740" y="296" text-anchor="middle" font-size="10" fill="#8a6418" font-style="italic">mounted from outside · colors match source cards</text> + + <!-- agent --> + <line x1="540" y1="300" x2="940" y2="300" stroke="#e1d4b3" stroke-width="0.8" stroke-dasharray="2 3"/> + <g> + <circle cx="740" cy="350" r="34" fill="#fbbf24" stroke="#8a6418" stroke-width="1.8"/> + <text x="740" y="357" text-anchor="middle" font-size="16" font-weight="700" fill="#3d2c08">agent</text> + </g> + + <!-- /loopat/loop/<id>/ organ row — simplified copy --> + <line x1="540" y1="395" x2="940" y2="395" stroke="#e1d4b3" stroke-width="0.8" stroke-dasharray="2 3"/> + <text x="540" y="411" font-size="10.5" fill="#8a6418" font-style="italic">selected at spawn</text> + <g> + <rect x="545" y="419" width="124" height="80" rx="8" fill="#eef2ff" stroke="#3b5bdb" stroke-width="1.2"/> + <text x="607" y="446" text-anchor="middle" font-size="14" font-weight="700" fill="#1e3a8a">workdir</text> + <text x="607" y="467" text-anchor="middle" font-size="11" fill="#3b5bdb">git worktree</text> + <text x="607" y="484" text-anchor="middle" font-size="11" fill="#3b5bdb">rw</text> + </g> + <g> + <rect x="677" y="419" width="124" height="80" rx="8" fill="#fce7f3" stroke="#be185d" stroke-width="1.2"/> + <text x="739" y="446" text-anchor="middle" font-size="14" font-weight="700" fill="#831843">vault</text> + <text x="739" y="467" text-anchor="middle" font-size="11" fill="#be185d">encrypted</text> + <text x="739" y="484" text-anchor="middle" font-size="11" fill="#be185d">one of many</text> + </g> + <g> + <rect x="809" y="419" width="124" height="80" rx="8" fill="#ccfbf1" stroke="#0d9488" stroke-width="1.2"/> + <text x="871" y="446" text-anchor="middle" font-size="14" font-weight="700" fill="#134e4a">toolchain</text> + <text x="871" y="467" text-anchor="middle" font-size="11" fill="#0d9488">cli + mcp</text> + <text x="871" y="484" text-anchor="middle" font-size="11" fill="#0d9488">managed catalog</text> + </g> + + <text x="740" y="518" text-anchor="middle" font-size="10.5" fill="#8a6418" font-style="italic">one mount namespace · isolated · disposable</text> + + <!-- ══════════════════════════════════════════════════════════════ + RIGHT CARD — CHAT (no spawn-loop label; one simple arrow) + ══════════════════════════════════════════════════════════════ --> + <g filter="url(#soft)"> + <rect x="1000" y="120" width="220" height="430" rx="14" fill="#ffffff" stroke="#d8dde4" stroke-width="1"/> + </g> + <rect x="1000" y="120" width="220" height="40" rx="14" fill="#e3f2eb"/> + <rect x="1000" y="146" width="220" height="14" fill="#e3f2eb"/> + <text x="1020" y="146" font-size="13" font-weight="700" fill="#1e6b3b" letter-spacing="2">CHAT</text> + <text x="1200" y="146" font-size="11" text-anchor="end" fill="#3d8458" letter-spacing="1">LIVE · STREAMING</text> + + <circle cx="1030" cy="185" r="14" fill="#16a34a"/> + <text x="1030" y="190" text-anchor="middle" font-size="12" font-weight="700" fill="white">C</text> + <text x="1055" y="183" font-size="17" font-weight="600" fill="#1c2733">chat</text> + <text x="1055" y="201" font-size="11" fill="#7e8794" letter-spacing="0.3">agents · live threads</text> + + <!-- 3 thread bubbles. Each bubble shows alternating speakers via + line color: green = a person, amber = an agent. The amber matches + the loop's agent circle (#fbbf24) — same agent color identity. --> + <g> + <!-- bubble 1: human asks, agent replies, human follow-up --> + <g> + <rect x="1018" y="222" width="186" height="42" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/> + <line x1="1030" y1="236" x2="1190" y2="236" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="244" x2="1170" y2="244" stroke="#d4a017" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="252" x2="1145" y2="252" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/> + </g> + <!-- bubble 2: agent opens, human answers, agent replies --> + <g> + <rect x="1018" y="276" width="186" height="42" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/> + <line x1="1030" y1="290" x2="1170" y2="290" stroke="#d4a017" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="298" x2="1180" y2="298" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="306" x2="1140" y2="306" stroke="#d4a017" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/> + </g> + <!-- bubble 3 --> + <g> + <rect x="1018" y="330" width="186" height="42" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/> + <line x1="1030" y1="344" x2="1150" y2="344" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="352" x2="1190" y2="352" stroke="#d4a017" stroke-width="2.5" stroke-linecap="round"/> + <line x1="1030" y1="360" x2="1130" y2="360" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/> + </g> + </g> + + <!-- legend: tiny key tying line colors to speakers (kept; explains the + bubble visuals). The subtitle "agents · live threads" already names + the dual-party nature, so no separate footer needed. --> + <g font-size="10" font-family="inherit"> + <line x1="1030" y1="394" x2="1042" y2="394" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round"/> + <text x="1046" y="397" fill="#1e6b3b">human</text> + <line x1="1100" y1="394" x2="1112" y2="394" stroke="#d4a017" stroke-width="2.5" stroke-linecap="round"/> + <text x="1116" y="397" fill="#a47e2c">agent</text> + </g> + + <!-- ══════════════════════════════════════════════════════════════ + READ ARROWS — 4 simple colored lines pointing AT the loop card + (general direction, not internal targeting). + ══════════════════════════════════════════════════════════════ --> + <!-- K knowledge → loop --> + <path d="M 460 185 C 480 185, 495 220, 510 245" + stroke="#3b5bdb" stroke-width="1.4" fill="none" marker-end="url(#arr-K)"/> + <!-- N notes → loop --> + <path d="M 460 325 C 482 325, 498 345, 510 350" + stroke="#d4a017" stroke-width="1.4" fill="none" marker-end="url(#arr-N)"/> + <!-- P personal → loop --> + <path d="M 460 445 C 484 445, 498 455, 510 455" + stroke="#9333ea" stroke-width="1.4" fill="none" marker-end="url(#arr-P)"/> + <!-- C chat → loop --> + <path d="M 1000 350 C 990 350, 982 350, 970 350" + stroke="#16a34a" stroke-width="1.4" fill="none" marker-end="url(#arr-C)"/> + + <!-- ══════════════════════════════════════════════════════════════ + WRITE-BACK arc (slim) + ══════════════════════════════════════════════════════════════ --> + <!-- Simpler arc now that repos is gone — gentle dip from loop bottom to + personal zone right edge. --> + <path d="M 600 545 C 545 605, 485 605, 460 490" + stroke="#d97706" stroke-width="1.5" fill="none" stroke-dasharray="2 5" + marker-end="url(#arr-write)"/> + <text x="525" y="625" text-anchor="middle" font-size="13" font-weight="600" fill="#d97706" letter-spacing="2">memory</text> + +</svg> diff --git a/docs/sandbox.md b/docs/sandbox.md new file mode 100644 index 00000000..cb7b30a4 --- /dev/null +++ b/docs/sandbox.md @@ -0,0 +1,382 @@ +--- +title: loopat sandbox +tags: [loopat, architecture, security] +status: draft (review before promoting to knowledge/) +--- + +# loopat sandbox + +每个 loop 跑在一个独立 sandbox 里。本文讲清楚:**为什么、怎么实现、Claude 看到什么、可移植性如何**。 + +## 设计原则 + +1. **loop 目录就是 sandbox 的完整描述** —— 沙箱权限不藏在配置文件里,而是从 `loops/<id>/` 目录的物理结构 + symlink 链派生。`ls -laR loops/<id>` 就能看见全部访问范围。 +2. **filesystem-first,不引入 DB** —— sandbox 配置、memory、context、状态全是文件,user / Claude / git 都能操作。 +3. **机器无关、可移植** —— 把 `$LOOPAT_HOME` 整个 rsync 到别的机器,sandbox 视图不变。 +4. **单层** —— 用一个 bwrap 包 Claude CLI driver;它 spawn 的 bash 子进程**继承同一 mount/pid namespace**,不需要嵌套 sandbox。 + +## 完整架构 + +### 进程拓扑 + +``` +host +└── loopat server (Bun, port 10001) ← 1 个,全员共用 + │ ├── Hono HTTP/WS router + │ │ /api/loops/... REST + │ │ /ws/loop/:id chat ws + │ │ /ws/loop/:id/term terminal ws + │ ├── Map<loopId, LoopSession> ← chat 状态 + │ └── Map<loopId, Term> ← PTY 状态 + │ + ├── LoopSession[loop-A] ← 1 / loop + │ ├── ws subscribers: [browser1, browser2, ...] + │ ├── Claude CLI subprocess (in bwrap-A1) ← 1 / loop + │ │ └── bash subprocesses (inherit bwrap-A1, 跑 Claude 的 Bash tool) + │ └── (memory recall + auto-commit + history persistence 都在 server 端) + │ + ├── LoopSession[loop-B] ← 同结构 + │ + └── Term[loop-A] ← 0 或 1 / loop(按需起) + └── PTY bash subprocess (in bwrap-A2) ← 跟 CLI 是不同的 bwrap,同布局 +``` + +**沙箱里的**:claude CLI driver + 它 spawn 的 bash + PTY 的交互式 bash。 +**沙箱外的**:loopat server(含 ws server、REST handlers、文件系统 helpers、SDK 驱动调用方)。 + +PTY 和 Claude SDK 走**同一个** `buildBwrapArgs(loopId)`,所以两者**视野完全一致**:terminal 里 `cd /loop` `ls /context` 跟 Claude 的 `Read /personal/memory/...` 看到的是同一个虚拟世界。 + +### 共用 / 独占矩阵 + +| 资源 | 实例数 | scope | +|---|---|---| +| host machine | 1 | 全员 | +| loopat server 进程(Bun) | 1 | 全员 | +| port 10001(HTTP+WS) | 1 | 全员 | +| workspace `loopat` | 1(MVP)| workspace member | +| LoopSession 内存对象 | N(每 loop 一个)| 同 loop 所有 ws subscriber | +| Claude CLI 进程 | 0 或 1 / loop | 同 loop 全员 | +| outer bwrap (Claude) | 跟 CLI 同生命周期 | 同 loop 全员 | +| messages.jsonl | 1 / loop | 同 loop 全员 | +| .claude/ session JSONL | 1 / loop | 同 loop 全员 | +| PTY bash 进程 | 0 或 1 / loop | 同 loop 所有 term ws 订阅者(**多人共用同一 bash session**) | +| outer bwrap (PTY) | 跟 PTY 同生命周期 | PTY 订阅者 | +| ws connection | N(一 tab 一个)| 独占 per browser tab | +| /context/knowledge | **per-loop worktree**(分支 `loop/<id>`),主仓 1 / workspace | 隔离写、显式 publish 到 trunk | +| /context/notes | **per-loop worktree**(分支 `loop/<id>`),主仓 1 / workspace | 同上 | +| /context/notes/memory/ | 通过 notes worktree | team memory,AI 自己 publish 到 trunk 后跨 loop 可见 | +| /personal/`<user>`/ | 1 per (workspace, user) | 一个 user 自己,跨 loop 直接共享 | +| /personal/`<user>`/memory/ | 1 per user | personal memory,SDK auto-recall | +| /personal/`<user>`/.loopat/vaults/`<active>` | 1 per (user, vault),按 `envs/` + `mounts/home/` 约定自动派发到 sandbox env / $HOME | per-loop 选 active;其他 vault 在 sandbox 里物理可见但 doctrine 不引导访问 | +| `<loopDir>/workdir/` | 1 / loop | 该 loop | +| `<loopDir>/home-upper/` | 1 / loop | 该 loop 的 $HOME container layer,persistent | + +### 4 层隔离(loop A 与 loop B 之间) + +| 层 | 防什么 | 谁强制 | +|---|---|---| +| 1. URL 路由 + 闭包 | 不同 loop 的请求进同一 server,闭包独立绑 loopId | 应用代码(Hono router) | +| 2. Map 按 loopId 分 | broadcast 只到本 loop 订阅者 | 应用代码(term.ts / session.ts) | +| 3. 每 loop 独立子进程 | Linux process 级隔离 | OS(process model) | +| 4. bwrap mount namespace | 文件系统视图独立 | Linux kernel | + +第 1、2 层是应用层逻辑;第 3、4 层是 OS / kernel 给的**硬保证**。即使应用层有 bug,kernel 仍然保证 bash-A 看不到 loop-B 的文件。 + +### 完整数据流:ws → PTY + +``` +[Browser Tab] + │ ws://host:10001/ws/loop/<id>/term + ▼ +[Hono router @ 10001] + │ matches /ws/loop/:id/term → 提取 id + ▼ +[ws handler] ← 闭包捕获 id;这个连接的所有事件都带这个 id + │ + ├─ onOpen(ws) → attachTerm(id, ws) + ├─ onMessage(e) → writeTerm(id, e.data) + └─ onClose() → detachTerm(id, ws) + +[term.ts] + │ Map<loopId, Term> + │ "loop-A" → { proc: ptyA, subs: Set(wsA1, wsA2) } + │ "loop-B" → { proc: ptyB, subs: Set(wsB1) } + ▼ getOrSpawn(id) + +[bun-pty.spawn("unshare", [-Umr, --, bash -c "mount overlay && exec bwrap ...", ...])] + │ ← 外层 unshare 提供 user+mount NS,在里面 mount $HOME overlayfs + ▼ +[Linux process: unshare → bash → exec bwrap] + │ ← bwrap 继承 mount NS,独立 mount namespace,只挂这 loop 的 paths + ▼ +[Linux process: bash -i] ← inherit bwrap namespace +``` + +**输出方向**:bash stdout → ptyA → `pty.onData` callback (closure 捕获 `t = terms.get("A")`) → 遍历 `t.subscribers` 全 broadcast。**只送给 loop A 的订阅者**。 + +**输入方向**:browser keystroke → ws → handler (closure 捕获 id="A") → `writeTerm("A", data)` → `terms.get("A").proc.write(data)` → ptyA stdin。**永远不会写到 ptyB**。 + +### 同 loop 多 ws subscriber 共享行为 + +Loop A 有两个 tab 都开了 terminal,wsA1 + wsA2。任何一方按键 → 进同一个 ptyA stdin → 同一个 bash 看到 → 同一份 stdout 广播给两人。结果:**同一行光标,两人共用一个 bash**。 + +含义: +- 同一 user 多 tab → 自己输入实时同步 ✓ +- 多 user 同一 loop → 像两人共用键盘,**会乱**(设计接受,"共享" model) + +### 故意共享、不隔离的部分 + +- **`/context/{knowledge,notes}` 主仓**(git repo)—— **不直接共享物理目录**。每 loop 拿自己的 git worktree(分支 `loop/<id>`),AI 在自己 worktree 里写,显式 publish(`git push . HEAD:<trunk>`)到主分支后其他 loop 才看得到。主仓配 `receive.denyCurrentBranch=ignore`,并发 push 由 git ref 原子更新串行化、AI 重试。详见"每 loop 一个 context worktree" 项目 memory +- **`/personal/<user>/`** —— 同一 user 的多 loop 共享物理目录,跨 loop 持久化 memory、vault 等 +- **host 网络** —— 多 loop 共用,能互相 bind 端口 + +这些是**设计共享**:team knowledge 协作通过 publish/pull,user 自己的 memory 跨 loop 累积,网络复用。要硬隔离网络需要每 sandbox 一个 net namespace + 反向代理,工程量大。 + +### 多人协作的当前 gap(v6 未解决) + +- `/personal` mount 当前指 driver 的 personal。如果 user A 是 driver、user B attach 同 loop:B 在 sandbox 里看到的是 A 的 secrets ❌ +- 解法(v6.x 之后):driver-transfer 时 unlink + relink personal mount + 重启 sandbox;或 read-only attach 模式不让 Claude 跑 + + + +## 虚拟路径布局 + +Claude 在 sandbox 里看到的(注意 v6 之后所有 loopat-managed 路径统一前缀 `/loopat/`): + +| 路径 | 真路径(host) | 模式 | 说明 | +|---|---|---|---| +| `/loopat/loop/<id>/workdir/` | `LOOPAT_HOME/loops/<id>/workdir/` | rw | cwd;为代码 loop 是 git worktree | +| `/loopat/loop/<id>/.claude/` | `LOOPAT_HOME/loops/<id>/.claude/` | rw | SDK session JSONL + settings.json | +| `/loopat/context/knowledge/` | `LOOPAT_HOME/loops/<id>/context/knowledge/`(**per-loop worktree**) | ro \| rw(按 `meta.config.knowledge_rw`) | AI 在自己分支 `loop/<id>` 上写,publish 到 trunk 后跨 loop 可见 | +| `/loopat/context/notes/` | `LOOPAT_HOME/loops/<id>/context/notes/`(**per-loop worktree**) | rw | 同上 | +| `/loopat/context/personal/` | `LOOPAT_HOME/personal/<user>/`(symlink) | rw | 用户私有;含 `memory/`、`.loopat/vaults/<name>/` 等。Vault 不以目录形式暴露给 AI,靠 `envs/` + `mounts/home/` 自动派发 | +| `/loopat/context/repos/<name>/` | `LOOPAT_HOME/context/repos/<name>/` | rw | workspace 全员共用的 git repo 集合 | +| `$HOME` (`/home/$USER`) | **overlayfs**:lower=`LOOPAT_HOME/sandbox-home-skel/`,upper=`LOOPAT_HOME/loops/<id>/home-upper/`,merged=`LOOPAT_HOME/loops/<id>/home-merged/` → bind 到 `$HOME` | rw | **docker container-layer 语义**:跨 sandbox 重启持久;pip/npm 安装、shell history 都活下来 | + +系统路径以 `--ro-bind-try` 逐项暴露:`/usr /etc /lib /lib64 /bin /sbin /opt /var /run`(read-only)。`/tmp` 共享 host(rw,让 socat unix socket / mktemp / IPC 工作)。`/proc /dev` 由 bwrap 标准 flag 提供。`--unshare-pid` 给独立 PID namespace。 + +**$HOME overlay 机制**:bwrap 0.9.0(Ubuntu noble)没编 overlay 支持,所以每次 spawn 用 `unshare -Umr -- bash -c "mount -t overlay overlay -o ... && exec bwrap ..."` 包一层 user+mount NS,在 NS 里 mount overlayfs,再 exec bwrap。bwrap 继承 mount NS,把 merged dir bind 到沙箱 `$HOME`。沙箱退出 → NS 死 → overlay 自动卸载;upper dir 在 host 上持久(kernel ≥ 5.11 支持 unprivileged overlayfs)。 + +**关键**:用 per-component `--ro-bind-try` 而**不是** `--ro-bind / /`。后者会让 / 整个 RO,bwrap 之后 mkdir `/loopat/...` 这种新路径会失败("Read-only file system")。前者让 sandbox root 是 fresh tmpfs,bwrap 自由创建虚拟挂载点。 + +## 三层 mount 权责 + +> 早期版本有个 personal-deps walker,扫 `personal/<user>/` 的 symlink 把 +> target 真路径 bind 进 sandbox。在多 user 场景下 member 可以 symlink 到 +> operator 任意 host 路径越权,已删除。现在 mount 入口收敛到三层。 + +| 层 | 来源 | 谁写 | 决定什么 | +|---|---|---|---| +| **operator** | `~/.dashscope/config.json` `mounts` | host shell 用户 | 任意 host 路径 mount(跨 user 共享缓存如 `/etc/pki/ca-trust`) | +| **admin** | `knowledge/.loopat/.claude/settings.json` + profiles | 团队管理员 | 无 mount 字段 | +| **member** | `vaults/<active>/mounts/home/<rel>/...` 目录布局 | 团队个人 | 自动派生:每个顶层条目 `--bind` 到 `$HOME/<rel>/...`。**无 config 字段**,文件系统布局就是 spec | + +权责跟文件系统所有权自然对齐。 + +**operator 例**(host cache 跨所有 loop 共享): + +```jsonc +// ~/.dashscope/config.json +{ + "mounts": [ + { "src": "$HOME/.cache", "dst": "$HOME/.cache", "rw": true } + ] +} +``` + +**member 例**(自己的 ssh + gh + dotfiles)—— 只是放文件,没有配置: + +``` +personal/<user>/.loopat/vaults/default/mounts/home/ +├── .ssh/ → sandbox 内 $HOME/.ssh/ +├── .config/gh/ → sandbox 内 $HOME/.config/gh/ +├── .gitconfig → sandbox 内 $HOME/.gitconfig +└── .secrets/<service>/ → sandbox 内 $HOME/.secrets/<service>/ ← ad-hoc 凭据池 +``` + +Sandbox 默认看不到 host 任意路径。opt-in 由 operator 或 member 显式(前者写 JSON,后者放文件)。**目录所有权 = ACL**。 + +## Vault(凭据隔离) + +Loop = `sandbox × vault` 的笛卡尔积: + +- **Sandbox**(admin 拥有)= "用什么工具"。Toolchain + MCP,团队共享。 +- **Vault**(member 拥有)= "以什么身份"。一组命名好的凭据,per-user 加密落盘。 + +每个 loop 在 `meta.config.vault` 上选一个 vault(默认 `"default"`)。Vault 不以目录形式暴露给 sandbox——两个**约定目录**驱动自动派发: + +| 目录 | spawn 时做什么 | +|---|---| +| `vaults/<v>/envs/<NAME>` | 文件内容注入成环境变量 `$NAME`(同时驱动 provider `apiKey` 里的 `${VAR}` 替换) | +| `vaults/<v>/mounts/home/<rel>/...` | 每个顶层条目 `--bind` 到 sandbox 的 `$HOME/<rel>/...` | + +### 目录结构例 + +``` +personal/<user>/.loopat/vaults/ +├── default/ +│ ├── envs/ +│ │ ├── ANTHROPIC_API_KEY ← provider apiKey: "${ANTHROPIC_API_KEY}" +│ │ └── MCP_GITHUB_TOKEN ← .mcp.json: "Authorization: Bearer ${MCP_GITHUB_TOKEN}" +│ └── mounts/home/ +│ ├── .ssh/ +│ └── .config/gh/ +├── dev/ +└── prod/ + └── envs/ + ├── ANTHROPIC_API_KEY ← prod 用不同 key + └── MCP_GITHUB_TOKEN → ../../default/envs/MCP_GITHUB_TOKEN ← symlink 共享 +``` + +- **Symlink 在 vault 内 / 跨 vault 都允许**,但 realpath 必须仍落在 `personal/<user>/` 内(不能逃出去指向 host 任意路径)。 + +### Sandbox 内的视图 + +整个 `personal/` 是 wholesale bind 进沙箱的,所以技术上 `/loopat/context/personal/.loopat/vaults/<name>/` 仍然物理可见。但 **doctrine 不引导 AI 去看 vault**——AI 只看到 `$HOME` 里的文件(来自 `mounts/home/`)和 env vars(来自 `envs/`),跟一台正常配好的开发机一样。 + +Vault 这个词在 AI 视角下消失:所有 secret 在它的消费位置(env 或 $HOME 路径)自然出现,不需要 AI 知道它们是从 vault 派生的。 + +### git-crypt 加密 + +Auto-init 生成的 `.gitattributes` 覆盖整个 vault 路径: + +``` +.loopat/vaults/** filter=git-crypt diff=git-crypt +``` + +新写进 vault 的任何文件(包括 `envs/*` 和 `mounts/home/*`)都会自动加密。 + +## 网络 + +**不 `--unshare-net`**,host 网络共享。Claude 调 `api.anthropic.com` / 百炼 / curl / git fetch / npm install / pip install 都直接走。 + +按域名过滤要做的话,host 上 iptables/nftables 处理(外层),或者 outer sandbox 加 squid/tinyproxy(v6 不做)。 + +## Memory(auto-recall + 虚拟路径) + +SDK 的 auto-memory recall 机制配置在每 loop 的 `<loopDir>/.claude/settings.json`: + +```json +{ + "autoMemoryEnabled": true, + "autoMemoryDirectory": "/loopat/context/personal/memory" +} +``` + +CLI 读这个 settings(通过 `settingSources: ["user"]`)→ 每 turn 自动扫 `/loopat/context/personal/memory/` → 相关 .md 注入 prompt。 + +因为 CLI 自己**也**跑在 sandbox 内,`/loopat/context/personal/memory/` 就是它眼里的真路径,**memory_recall 事件里 path 字段也是 `/loopat/context/personal/memory/foo.md`**(不会泄漏 `/home/...`)—— 这就是单层包 CLI 比包 Bash 严格更优的核心原因。 + +doctrine(CLAUDE.md)规定**两层 memory**: + +- **`/loopat/context/personal/memory/`** —— 私有,per-user,SDK 自动召回,频繁、低门槛 +- **`/loopat/context/notes/memory/`** —— 团队共享(在 notes git repo 里),**不**自动召回;doctrine 教 Claude 复杂 turn 开始时主动 `Read /loopat/context/notes/memory/MEMORY.md` 索引;判断 memory 对全团队有用时**自动 promote**(写一份到 team memory,不用问 user) + +注意:team memory 写完后 AI 需要走 publish workflow(`git add → commit → merge trunk → push . HEAD:<trunk>`)才能被其他 loop 看到,因为 notes 是 per-loop worktree。 + +## System prompt 多层 + +``` +┌────────────────────────────────┐ +│ L1: Claude Code preset │ SDK 内置 +├────────────────────────────────┤ +│ L2: 平台 doctrine │ bundled,server/templates/CLAUDE.md +│ - 沙箱边界、context 约定 │ 静态,跨 loop 复用,cache 友好 +│ - memory 模型、行为规则 │ 通过 systemPrompt.append 注入 +│ - publish workflow │ +├────────────────────────────────┤ +│ L2+: workspace 团队 supplement │ knowledge/.loopat/.claude/CLAUDE.md(可选) +│ │ bwrap ro-bind 到 CLAUDE_CONFIG_DIR/CLAUDE.md +│ │ CC 通过 settingSources:["user"] 自动加载 +├────────────────────────────────┤ +│ L2++: project tier │ <workdir>/CLAUDE.md(可选) +│ │ CC 通过 settingSources:["project"] 自动加载 +│ │ 例:distill loop 的 workdir 落一份 distill-doctrine +├────────────────────────────────┤ +│ L3: per-loop runtime block │ server 算的:title/id/driver/branch/repo + context worktree 信息 +│ │ 通过 systemPrompt.append 注入 +└────────────────────────────────┘ +``` + +L2 + L3 拼接后通过 `systemPrompt: { type:"preset", preset:"claude_code", append: <L2+L3> }` 注入。L2 用虚拟绝对路径(`/loopat/loop/<id>/`、`/loopat/context/...`),跨 loop 跨 user 跨机器**完全静态**,prompt cache 命中率最大化。 + +**Loop kinds**:未来扩展通过 `server/templates/loop-kinds/<kind>/CLAUDE.md`,由对应的 spawn 路径(如 `distillLoop`)拷到 workdir 当 L2++。第一个用上的是 distill。 + +## 可移植性测试 + +```sh +# a 机 +rsync -a ~/.loopat/ b:.loopat/ + +# b 机 +cd ~/workspace/loopat && bun install +bun run --hot src/index.ts +``` + +预期 b 机上: +- ✓ 所有 loop 列表保留 +- ✓ chat history 保留(messages.jsonl 跟 cwd / 机器无关) +- ✓ session continue 仍 work(CLAUDE_CONFIG_DIR JSONL 里有的是 cwd hash 而 cwd 是虚拟路径 `/loop/<id>`) +- ✓ Claude 之前写的 memory 内容引用 `/personal/memory/X` `/context/notes/Y` 等虚拟路径**仍然有效**,因为 b 机的 sandbox 重生成时 `/personal` 指向 b 机的真 personal 路径 +- ✗ `context/repos/loopat -> /home/simpx/workspace/1001/loopat` 这种 host 上的代码 repo 注册 symlink **不可移植**(host-specific),b 机要重新 ln -s 到自己的 repo 路径 + +## 安全性 vs UX + +| 威胁 | 防护 | +|---|---| +| Claude 想 `cat ~/.aws/credentials` | `/home/$USER` 是 overlayfs(lower 是空 skeleton)+ 没 personal symlink → "No such file" | +| Claude 想 `rm -rf /` | RO bind /usr /etc 等 → 写不动;rw 区域只有 workdir / context/notes worktree / personal / $HOME overlay | +| Claude 想 ssh 到外网机器 | personal/.ssh 没 ln 进来 → ssh client 找不到 key → 无法连。要 opt-in:用户 `ln -s ~/.ssh personal/.loopat/vaults/default/.ssh` | +| Claude 写 memory 时引用真路径 | 不会发生 —— CLI 在 sandbox 内,看到的就是虚拟路径 | +| 多 loop 间数据隔离 | 每 loop 自己的 `/loopat/loop/<id>/` 独立 bind;notes/knowledge 是 per-loop git worktree(分支 `loop/<id>`),跨 loop 看不见对方的未 publish 写 | +| /tmp 写恶意东西 | 共享 host /tmp,但 tmp 本来就是 ephemeral,无害 | +| AI 一个 loop 改了 home 的 .bashrc 影响其他 loop | $HOME upper 是 per-loop,不串 | + +UX 上**不"完美 docker"的地方**: +- `$HOME=/home/simpx`(不是 `/loopat/context/personal`)—— 因为 ssh 等工具固定读 `$HOME/.ssh` +- `/usr/local` 之类系统路径仍可见(RO)—— sandbox 里的 Claude 不应该感到"在外星系",常用工具应该都在 +- `LOOPAT_INSTALL_DIR`(如 `/home/simpx/workspace/loopat`)有泄漏 —— 因为 claude binary 必须能跑,只能 bind same-to-same +- 只有 `$HOME` 是 overlayfs container layer,`/var`、`/opt` 等不持久(写在沙箱 root tmpfs,下次起新沙箱就丢)—— 故意只覆盖最大痛点,full root overlay 待将来需要时再扩 + +这些都是**实用主义妥协**。彻底纯化(每层路径都虚拟、$HOME 也虚拟)需要更多 bind 重写 + 可能破坏工具,得不偿失。 + +## 跟其他方案对比(为什么 bwrap) + +调研结论(详见 v6 实现讨论): + +- **Docker / Podman**:spawn 慢(1-3s),需要 image 管理,不必要 +- **gVisor**:拦 syscall,bwrap 嵌套(如果以后想做)会废 +- **Firejail**:要 SUID,desktop-focused,方向不对 +- **systemd-nspawn**:要 root +- **E2B / Modal**:云端 VM,不挂 host 真目录,跟 loopat filesystem-first 哲学冲突 +- **Anthropic 自家 sandbox-runtime**:是给"Claude 调用的 Bash 工具"用的,不是包 CLI 自己;v6 之前我们用过,v6 改用直接 bwrap 后弃用 + +bwrap 是唯一同时满足"轻、快、原生 namespace、无 daemon、Anthropic 已经在用"的方案。代码量 ~80 行 argv 数组生成 + spawn。 + +## 一次性 host 配置(部署清单) + +```sh +sudo apt install -y bubblewrap util-linux socat ripgrep # bubblewrap=bwrap;util-linux 带 unshare;socat 暂留;ripgrep 给 Claude 用 +sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 +echo "kernel.apparmor_restrict_unprivileged_userns=0" | sudo tee /etc/sysctl.d/60-apparmor-namespace.conf +``` + +Ubuntu 24.04+ 必须关 apparmor 那一项,不然 unprivileged user namespace 起不来,bwrap 跑不动。 + +**Kernel 要求**:≥ 5.11,支持 unprivileged overlayfs(用于 $HOME container layer)。Ubuntu noble 24.04+ 默认满足。 + +**bwrap 版本**:≥ 0.6 就够(用 stock 0.9.0 即可)。bwrap 本身不需要 overlay 编译选项——overlay 在外层 `unshare` 里用 kernel native overlayfs mount,bwrap 只负责 bind 其他东西。 + +## 文件位置(实现) + +- `server/src/bwrap.ts` —— `buildBwrapArgs(loopId, createdBy, extraSetenv, sandboxName, vaultName, knowledgeRw)` 构造 bwrap argv;`prepareSandboxOverlay(loopId)` 异步 mkdir overlay 目录;`buildSandboxSpawnArgv(...)` 同步拼出 `unshare -Umr -- bash -c "mount && exec bwrap ..."` 的最终 argv +- `server/src/sandboxes.ts` —— sandbox catalog (list/read/write/lock/commit) +- `server/src/session.ts` —— SDK options 里 `spawnClaudeCodeProcess` callback 包 CLI;spawn 的 binary 是 `unshare` 不是 `bwrap`(因为外层包了 unshare);`sandbox: { enabled: false }` 关 SDK 内置 sandbox-runtime +- `server/src/term.ts` —— PTY 同样走 `unshare` + `buildBwrapArgs`,bash 进沙箱 +- `server/src/system-prompt.ts` —— L2 doctrine(bundled)+ L3 runtime block 拼接 +- `server/src/loops.ts` —— `ensureWorkspaceDirs`(设置 notes/knowledge repo 的 `receive.denyCurrentBranch=ignore`)、`ensureContextMounts`(per-loop git worktree of notes/knowledge)、`distillLoop` +- `server/templates/CLAUDE.md` —— L2 平台 doctrine +- `server/templates/loop-kinds/<kind>/CLAUDE.md` —— L2++ project-tier doctrine(distill 用) +- `knowledge/.loopat/.claude/CLAUDE.md` —— L2+ workspace 团队 supplement(可选) +- `<loopDir>/.claude/settings.json` —— `autoMemoryDirectory: /loopat/context/personal/memory` +- `LOOPAT_HOME/sandbox-home-skel/` —— $HOME overlay 的 lower 层(默认空,可放 dotfiles) diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 00000000..c3102580 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/docs/setup-admin.md b/docs/setup-admin.md new file mode 100644 index 00000000..24f9a0fc --- /dev/null +++ b/docs/setup-admin.md @@ -0,0 +1,258 @@ +# Admin setup — provision a workspace + +> Audience: you've just installed loopat and need to make it usable for +> a team. This guide is the **post-install** checklist — workspace +> config, knowledge repo, notes repo, sandboxes, MCP. Solo users can +> skim it; almost everything is optional and loopat will run with +> empty defaults. + +If loopat isn't installed yet, do that first — see +[install.md](install.md). This guide picks up after `bun run dev` +prints its bootstrap banner. + +--- + +## TL;DR + +| Step | What | File / location | Optional? | +|---|---|---|---| +| 1 | Workspace config | `~/.loopat/config.json` | required if you want shared repos | +| 2 | Knowledge repo | `context/knowledge/` (cloned from remote) | optional, recommended | +| 3 | Notes repo | `context/notes/` (cloned from remote) | optional, recommended | +| 4 | Team CLAUDE.md / skills / MCP | `knowledge/.loopat/.claude/` | optional | +| 5 | Profiles (roles / modes) | `knowledge/.loopat/profiles/<name>/.claude/` | optional | +| 6 | Operator mounts | `config.json` `mounts[]` | optional | +| 7 | Activate users | UI → `/admin` | once members register | + +You are the **first** user that registers — first registration auto- +promotes to `role: admin`, status `active`. Every subsequent user +lands as `member`, status `pending` and waits for you to activate them. + +--- + +## 1. Workspace `config.json` + +`~/.loopat/config.json` is the workspace-shared root. Templates with +empty fields after first run. Fill it in: + +```jsonc +{ + "knowledge": { "git": "git@github.com:your-team/loopat-knowledge.git" }, + "notes": { "git": "git@github.com:your-team/loopat-notes.git" }, + "repos": [ + { "name": "app", "git": "git@github.com:your-team/app.git" }, + { "name": "infra", "git": "git@github.com:your-team/infra.git" } + ], + "mounts": [ + { "src": "$HOME/.cache", "dst": "$HOME/.cache", "rw": true } + ] +} +``` + +| Field | Notes | +|---|---| +| `knowledge.git` | clone URL for team-shared knowledge repo. Empty → local-only dir. | +| `notes.git` | clone URL for team-shared notes repo. Empty → local-only dir with `git init`. | +| `repos[]` | each entry → cloned to `context/repos/<name>/`. Loops spawn against these. | +| `mounts[]` | **operator-level** mounts; `src` is any host path. Shared by every loop on this workspace. See §6. | + +Provider config (`apiKey`, `model`, `baseUrl`) is **not** here — that +lives per-user under `personal/<user>/.loopat/config.json`. Admins +don't pre-fill API keys for the team. + +Restart `bun run dev` after editing. The banner clones any new +remotes and reports `✓ knowledge / ✓ notes / ✓ repos`. + +--- + +## 2. Knowledge repo + +`knowledge/` is **read-only** inside sandboxes. Anything you commit +here is visible to every loop on the workspace and forms the team's +durable context layer. + +Layout once provisioned: + +``` +context/knowledge/ +├── .loopat/ ← reserved namespace (loopat-aware) +│ ├── .claude/ workspace tier (always on) +│ │ ├── CLAUDE.md (optional) team prompt supplement +│ │ ├── settings.json (optional) enabledPlugins, mcpServers, hooks +│ │ ├── skills/ (optional) team skills +│ │ ├── agents/ (optional) team subagents +│ │ └── mise.toml (optional) team toolchain pins +│ └── profiles/ profile tier (opt-in per loop) +│ └── <name>/.claude/ same shape as workspace .claude/ above +└── ... your team's prose docs (anything else) +``` + +Bootstrap clones the remote on first run. If the remote is private and +the clone fails, the banner shows `✗ knowledge` with a hint — fix SSH / +HTTPS access, `rm -rf context/knowledge`, restart. + +--- + +## 3. Notes repo + +Same shape as knowledge: empty git repo, `notes.git` URL in +`config.json`. On first clone loopat seeds: + +- `notes/inbox.md` — append-only team scratchpad (loops write here) +- `notes/memory/` — team memory (`MEMORY.md` index + per-fact files) + +Unlike knowledge, **notes is read-write** in sandboxes. Loops auto- +commit and push every write. Do **not** branch-protect main on this +repo — auto-push will fail. + +--- + +## 4. Team Claude config — CLAUDE.md / skills / MCP + +All three live under `knowledge/.loopat/.claude/`. Loopat ro-binds the +whole tree into each loop's `$CLAUDE_CONFIG_DIR/`, so Claude Code +discovers them natively as if they were `~/.claude/` on a normal host. + +### Team CLAUDE.md + +Workspace conventions everyone agrees on — directory layout, how to +run tests, what never to touch, doc pointers. Concatenated into every +loop's user-tier prompt. Keep it static so prompt cache stays warm. + +### Skills + +`SKILL.md` folders under `skills/`. Each folder becomes an invocable +slash-skill inside loops. Use these for repeatable team procedures +(release flow, on-call triage, repo-specific lint fix). + +### MCP servers + +```json +// knowledge/.loopat/.claude/settings.json +{ + "mcpServers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { "Authorization": "Bearer ghp_team_shared_token" } + } + } +} +``` + +`claude.json` is team-shared — only commit tokens that **everyone on +the team is allowed to use**. For per-user tokens (oauth, personal +PATs), leave them out and let each member set them via personal config. + +Details on the full injection model: [claude-config.md](claude-config.md). + +--- + +## 5. Profiles (roles / modes) + +A profile = an opt-in bundle of `.claude/` config that loops can stack. +Each profile is a complete `.claude/` tree: + +``` +knowledge/.loopat/profiles/ +├── role-eng/ +│ └── .claude/ +│ ├── CLAUDE.md # role-specific doctrine +│ ├── settings.json # enabledPlugins, mcpServers, hooks +│ ├── skills/<name>/SKILL.md +│ ├── agents/<name>.md +│ └── mise.toml # toolchain pins for this role +├── role-legal/ +│ └── .claude/… +└── mode-oncall/ + └── .claude/… +``` + +When a user spawns a loop and selects `[role-eng, mode-oncall]`, loopat +merges those profiles' `.claude/` directories on top of the team workspace +tier and the user's personal tier. Per-key shallow union; later tier wins +per key. See [composition.md](composition.md). + +`mise.toml` inside any profile activates `mise install` for that loop; +toolchain installs are shared across all loops (cached at +`~/.local/share/mise/installs/`). + +A loop selecting **zero** profiles still gets workspace + personal + the +project's own `.claude/` — works fine for pure-prose loops. + +--- + +## 6. Operator mounts + +The workspace `mounts[]` field exposes **host** paths into every +loop's sandbox. Use this for caches you want shared globally or for +trust roots: + +```jsonc +"mounts": [ + { "src": "$HOME/.cache", "dst": "$HOME/.cache", "rw": true }, + { "src": "$HOME/.bun", "dst": "$HOME/.bun", "rw": true }, + { "src": "/etc/pki/ca-trust", "dst": "/etc/pki/ca-trust" } +] +``` + +- `src` — any host path (`$HOME/...`, `~/...`, absolute `/...`) +- `dst` — must be sandbox-rooted (`$HOME/...`, `~/...`, `/...`) +- `rw` — defaults to false (read-only). Set true if loops need to write. + +Operator mounts apply to **every** loop on this workspace. For per- +user mounts (private ssh keys, individual gh tokens), users add them +in their personal config — see [setup-user.md](setup-user.md). + +> **Why this layer exists** — the three mount layers (operator / +> admin / member) map directly to filesystem ownership: operator owns +> the host, admin pushes to `knowledge/`, member writes their own +> `personal/`. Whoever owns the directory owns that layer's mount +> decisions. Details: [sandbox.md §三层 mount 权责](sandbox.md). + +--- + +## 7. Activate users + +When a new member registers, they land as `status: pending` and can't +log in until you activate them. + +Open `http://<your-host>:10001/admin` (admin-only route) and flip the +status to `active`. The user can now log in and start their own +[user setup flow](setup-user.md). + +--- + +## Verify + +After the dust settles, the banner should be all green: + +``` +──────────────────────────────────────────────────────────── + loopat bootstrap — loopat (user=admin) +──────────────────────────────────────────────────────────── + ✓ workspace: /home/admin/.loopat + ✓ workspace supplement: knowledge/.loopat/.claude/CLAUDE.md (present) + ✓ knowledge: git@…/loopat-knowledge.git + ✓ notes: git@…/loopat-notes.git + ✓ repos: app, infra + ✓ users: 1 (admin) + ✓ config: /home/admin/.loopat/config.json + ✓ bwrap (sandbox) + ✓ claude binary (…/claude) + ready. open http://localhost:10001 +``` + +Share the URL with your team and point them at +[setup-user.md](setup-user.md). + +--- + +## See also + +- [install.md](install.md) — host install, system deps, env vars +- [setup-user.md](setup-user.md) — member-side companion to this guide +- [architecture.md](architecture.md) — read/write paths, context layers +- [claude-config.md](claude-config.md) — exact CLAUDE.md / skills / MCP wiring +- [sandbox.md](sandbox.md) — bwrap mechanics, three-tier mount authority +- [troubleshoot.md](troubleshoot.md) — banner errors, common pitfalls diff --git a/docs/setup-user.md b/docs/setup-user.md new file mode 100644 index 00000000..7183c6e6 --- /dev/null +++ b/docs/setup-user.md @@ -0,0 +1,239 @@ +# User setup — join a workspace + +> Audience: an admin already stood up the workspace. Your job is to +> get your own loops working — register, set up your private credential +> repo, plug in an AI provider, link your dotfiles into the sandbox. + +If you're the admin standing up loopat, start with +[setup-admin.md](setup-admin.md) first. + +--- + +## TL;DR + +| Step | What | Where | +|---|---|---| +| 1 | Register | open the UI, click **Register** | +| 2 | Get activated | wait for admin (first user is auto-admin) | +| 3 | Personal repo | Settings → Personal Repo (gates everything) | +| 4 | AI provider | Settings → AI Providers | +| 5 | Credentials → vault | drop ssh / gh / etc. into your vault | +| 6 | Sandbox mounts | Settings → Sandbox Mounts | +| 7 | Spawn a loop | Loops → New loop | + +--- + +## 1. Register + +Open `http://<workspace-host>:10001` and click **Register**: + +- **Username** — lowercase, `[a-z0-9_-]`, 1–32 chars +- **Password** +- **Personal repo** — *optional but recommended*. A **fresh, empty + private** git repo (GitHub / GitLab / Gitea, all work). You can + skip this and fill it in later from the Settings page. + +The first account to register on a workspace auto-promotes to admin ++ active. Every other account starts `pending` and waits for an admin +to activate it. If you see a yellow "pending" notice on login, ping +your admin. + +--- + +## 2. Personal repo — the credential vault + +Everything else in Settings is **gated** behind setting up your +personal repo. This is by design: provider apiKey values, MCP OAuth +tokens, SSH keys, CLI dotfiles all live in your personal repo's vault, +encrypted with git-crypt. No personal repo, nowhere to put your keys. + +### Why a separate repo? + +`personal/<you>/` carries your secrets — API keys, ssh private keys, +gh tokens, dotfiles. It is **never** pushed to the shared knowledge / +notes repos. Instead, each member gets their own private repo that +loopat manages: + +- you create an empty git repo (private!) +- loopat clones it on your machine, runs `git-crypt init`, generates + a fresh symmetric key, pushes a scaffold +- every write under `personal/<you>/.loopat/` is auto-committed and + pushed; secrets are transparently encrypted by git-crypt + +You **don't need git-crypt installed**. Loopat handles it. + +### The flow + +Go to **Settings → Personal Repo**: + +1. **Copy the deploy key** loopat shows you. This is an ssh public + key generated for your account, stored on the host outside + `personal/` (so the sandbox can't read the private half). + +2. **Create an empty private repo** on GitHub / GitLab. Add the + deploy key under *Settings → Deploy keys* (or equivalent on + your provider). **Tick "Allow write access".** + +3. **Paste the repo URL** (`git@…:you/loopat-personal.git`) into the + panel and click **Continue**. + +4. Loopat clones, runs `git-crypt init`, and **shows you a + git-crypt key** once. **Save it somewhere safe** (password + manager). If you ever set up loopat on a new machine, you'll + paste this key into the "I already have a git-crypt key" + recovery box. + +5. Tick "I've saved it" → done. Settings unlocks. + +### "It says my repo isn't clean" + +Loopat refuses to import a repo that already has git-crypt set up or +that has tracked files matching the secret patterns. Rotate any +exposed secrets and either delete + recreate the repo, or use the +recovery flow with your existing git-crypt key. + +--- + +## 3. AI provider + API key + +**Settings → AI Providers**. + +Add a provider entry — pick a name (`anthropic`, `bailian`, +`openrouter`, whatever you want), fill in `model`, `baseUrl`, +`API key`. The API key gets written to your vault at +`personal/<you>/.loopat/vaults/default/envs/<NAME>_API_KEY` (where +`<NAME>` is the uppercase provider name) and encrypted by git-crypt +on commit. config.json carries a `"${<NAME>_API_KEY}"` reference, +never the literal value. + +Example for direct Anthropic: + +| Field | Value | +|---|---| +| Name | `anthropic` | +| Model | `claude-opus-4-7` | +| Base URL | `https://api.anthropic.com` | +| API key | (paste your `sk-…`) | + +Set this as the **default** provider. The banner now shows +`✓ apiKey (anthropic)` for your loops — chat will actually work. + +You can add as many providers as you want and switch the default; the +UI gives a dropdown per loop too. + +--- + +## 4. Stock your vault + +The **vault** is where every credential lives. There's no config +file — the vault uses two **filesystem conventions** to drive +automatic sandbox delivery: + +``` +~/.loopat/personal/<you>/.loopat/vaults/default/ +├── envs/ ← auto-injected as env vars +│ ├── ANTHROPIC_API_KEY ← created by step 3 +│ ├── MCP_GITHUB_TOKEN ← MCP OAuth callback writes here +│ ├── SENTRY_AUTH_TOKEN ← any custom env +│ └── … +└── mounts/home/ ← auto-bound to sandbox $HOME + ├── .ssh/ ← drop your ssh keys here + │ ├── id_ed25519 + │ ├── id_ed25519.pub + │ └── config + ├── .config/gh/ ← gh CLI auth + ├── .gitconfig ← git identity + ├── .vimrc + └── .secrets/ ← optional: ad-hoc per-service keys + ├── sls/AK_ID + └── … +``` + +**Rules**: + +- `envs/<NAME>` — filename is the env var name, file content (with + trailing newline stripped) is the value. The spawn process injects + every entry as `$NAME`; provider apiKey can reference them as + `"${NAME}"`; workspace MCP configs can reference them as + `"Authorization": "Bearer ${MCP_<SERVER>_TOKEN}"`. +- `mounts/home/<entry>` — each top-level entry (file or directory) is + `--bind`'d at sandbox `$HOME/<entry>`. No config, no declaration — + putting a file there IS the configuration. +- Everything under `vaults/**` is auto-encrypted by git-crypt before + push — committed to your private repo as ciphertext, decrypted on + clone. + +**Multi-vault** is supported but optional: create a sibling dir +(`vaults/prod/`, `vaults/test/`) and loops can pick which vault to +activate. Cross-vault symlinks are fine as long as `realpath` stays +inside your `personal/<you>/` tree. See +[sandbox.md §Vault](sandbox.md#vault凭据隔离) for the model. + +Once stocked, sandbox CLIs Just Work: `ssh git@github.com` / +`gh auth status` / `git push` all use the credentials you dropped in. + +--- + +## 5. Terminal shell (optional) + +**Settings → Terminal Shell**. + +PTY shell binary for loop terminals. Default is `/bin/bash`. + +Set to `/usr/bin/zsh` etc. if you've also exposed it via +`vaults/<active>/mounts/home/...` or via a profile's `mise.toml`. + +--- + +## 6. Spawn your first loop + +**Loops → New loop**. Pick: + +- A **repo** (if you want to work in code) — one of `repos[]` from + the workspace config +- A **sandbox** (if the repo needs a toolchain) — one of the named + sandboxes the admin provisioned +- A **vault** — defaults to `default` + +Click Create. The server forks a worktree on `loop/<slug>-<id>`, +spawns a sandboxed Claude session, and drops you into chat. + +Things to sanity-check on a fresh loop: + +```sh +# in the loop's terminal pane +echo $ANTHROPIC_API_KEY # … or your provider's env var (set if envs injected) +cat ~/.ssh/id_ed25519.pub # works iff §5 mount is set +git ls-remote # works iff §5 .gitconfig + ssh are set +``` + +If chat returns a red error or terminal is empty / shell missing, +walk [troubleshoot.md](troubleshoot.md) — most issues fall under +"API key wrong" or "broken symlink in mounts". + +--- + +## Recovery: setting up on a new machine + +Your personal repo + the git-crypt key are sufficient to recreate +everything. On the new host: + +1. Install loopat ([install.md](install.md)), register with the + **same username**. +2. Settings → Personal Repo → paste repo URL + open the recovery + panel → paste your git-crypt key. +3. Loopat clones, unlocks, and your providers / mounts / envs / + vault contents are all back. + +This is the entire backup story: **personal repo + crypt key**. + +--- + +## See also + +- [setup-admin.md](setup-admin.md) — workspace-side companion +- [architecture.md](architecture.md) — what context layers exist +- [sandbox.md §Vault](sandbox.md#vault凭据隔离) — multi-vault model +- [claude-config.md](claude-config.md) — how team CLAUDE.md / skills + / MCP reach your loops +- [troubleshoot.md](troubleshoot.md) — when chat doesn't work diff --git a/docs/superpowers/plans/2026-06-02-dogfood-first-5-minutes.md b/docs/superpowers/plans/2026-06-02-dogfood-first-5-minutes.md new file mode 100644 index 00000000..f19c0d8d --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-dogfood-first-5-minutes.md @@ -0,0 +1,67 @@ +# dogfood/first-5-minutes 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development 或 superpowers:executing-plans 逐任务实现。步骤用 `- [ ]` 勾选。 + +**Goal:** 一条 Playwright e2e,完整模拟真实用户:浏览器建基于 roster repo 的 loop → 真容器 → UI 发消息真 AI 回 → 点开 terminal 跑 git status/commit/push 到 fixture sshd 容器里的真 origin。 + +**Architecture:** 独立 playwright project `dogfood/`;fixture = 一个 sshd+git 的 podman 容器(裸仓库 knowledge/notes/personal/roster);隔离 LOOPAT_HOME 预置"已 onboarded",gitHost 指向 fixture;真 claude + 真 provider key(来自 dev 机 vault)。podman/key 缺则 fail。 + +**Tech Stack:** Playwright、podman、bun、现有 e2e/globalSetup pattern。 + +--- + +### Task 1: fixture sshd 容器(镜像 + 种子裸仓库) + +**Files:** +- Create: `dogfood/first-5-minutes/fixtures/Containerfile` +- Create: `dogfood/first-5-minutes/fixtures/seed.sh` + +- [ ] **Step 1:** 写 Containerfile:`FROM alpine`,装 `openssh git`,建 `git` 用户,sshd 配 `AuthorizedKeysFile`,base path `/srv/git`。 +- [ ] **Step 2:** 写 seed.sh:把传入的 pubkey 写入 authorized_keys;`git init --bare /srv/git/{knowledge,notes,roster1,personal}.git`;往 knowledge 裸仓库 push 一个含 `.loopat/config.json`(notes 指向 fixture)的初始 commit;roster1 push 几个文件;每个 `--bare` 开 `receive.denyCurrentBranch=updateInstead`/允许 push。 +- [ ] **Step 3:** 提交。`git add dogfood/first-5-minutes/fixtures && git commit -m "test: fixture sshd git server image"` + +### Task 2: dogfood playwright config + fixture 起停 + +**Files:** +- Create: `dogfood/playwright.config.ts`(仿 `playwright.config.ts`,testDir=dogfood,globalSetup 起 fixture 容器 + backend) +- Create: `dogfood/setup.ts`、`dogfood/teardown.ts` + +- [ ] **Step 1:** setup.ts:`podman build` fixture 镜像 → `podman run -d -p 127.0.0.1:0:22` 拿 host 端口 → 起隔离 LOOPAT_HOME backend(gitHost.baseUrl=ssh fixture,knowledge git=ssh)。 +- [ ] **Step 2:** 预置 onboarded:在 LOOPAT_HOME 写 personal/test 的 config.json(provider=真 idealab,apiKey=`${IDEALAB_API_KEY}`)+ vault envs/IDEALAB_API_KEY(从 dev `~/.dashscope`? 用 dev vault 复制)+ vault id_ed25519(pub 已进 fixture authorized_keys)。 +- [ ] **Step 3:** teardown.ts:`podman rm -f` fixture,kill backend。 +- [ ] **Step 4:** 提交。 + +### Task 3: README + 建 loop 旅程骨架 + +**Files:** +- Create: `dogfood/first-5-minutes/README.md`(目的/流程/断言) +- Create: `dogfood/first-5-minutes/journey.spec.ts` + +- [ ] **Step 1:** spec:登录态(storageState)→ `/loop` → 建基于 roster1 repo 的 loop → 等容器 running(轮询 status)。断言 loop 出现。 +- [ ] **Step 2:** 运行验证(fail:容器/选择器待对)。`bunx playwright test --config dogfood/playwright.config.ts` +- [ ] **Step 3:** 提交。 + +### Task 4: UI 发消息 + 真 AI + +- [ ] **Step 1:** 在 chat 输入框输入"在 workdir 跑 git status 并新建 a.txt"→ 发送 → 断言收到回复、无 error 事件(轮询 SSE/DOM)。 +- [ ] **Step 2:** 运行、提交。 + +### Task 5: terminal git 断言(核心回归) + +- [ ] **Step 1:** 点开 terminal → 输 `git status` → 断言**非 fatal**;`echo x>>a.txt; git add -A; git commit`;`git push` → 断言 push 成功。 +- [ ] **Step 2:** host 侧验 fixture roster1.git 收到新 commit。 +- [ ] **Step 3:** 提交。 + +### Task 6: 假绿即红 + npm script + +- [ ] **Step 1:** podman/IDEALAB_API_KEY 缺 → `throw`(非 skip)。 +- [ ] **Step 2:** `package.json` 加 `"dogfood": "playwright test --config dogfood/playwright.config.ts"`。提交。 + +## Task 1 实地发现(已验证,务必照做) +- podman 不支持 `-p 127.0.0.1:0:22`(报 "got 0")→ setup.ts 必须 pickPort 拿空闲端口再 `-p 127.0.0.1:PORT:22`。 +- alpine `adduser -D` 锁账号 → pubkey 全拒;Containerfile 已加 `passwd -d git`。 +- 已验证:build → run → seed.sh "$pubkey" → `ssh -p PORT git@127.0.0.1` clone roster1.git 成功。 + +## Self-Review +- 覆盖:fixture✓ 跳onboarding✓ 建loop✓ UI发消息✓ terminal git✓ 真AI✓ 假绿即红✓ +- 待实现时确认:chat/terminal 的真实 selector、dev vault key 复制路径。 diff --git a/docs/superpowers/specs/2026-06-02-critical-path-e2e-design.md b/docs/superpowers/specs/2026-06-02-critical-path-e2e-design.md new file mode 100644 index 00000000..0112a5b4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-critical-path-e2e-design.md @@ -0,0 +1,59 @@ +# loopat 关键路径 E2E — 设计 + +## 痛点 + +AI 提交代码很快,但容易 break 基本逻辑;人肉走一遍"前 5 分钟基本体验"就能发现一堆 bug(workdir git 路径错、repos 页面空、ssh key 改名挂、config-hash 漂移)。AI 自测测不出:要么只跑 `bun test`(L4 被 `skipIf` 静默跳过)、要么干脆没真跑——结果永远"假绿"。 + +这些 bug 共性:都是「真沙箱 + 真配置 + 真 git」的集成问题,L1–L3 逻辑测试天然抓不到。缺的是一条**最高保真、贯通整条关键路径**的 e2e,而且必须**跑不了就红、不能 skip 成绿**。 + +## 目标 + +一条 Playwright e2e,完整模拟真实用户,把"前 5 分钟"整条走真:浏览器 → 后端 → fixture sshd git server → podman 真容器 → 真 AI → 在 UI 里点开 terminal 跑 git。一次能炸出本次所有 bug。 + +## 范围 + +**测**:已 onboarded 状态下的核心环路——建基于 roster repo 的 loop → 真容器 → UI 打字发消息 → AI 回 → 点开 terminal `git status`/改文件/commit/push 到真 origin。 + +**不测(明确划出)**:onboarding。它锁死内部 `code.ts`(code.alibaba API、idealab key、dashjump 探测),fixture 模拟不了;靠真环境手动验。e2e 预置"已 onboarded"态绕开它。 + +## 架构 + +1. **fixture sshd git server(podman 容器)**:测试起一个 sshd + git 的 podman 容器,`authorized_keys` 塞 fixture 的 `id_ed25519.pub`,ssh 端口 publish 到 host;容器内预置裸仓库:`knowledge`(带 `.loopat/config.json` + `.claude`)、`notes`、`personal/<user>`、若干 roster repo(如 vineyard)。loop 容器走 host 网络连上去,clone / worktree / push 全程真 ssh,把 vault key + known_hosts + 认证一起验。隔离、可复现、跑完即删,不碰 host sshd/端口。 +2. **隔离后端**:临时 `LOOPAT_HOME`,gitHost / knowledge 指向 fixture sshd。预置 personal repo(vault: 测试 `id_ed25519` + 配好的 provider key)→ 跳过 onboarding。 +3. **浏览器**:Playwright 预登录,dismiss setup 卡,落 `/loop`。 +4. **真容器**:podman host 网络,容器直连 host 上 fixture sshd 端口。 +5. **UI 真交互**:在 chat 输入框打字发送;点开 terminal 面板敲命令、读输出(不是调 API)。 +6. **真实 AI**:真 claude binary + 真 provider(idealab/bailian,key 从 vault),完整模拟用户,不 mock。慢、烧钱、非确定 → 定位发版前/手动高保真验收。 + +## 关键路径(断言点) + +- 登录 → `/loop`,loop 列表可见 +- 建一个基于 roster repo(vineyard)的 loop → 容器 running +- chat 输入框发一句 → 真 AI 回复(无 error 事件) +- 点开 terminal → `git status` **不 fatal**(回归本次 workdir gitdir bug)→ 改文件 → commit → `git push` 成功进 fixture origin +- 断言**行为**(真 AI 非确定,不断准文本):loop 建成 / 容器 running / 有回复 / git status exit 0 / origin 收到 push + +## 取舍 + +- **git server**:podman 容器跑 sshd(最真且隔离);不用 host sshd(污染端口)、不用 git daemon / file://(否则 ssh 那套测不到)。 +- **git-crypt**:测试用预置 key 解锁(或明文),不搞真 git-crypt。 +- **假绿即红**:podman 或 provider key 不可用时 **fail 而非 skip**——这条测试不能假绿。定位发版前/手动,慢可接受。 + +## 目录与命名 + +这类"完整模拟真实用户"的测试统一放 `dogfood/`,每个旅程一个子目录、自成一体: +``` +dogfood/ + first-5-minutes/ # 首个旅程:本设计描述的环路 + README.md # 目的 + 流程 + 断言(人能读) + journey.spec.ts # Playwright 测试 + fixtures/ # sshd 容器镜像、裸仓库种子 +``` + +## 不做 + +onboarding、`code.ts`、真 gitlab。 + +## 未来扩展(非当前范围) + +**远程/预发测试**(loopat 跑在 mac mini 等目标机,pw/CI 在别处触发):当前 dogfood 把"被测机"和"测试机"绑在一台(setup 在本机起栈、断言直接敲本机 podman/git),刻意从简。要支持远程需引入 **target-host 抽象**——baseURL 指目标机 + 把 podman/git 集成断言改为远程执行(ssh)或走 loopat HTTP API。预发环境验证用,非刚需。 diff --git a/docs/superpowers/specs/2026-06-03-first-run-journey-redesign.md b/docs/superpowers/specs/2026-06-03-first-run-journey-redesign.md new file mode 100644 index 00000000..3f553eef --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-first-run-journey-redesign.md @@ -0,0 +1,45 @@ +# dogfood first-5-minutes 重做 = 真·首次用户全旅程 — 设计 + +## 目标 +first-5-minutes 从"预置 onboarded + storageState 跳过登录"改成**真·首次用户冷启动全旅程**,当 smoke:全空 LOOPAT_HOME → 注册 → 登录 → onboarding gate 拦 → UI 配 personal repo(填地址 + git-crypt key)→ 把 ssh 公钥加到"平台"→ context 出内容(clone)→ 建 loop → AI → terminal。覆盖一个普通用户第一次用 loopat 的完整链路,而且**真测 onboarding**(之前故意划出范围的部分)。 + +## 核心新增:fixture git-host provider +一个仿 `code.ts`、但**操作 fixture sshd** 的测试 provider,由 setup 写进 `LOOPAT_HOME/extensions/providers/<id>.ts`。duck-typed `GitHostProvider`,**不 import loopat、不含任何内网 endpoint**(全从 env 读): +- `id`/`label`;`gitAuthMode: "ssh-deploy-key"`(personal repo 走 deploy key;team repo kn/notes 走 vault 的 id_ed25519) +- `authenticate(cred)`:用 env 传入的 token/marker 校验,返回 `login`(如 `test`) +- `ensureRepo`:在 fixture sshd 容器里 `git init --bare` 出 personal repo +- `registerDeployKey`:把 personal repo 的 deploy **公钥**塞进 fixture `authorized_keys`(让 loopat 能 clone/push personal repo) +- `seedDefaults`:在 personal repo 工作树里写 `.loopat/config.json`(provider = AI,`baseUrl`/`apiKey` 用 **env-var 引用**,不写实值)+ 在 vault 里 `ssh-keygen` 生成 `id_ed25519`(git-crypt 加密) +- `onboarding(ctx)`:**1:1 抄 code.ts** 三步、尽量真实不简化,驱动 gate: + 1. `!personalRepoImported` → route `/settings/personal-repo`。该页(通用前端 `PersonalRepoPanel`)真实驱动:用户填一个**(假)token** → 前端调 `listRepos`(首次**空**)→ 用户点"创建" → `ensureRepo` 在 fixture 建 personal repo + `registerDeployKey` 塞 deploy 公钥 + `seedDefaults` 备好 `.ssh` key & `config.json`(base url 走 env) → import(git-crypt:用户输 key 解锁)。 + 2. 无 AI key → form 要 AI key(值由 env 提供,action `vault-env`)——同 code.ts,用户在跳转的这页填 key。 + 3. ssh 探测:vault 的 key 能否 clone fixture kn/notes → **不能** → `info`("把这个公钥加到平台 SSH Keys");**能** → `{done:true}`。 +- gate:provider 实现 onboarding ⇒ loopat 强制 onboarding(后端 403 + 前端 Shell 全局拦),done 前进不了主 UI + +## 决策(已敲定) +- **git-crypt 真**:personal repo 真加密(`.loopat/vaults/**`),第 5 步用户输 git-crypt key → loopat `saveGitCryptKey` + `git-crypt unlock`。 +- **ssh 公钥 = 测试 seed**:第 7 步测试代码把 vault `id_ed25519.pub` 塞进 fixture `authorized_keys`(模拟用户去平台 SSH Keys 手动加 key)。加之前 kn/notes clone 不了,加之后能。 +- **endpoint 全 env**:fixture provider baseUrl、AI provider baseUrl+key、fixture repo url 全走环境变量;repo 源码与 fixture 里不出现任何内网 endpoint。 +- **第 4 步测"被拦"**:未配置时点 context / 建 loop,断言被 onboarding gate 正确拦住(到不了 context;建 loop 403)。 + +## 11 步 → Playwright 断言(全真:浏览器 + 真容器 + 真 git + 真 AI) +1. **全空 LOOPAT_HOME**(setup mkdtemp,不预置 user、不预置 onboarded、不写 storageState)。 +2. 浏览器**注册**(UI 输 user/pass)→ 成功。 +3. 浏览器**登录**(UI)→ 成功,落到 onboarding gate。 +4. 点 context / 试建 loop → **被 onboarding gate 拦**(断言看到 onboarding 提示、进不去 context;建 loop API 403)。 +5. UI 配 **personal repo**(真走 code.ts 式流程):填(假)token → 列个人 repo(**空**)→ 点"创建" → fixture provider `ensureRepo`+`registerDeployKey`+`seedDefaults` 备好 → 输 git-crypt key → import 解锁。 +6. 再看 → 仍被拦(ssh 公钥没加,kn/notes clone 不了),onboarding 仍提示"加公钥"。 +7. **测试 seed**:vault `id_ed25519.pub` → fixture `authorized_keys`(模拟平台加 key)。 +8. onboarding 重探测 → `done`;进 context → 等后台 clone → 看到 kn/notes 内容。 +9. **建 loop** → 容器"准备中" → running。 +10. **chat** → 真 AI 回(env key)。 +11. **terminal** → `git status` 正常。 + +## 不做 +真 code 平台、内网 endpoint、storageState 跳登录(这条 case 专门走真注册/登录)。其它 case 仍用 storageState(快)。 + +## 风险 +- 目前最大的一条:fixture provider + 真 git-crypt + 整个 onboarding UI 流程,比现有所有 case 都重、selector 多。 +- onboarding 的 submit/personal-import 流程沿用现有后端(`/api/onboarding/submit`、personal import、`saveGitCryptKey`);实现时跟随现有代码,别新发明。 +- 真 git-crypt 要求跑测试的机器装了 `git-crypt`(bootstrap 已检测)。 +- **起点冲突**:first-run 要**全空** LOOPAT_HOME(不预置 onboarded),而其它 case 共享的 setup 预置了 onboarded。故 first-run 需要**自己的 config + setup**(空起点 + 装 fixture provider + 不写 storageState),与其它 case 的 setup 分开。 diff --git a/docs/troubleshoot.md b/docs/troubleshoot.md new file mode 100644 index 00000000..1556bff2 --- /dev/null +++ b/docs/troubleshoot.md @@ -0,0 +1,180 @@ +# Troubleshooting + +If chat doesn't work or the UI shows red errors, walk this list top-to-bottom. Most issues land in §1 or §2. + +## 0. The bootstrap banner is the first signal + +Whatever's wrong, look at the banner `bun run dev` prints first: + +``` +──────────────────────────────────────────────────────────── + loopat bootstrap — loopat (user=alice) +──────────────────────────────────────────────────────────── + ✓ workspace: /home/alice/.loopat + ✓ doctrine: knowledge/loopat/CLAUDE.md + ✓ knowledge: git@…/loopat-knowledge.git + ✓ notes: git@…/loopat-notes.git + ✓ repos: loopat + ✓ config: /home/alice/.loopat/config.json + ✓ bwrap (sandbox) + ✓ claude binary (@anthropic-ai/claude-agent-sdk-linux-x64/claude) + ✓ apiKey (bailian) +``` + +Any `✗` line tells you exactly what to fix. Hint after the bar gives the command/path. + +--- + +## 1. "Claude code process exited with code 1" + +This is the most common runtime error. Pops up in the UI as a red `⚠️ Claude code process exited with code 1` and the chat doesn't progress. + +### What it means + +loopat doesn't talk to the Anthropic API directly — the SDK spawns the `claude` CLI binary as a subprocess (wrapped in `bwrap`). The error means that subprocess died. + +``` +loopat server + └─ @anthropic-ai/claude-agent-sdk + └─ spawn: bwrap … -- /path/to/claude … ← this exited non-zero +``` + +### Diagnose + +The server pipes the child's stderr to its own stdout. Look at the terminal running `bun run dev` for lines tagged `[sdk:<id>:stderr]`: + +``` +[sdk:abcd1234:stderr] bwrap: Can't bind mount … +[sdk:abcd1234] child exited code=1 +``` + +That's the actual error. If you want to see the full spawn command too: + +```sh +LOOPAT_DEBUG_SPAWN=1 bun run dev +``` + +prints the full `bwrap …` argv on every spawn. Copy it, run by hand to reproduce outside the SDK loop. + +### Common causes (by probability) + +1. **`bwrap` not installed.** Linux only — `sudo apt install bubblewrap`. The bootstrap banner catches this with `✗ bwrap (sandbox)` so you should have noticed earlier. + +2. **API key / baseUrl wrong.** `~/.loopat/config.json` → `providers.<active>.apiKey` or `baseUrl` is empty / typo'd. Stderr usually shows `401 Unauthorized` or `connection refused` / `getaddrinfo`. + +3. **Claude binary architecture mismatch.** `bun install` ships a platform-specific package (`@anthropic-ai/claude-agent-sdk-linux-{x64,arm64}`, optionally `-musl`). If yours doesn't match the host you'll get `cannot execute binary file` or `No such file or directory` (loader confusion). Run the binary directly to confirm: + + ```sh + path-from-banner --version + ``` + +4. **Broken `personal-deps` symlinks.** Symlinks anywhere under `personal/<user>/` get re-bound into the sandbox at their `$HOME` target. If a target doesn't exist, bwrap aborts. + + ```sh + find ~/.loopat/personal -xtype l + # any output = broken symlink → delete it + ``` + +5. **`/tmp` permission weirdness on shared hosts.** bwrap needs `/tmp` writable. If your `/tmp` is mounted noexec or has restrictive permissions, spawn can fail. + +--- + +## 2. Bootstrap-time problems + +### `✗ bwrap (sandbox)` + +```sh +sudo apt install bubblewrap +``` + +Linux-only. macOS / Windows isn't supported (no equivalent userns sandbox). + +### `✗ claude binary` + +Run `bun install` again from the loopat repo root. The platform packages aren't optional deps — they should always install. If they didn't: + +```sh +bun pm ls | grep claude-agent-sdk +``` + +Check the platform-specific one is present. If not, you may need `bun install --force`. + +### `✗ apiKey (<provider>)` + +Edit `~/.loopat/config.json`: + +```json +{ + "default": "bailian", + "providers": { + "bailian": { "model": "...", "baseUrl": "...", "apiKey": "PASTE_HERE" } + } +} +``` + +Restart `bun run dev`. The banner re-checks on every restart. + +### `clone failed (…) → falling back to empty dir` + +Knowledge or notes had a `git` URL but the clone failed (no SSH key, repo private, network). Fix one of: +- Switch SSH `git@…` URL to HTTPS `https://…` if the repo is public +- Add your SSH key to GitHub and `ssh -T git@github.com` to verify +- Leave the URL empty (`""`) to skip clone and use a local-only dir + +After fixing, delete the empty dir and restart so bootstrap re-tries the clone: + +```sh +rmdir ~/.loopat/context/knowledge # or notes +``` + +--- + +## 3. Web UI issues + +### "ECONNREFUSED 127.0.0.1:10001" in vite log + +Startup race — vite (5173) is up, server (10001) is still booting. Errors stop within 1–2 seconds. Refresh the page. See "Why" in the README. + +### Loops list is empty after upgrading + +You probably had `~/.loopat/<workspace-name>/` from an older nested layout. Check `/api/health` — if `workspace` doesn't match what's actually on disk, restart the server (running process holds stale paths from before the rename). + +### Two of every user message + +Already fixed (commit `5b878c0`). If you still see it, your dev server is running an old build — `Ctrl+C`, re-run `bun run dev`. + +### Big blank space right after submitting + +Already filtered (commit `41f0153`) — empty assistant placeholders no longer render. Same fix as above: restart the dev server. + +--- + +## 4. Where to look for logs + +| Where | What | +|---|---| +| Terminal running `bun run dev` | Bootstrap banner, `[sdk:<id>:stderr]` lines, child exit codes, server-side errors | +| Browser devtools console | Web errors, ws connect/close events, vite proxy errors | +| `~/.loopat/loops/<id>/messages.jsonl` | Persistent transcript per loop. Replays on attach. | +| `/api/health` | Sanity check — current workspace + LOOPAT_HOME | + +**Verbose logging** + +```sh +LOOPAT_DEBUG=1 bun run dev +``` + +prints, per loop session: +- resolved provider / model / baseUrl / apiKey-length +- full bwrap argv (one line per spawn) +- spawned PID +- every line of child stderr (`[sdk:<id>:stderr] ...`) +- every line of child stdout (capped, `[sdk:<id>:stdout] ...`) +- every SDK message type passing through (`[sdk:<id>] msg <type>`) +- exit code + signal + +Plus, **always** (even without `LOOPAT_DEBUG`), child stderr is teed to `~/.loopat/loops/<id>/stderr.log`. So even if the terminal eats output (bun's filter multiplexer truncates, paste tools elide, etc.), this file has the full unfiltered text. After a code-1 exit the server log points at this file: + +``` +[sdk:abcd1234] child exited code=1; full stderr at /home/alice/.loopat/loops/<id>/stderr.log +``` diff --git a/dogfood/README.md b/dogfood/README.md new file mode 100644 index 00000000..f8f5137f --- /dev/null +++ b/dogfood/README.md @@ -0,0 +1,90 @@ +# dogfood — real-stack e2e + +These tests boot a **real** loopat stack (isolated `LOOPAT_HOME`, real backend, +real podman sandbox, real fixture git origin over ssh) and drive it through a +real browser. No mocks. They catch integration bugs that the logic tests in +`e2e/` cannot — workdir gitdir paths, empty repos page, config-hash drift, ssh +key naming, real AI turns, real pushes to origin. If the stack can't actually +run, a dogfood test **fails — it never skips green**. + +They cost a (small) real AI key spend, so they are organized into four tiers by +cost and intent. + +## Tiers + +### smoke — fast, run on every change +The shared-fixture suite presets an ALREADY-ONBOARDED user and reuses one +backend, so it's the cheapest way to know the critical path still works. + +| Case | Answers | +|------|---------| +| `first-5-minutes` | Can a fresh user create a loop, get a real AI reply, and `git push` from the terminal to origin — in the first five minutes? | + +Run: `bun run dogfood:smoke` + +### journey — full cold-start, run before a release +The whole first-time-user flow from a TRULY EMPTY `LOOPAT_HOME`, driven entirely +through the browser (register → onboarding gate → personal repo + git-crypt → +ssh pubkey → context → loop → AI → terminal). Ends with BOTH an AI push and a +human push reaching origin, per the doctrine that origin is the source of truth. + +| Case | Answers | +|------|---------| +| `first-run` | Does the complete cold-start onboarding work end to end, and do both "AI done" and "human done" really land in origin? | + +Run: `bun run dogfood:journey` + +### scenario — focused / regression, run when touching the relevant area +Each isolates one behavior on the shared-fixture stack. + +| Case | Answers | +|------|---------| +| `repos-page` | Does the repos page render the user's roster from real provider state? | +| `second-loop-warm` | Is a second loop on the same repo fast (warm mirror, fetch + worktree, no re-clone)? | +| `attach-detach` | Does detaching/re-attaching a loop recreate the container on config-hash drift? | +| `context-notes-sync` | Does a loop push its notes worktree to origin? | +| `concurrent-push` | Do concurrent pushes from multiple loops converge on origin without loss? | +| `multi-turn-task` | Does a real multi-turn AI tool-use turn complete and verify against integration truth? | + +Run the full scenario preset (the 7 shared-fixture cases): `bun run dogfood` + +### sync — two-server context flow, run before a release +Two fully independent loopat servers (each its own `LOOPAT_HOME`/backend/vite/ +user/vault) sharing ONE fixture git origin. Proves the docs/context-flow.md +claim that across-server and on-one-server multi-user are the same mechanism: +every edit converges on the shared SoT. + +| Case | Answers | +|------|---------| +| `sync` (S0–S5) | A edits → origin → B sees it (UI loop, AI loop, shared kn, concurrent different files, and same-file held-back) — across two independent servers? | + +Run: `bun run dogfood:sync` + +## Running + +All tiers need a real AI key in the environment (never read from disk, never +committed): + +```sh +export IDEALAB_API_KEY=$(cat ~/.loopat/personal/<you>/.loopat/vaults/default/envs/IDEALAB_API_KEY) +``` + +`dogfood:journey` (first-run) additionally needs the AI provider base url: + +```sh +export FIRST_RUN_AI_BASE_URL=https://idealab.alibaba-inc.com/api/anthropic +``` + +Then: + +```sh +bun run dogfood:smoke # first-5-minutes (fast) +bun run dogfood # the 7 shared-fixture scenario cases +bun run dogfood:journey # first-run full cold-start (release) +bun run dogfood:sync # two-server context flow S0–S5 (release) +``` + +`dogfood:sync` additionally needs `FIRST_RUN_AI_BASE_URL`. + +Preconditions are enforced at config load (fail, never skip): `podman`, +`git-crypt` (journey only), and the required env vars must all be present. diff --git a/dogfood/attach-detach/README.md b/dogfood/attach-detach/README.md new file mode 100644 index 00000000..30f18674 --- /dev/null +++ b/dogfood/attach-detach/README.md @@ -0,0 +1,62 @@ +# dogfood/attach-detach — the config-hash-drift regression + +## What this guards + +A loop's sandbox container is created once (lazily, on first terminal/SDK +attach) and is meant to **persist** across every later attach/detach. The +backend decides whether to reuse-or-recreate a container by comparing a +per-loop **config hash** (`hashCreateArgs` in `server/src/podman.ts`, +stamped onto the container as the `loopat.config-hash` label) plus the +resolved image ID against what `ensureContainer` computes on each call. + +The danger this test pins: + +> If `hashCreateArgs` produced a **different** value between two `ensureContainer` +> calls for the *same loop+vault* — e.g. because a different caller +> (`term.ts` PTY vs `session.ts` SDK) or a different lifecycle moment +> (open → close → reopen) fed it drifting inputs — `ensureContainer` would +> hit its "config hash drift — recreating" branch: +> +> ``` +> running, hash drift → stop + rm + create + start +> ``` +> +> That tears the container down and **SIGKILLs (137)** every in-flight +> `podman exec` process: the user's PTY shell, an active claude CLI turn, +> any `nohup`'d server. Symptom users report: *"my terminal disconnected +> the moment a chat started"* / *"my dev server died when I reopened the +> loop."* + +`hashCreateArgs` deliberately **excludes** the env map (PTY and SDK pass +different `extraEnv`, which must NOT force a recreate) and **includes** the +mounts + loop-scoped opts (vault, knowledgeRw, mountAllLoops, ephemeral +ports). This test proves that opening/closing the loop and its terminal +repeatedly keeps the hash — and therefore the container — **stable**. + +## Flow (NO chat message → zero AI tokens) + +1. Create a loop from roster1 through the real UI. +2. Open the terminal panel → backend `ensureContainer` → poll podman until + the sandbox container is **running**. Record its container **ID** and its + **`StartedAt`** (and `CreatedAt`) via `podman inspect`. +3. **Detach**: navigate back to the `/loop` list (unmounts the loop page + + terminal, closing the `/ws/loop/:id/term` socket). +4. **Re-attach**: navigate back to the loop and reopen the terminal — this + calls `ensureContainer` a second time for the same loop+vault. +5. **Integration truth** (`podman inspect`, not the DOM): the container is + the **SAME ID** and was **NOT recreated** — its `StartedAt` and + `CreatedAt` are byte-for-byte identical across detach→reattach. A + teardown+recreate (the drift bug) would change the ID and reset + `StartedAt`/`CreatedAt`; an unchanged `StartedAt` is the hard, + deterministic signal that no drift fired. + +We repeat the detach→reattach cycle twice to make a single lucky no-op +unconvincing. + +## Why `StartedAt`, not just the ID + +A bare ID check could pass if podman happened to reuse a name with a fresh +container in a tight window. `StartedAt` only stays constant if the *exact +same container process* kept running — it is reset by any `rm + create + +start`. Same ID **and** unchanged `StartedAt` together cannot be faked by a +recreate. diff --git a/dogfood/attach-detach/journey.spec.ts b/dogfood/attach-detach/journey.spec.ts new file mode 100644 index 00000000..81616648 --- /dev/null +++ b/dogfood/attach-detach/journey.spec.ts @@ -0,0 +1,223 @@ +/** + * attach-detach — regresses the CONFIG-HASH-DRIFT bug. + * + * THE BUG WE GUARD: a loop's sandbox container is created once (lazily, on + * first terminal attach) and must PERSIST across every later open/close of the + * loop or its terminal. `ensureContainer` (server/src/podman.ts) decides + * reuse-vs-recreate by comparing a per-loop config hash (`hashCreateArgs`, + * stamped as the `loopat.config-hash` label) + the resolved image ID against + * what it recomputes on each call. If that hash ever drifted between two + * attaches for the SAME loop+vault, ensureContainer would hit its + * "running, hash drift → stop + rm + create + start" + * branch — tearing the container down and SIGKILL-137'ing every in-flight + * `podman exec` (the PTY shell, an active claude turn, a user's dev server). + * + * THE REGRESSION SIGNAL (integration truth via `podman inspect`, not the DOM): + * - SAME container ID across detach→reattach, AND + * - UNCHANGED `StartedAt` (and `CreatedAt`). + * A recreate resets `StartedAt`; an unchanged `StartedAt` proves the exact same + * container process kept running — no drift, no teardown. We cycle + * detach→reattach TWICE so one lucky no-op can't pass it. + * + * NO chat message is sent → zero AI tokens. + * + * The harness (dogfood/playwright.config.ts + setup.ts) already booted the real + * stack and preconfigured the `test` user ALREADY ONBOARDED with the idealab + * provider and roster repo roster1. We arrive logged in via storageState. + */ +import { test, expect, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; + +/** Names of RUNNING sandbox containers for this loop id (empty array = none). */ +function runningContainers(loopId: string): string[] { + return execFileSync("podman", [ + "ps", + "--filter", + `label=loopat.loop-id=${loopId}`, + "--filter", + "status=running", + "--format", + "{{.Names}}", + ]) + .toString() + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** The single running container's identity fields for this loop. Throws unless + * exactly one is running — the drift bug would briefly produce zero (during the + * rm) or, after recreate, a container with a different Id/StartedAt. */ +function containerIdentity(loopId: string): { + id: string; + startedAt: string; + createdAt: string; +} { + const names = runningContainers(loopId); + if (names.length !== 1) { + throw new Error(`expected exactly one running container for loop ${loopId}, got: ${names.join(", ")}`); + } + // `.Id` is the full container id; `.State.StartedAt` / `.Created` are RFC3339 + // timestamps that only change when the container is (re)created+started. + const out = execFileSync("podman", [ + "inspect", + "--format", + "{{.Id}}|{{.State.StartedAt}}|{{.Created}}", + names[0], + ]) + .toString() + .trim(); + const [id, startedAt, createdAt] = out.split("|"); + expect(id, "container should have an Id").toBeTruthy(); + expect(startedAt, "container should have a StartedAt").toBeTruthy(); + return { id, startedAt, createdAt }; +} + +/** Open the terminal panel (opens /ws/loop/:id/term → backend ensureContainer) + * and poll podman until this loop's sandbox container is RUNNING. */ +async function openTerminalAndWait(page: Page, loopId: string): Promise<void> { + await page.getByRole("button", { name: /terminal/ }).first().click(); + await expect + .poll(() => runningContainers(loopId), { + message: `expected a running podman container labelled loopat.loop-id=${loopId}`, + timeout: 240_000, + intervals: [1_000, 2_000, 5_000], + }) + .not.toEqual([]); + // First use may still be building the per-loop image behind the + // PreparingOverlay; wait for it to clear so a later reopen drives a warm, + // already-running container (the real attach/detach scenario). + const preparingOverlay = page.getByText("Preparing this loop’s sandbox…"); + await expect(preparingOverlay).toBeHidden({ timeout: 240_000 }); +} + +/** Best-effort remove this loop's sandbox container so loops/containers don't + * accumulate in the shared LOOPAT_HOME across the serial suite. Never throws. */ +function cleanupLoopContainer(loopId: string): void { + if (!loopId) return; + try { + const ids = execFileSync("podman", [ + "ps", "-a", + "--filter", `label=loopat.loop-id=${loopId}`, + "--format", "{{.ID}}", + ]).toString().split("\n").map((s) => s.trim()).filter(Boolean); + if (ids.length) execFileSync("podman", ["rm", "-f", ...ids]); + } catch { + // best-effort teardown — never fail the suite on cleanup. + } +} + +// The loop this case created, so afterEach can reap its container. +let createdLoopId = ""; + +test.beforeEach(async ({ page }) => { + createdLoopId = ""; + // Bypass the "Setup Personal Repo" card for the (preconfigured) account. + await page.addInitScript(() => { + localStorage.setItem("loopat:setupPersonalRepoDismissed", "1"); + }); +}); + +test.afterEach(() => { + cleanupLoopContainer(createdLoopId); +}); + +test("attach/detach a loop repeatedly does NOT recreate its container (no config-hash drift)", async ({ page }) => { + // ── Step 1: land on /loop and create a loop from roster1. ── + await page.goto("/loop"); + await expect( + page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first(), + ).toBeVisible({ timeout: 15_000 }); + + const loopTitle = `dogfood-attach-${Date.now()}`; + const createResp = page.waitForResponse( + (resp) => resp.url().includes("/api/v1/loops") && resp.request().method() === "POST", + { timeout: 15_000 }, + ); + + await page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first().click(); + await expect(page.getByText("New loop", { exact: true })).toBeVisible({ timeout: 5_000 }); + await page.getByRole("combobox").first().selectOption("roster1"); + await page.getByPlaceholder("refactor-gateway").fill(loopTitle); + await page.getByRole("button", { name: "create", exact: true }).click(); + + const resp = await createResp; + // v1 ids carry a `loop_` prefix; the loop URL uses the raw uuid. + const respBody = await resp.json(); + const loopId = String(respBody.id ?? respBody.loop?.id ?? "").replace(/^loop_/, ""); + expect(loopId, `create response should carry the new loop id: ${JSON.stringify(respBody)}`).toMatch(/^[a-f0-9-]+$/); + createdLoopId = loopId; + + await expect(page).toHaveURL(new RegExp(`/loop/${loopId}`), { timeout: 15_000 }); + const sidebar = page.locator("aside").first(); + await expect(sidebar).toBeVisible({ timeout: 15_000 }); + await expect(sidebar.getByText(loopTitle)).toBeVisible({ timeout: 10_000 }); + + // Brand-new loop — nothing has touched it, so no container yet. + expect(runningContainers(loopId), "no container should exist before the terminal opens").toEqual([]); + + // ── Step 2: first attach — open terminal, wait for the running container, + // record its identity. This is the container that must SURVIVE. ── + await openTerminalAndWait(page, loopId); + const baseline = containerIdentity(loopId); + console.log( + `[dogfood] baseline container: id=${baseline.id.slice(0, 12)} startedAt=${baseline.startedAt} createdAt=${baseline.createdAt}`, + ); + + // ── Steps 3–4, twice: detach (back to /loop list, unmounting the terminal + + // closing the term socket) then re-attach (reopen the terminal, + // which calls ensureContainer again). After EACH reattach, assert + // the container is the SAME and was NOT recreated. ── + for (let cycle = 1; cycle <= 2; cycle++) { + // Detach: leave the loop entirely → LoopPage + Terminal unmount, the + // /ws/loop/:id/term socket closes (the "close the terminal panel and + // navigate away" half of the danger). + await page.goto("/loop"); + await expect( + page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first(), + ).toBeVisible({ timeout: 15_000 }); + // The container must remain running while detached (idle stop is 30min by + // default, far beyond this test) — detach alone must not tear it down. + expect( + runningContainers(loopId), + `cycle ${cycle}: container must stay running while detached (detach must not stop it)`, + ).not.toEqual([]); + + // Re-attach: return to the loop and reopen the terminal → ensureContainer + // runs again for the SAME loop+vault. If the hash drifted, THIS is where + // the recreate (stop + rm + create + start) would fire. + await page.goto(`/loop/${loopId}`); + await openTerminalAndWait(page, loopId); + + const after = containerIdentity(loopId); + console.log( + `[dogfood] cycle ${cycle} after reattach: id=${after.id.slice(0, 12)} startedAt=${after.startedAt}`, + ); + + // INTEGRATION TRUTH: same container ID … + expect( + after.id, + `cycle ${cycle}: reattaching must reuse the SAME container — a different id means ensureContainer ` + + `recreated it (config-hash drift), which SIGKILLs in-flight exec'd processes`, + ).toBe(baseline.id); + + // … and it was NOT recreated — StartedAt unchanged. A stop+rm+create+start + // (the drift path) would reset StartedAt; an identical value is the hard + // proof the exact same container process kept running. + expect( + after.startedAt, + `cycle ${cycle}: container StartedAt must be unchanged — any change means it was torn down + recreated`, + ).toBe(baseline.startedAt); + + // CreatedAt is even stronger: it only ever changes on `podman create`. + expect( + after.createdAt, + `cycle ${cycle}: container CreatedAt must be unchanged — a recreate would set a new one`, + ).toBe(baseline.createdAt); + } + + console.log( + `[dogfood] PROVEN stable: container ${baseline.id.slice(0, 12)} survived 2 detach→reattach cycles ` + + `with unchanged StartedAt (${baseline.startedAt}) — no config-hash drift, no recreate`, + ); +}); diff --git a/dogfood/concurrent-push/README.md b/dogfood/concurrent-push/README.md new file mode 100644 index 00000000..b6b3ae11 --- /dev/null +++ b/dogfood/concurrent-push/README.md @@ -0,0 +1,54 @@ +# concurrent-push — git push CONFLICT RESOLUTION, head-on + +This journey proves the part of concurrency that actually matters: not *avoiding* +a conflict, but **resolving** one. Two writers race the same branch (`master`) +of the same origin (the fixture `roster1.git`); the loop is the *second* writer, +so its first push is rejected non-fast-forward, and it must fetch + rebase + +re-push to land its work on top of the other writer's. "Last one resolves." + +This is the legitimacy test for shared loops: a loop's workdir is a git worktree +off a roster mirror, `origin` is the real fixture bare repo, and nothing +serializes pushes. So the loop has to handle a moved origin the standard git way. + +## Flow + +1. Create a loop from `roster1` through the real UI; open the terminal; wait for + the sandbox container to be RUNNING and the PreparingOverlay to clear. +2. In the loop's UI terminal (xterm, **fish** shell), set git identity and make a + commit **Z-base** state on top of origin's current tip. This is the loop's + honest starting point — it is *not* yet behind. +3. **Advance origin from OUTSIDE the loop.** Via `podman exec <fixtureContainer>`, + clone `roster1.git` to a temp dir, commit **Y** on `master`, and push it back + into the bare repo. Now `origin/master` has Y that the loop's local ref does + not — the loop is genuinely behind. +4. In the loop terminal, make a new commit **Z** on the (now-stale) base and + `git push origin HEAD:master` → it is **REJECTED non-fast-forward**, because + origin moved. This is the real conflict. +5. In the loop terminal, resolve it the standard way: + `git pull --rebase origin master`, then `git push origin HEAD:master` again → + succeeds. (We use the explicit `origin master` pull-rebase rather than + `git rebase origin/master`: the loop workdir is a worktree off a bare mirror + whose fetch refspec maps origin's `master` into the LOCAL `refs/heads/master`, + not a `refs/remotes/origin/master` tracking ref — so the explicit pull-rebase + is the layout-independent, canonical "resolve a non-ff" move.) + +## How we assert it — by STATE, never by scraping the xterm + +xterm output is flaky to read (WebGL renderer, async PTY), so we drive the +commands through the real UI terminal but verify every outcome via **integration +truth**: `podman exec <fixtureContainer> git -C /srv/git/roster1.git ...`. + +- **Conflict happened / first push rejected (step 4):** after the loop's first + `git push`, the fixture origin's `master` tip must STILL be **Y** — commit Z + did NOT land. Z is absent from `roster1.git`. That stale tip is the + deterministic proof the non-ff push was rejected (a *successful* push would + have moved the tip to Z). +- **Conflict resolved / both land (step 5+6):** after fetch+rebase+re-push, + `git -C /srv/git/roster1.git log --oneline` contains BOTH the outside commit Y + AND the loop's commit Z — the second writer rebased on top of the first and + both are in origin's history. + +## No AI tokens + +No chat message is ever sent. The whole journey is driven through the real UI +terminal, so this case spends zero provider tokens. diff --git a/dogfood/concurrent-push/journey.spec.ts b/dogfood/concurrent-push/journey.spec.ts new file mode 100644 index 00000000..c06d882a --- /dev/null +++ b/dogfood/concurrent-push/journey.spec.ts @@ -0,0 +1,386 @@ +/** + * concurrent-push — git push CONFLICT RESOLUTION, head-on. + * + * The legitimacy test for shared loops: two writers race the SAME branch + * (`master`) of the SAME origin (the fixture `roster1.git`). The loop is the + * SECOND writer, so its first push is REJECTED non-fast-forward and it must + * fetch + rebase + re-push to land its work on top of the other writer's. + * "Last one resolves." This does NOT avoid the conflict — it forces one and + * resolves it the standard git way. + * + * Flow: + * 1. Create a loop from roster1; open the terminal; wait container running + + * PreparingOverlay clear. + * 2. In the loop's UI terminal (xterm, fish): set git identity, commit a + * Z-BASE state and push it so loop + origin start in sync. + * 3. Advance origin from OUTSIDE the loop (podman exec into the fixture): clone + * roster1.git, commit Y on master, push it back. Origin now has Y the loop + * doesn't. + * 4. In the loop terminal: commit Z on the (now-stale) base and push → it is + * REJECTED non-fast-forward. We prove the rejection by STATE: the fixture + * origin's tip must STILL be Y (Z did NOT land yet). + * 5. In the loop terminal: fetch + rebase onto origin/master, push again → + * succeeds. + * 6. INTEGRATION TRUTH: roster1.git log --oneline shows BOTH Y AND Z — the + * conflict was resolved, last writer rebased on top, both landed. + * + * No chat message is ever sent → zero AI tokens. We drive the real UI terminal + * (xterm, fish shell — every command is fish-valid) and verify every outcome via + * integration truth (`podman exec ... git`), never by scraping the flaky xterm. + * + * The harness (dogfood/playwright.config.ts + setup.ts) already booted the real + * stack and preconfigured the `test` user ALREADY ONBOARDED with the idealab + * provider and roster repo roster1. We arrive logged in via storageState. + */ +import { test, expect, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** The fixture sshd container id, recorded by setup.ts in .test-meta.json. */ +function fixtureContainer(): string { + const meta = JSON.parse( + readFileSync(join(import.meta.dirname, "..", ".test-meta.json"), "utf8"), + ); + if (!meta.fixtureContainer) throw new Error("fixtureContainer missing from .test-meta.json"); + return meta.fixtureContainer as string; +} + +/** Run a shell command INSIDE the loop's sandbox container, return stdout (the + * container's own truth about the loop workdir's local git state). Best-effort: + * returns "" on error so it can be used purely for diagnostics. */ +function sandboxExec(loopId: string, cmd: string): string { + try { + const names = runningContainers(loopId); + if (names.length !== 1) return ""; + return execFileSync("podman", ["exec", names[0], "sh", "-lc", cmd]).toString(); + } catch (e) { + return `<exec error: ${e}>`; + } +} + +/** Names of RUNNING sandbox containers for this loop id (empty array = none). */ +function runningContainers(loopId: string): string[] { + return execFileSync("podman", [ + "ps", + "--filter", + `label=loopat.loop-id=${loopId}`, + "--filter", + "status=running", + "--format", + "{{.Names}}", + ]) + .toString() + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** `git log --oneline` of the fixture's bare roster1.git — the origin's TRUTH. + * The bare repo is owned by `git`; `podman exec` defaults to root, so git + * refuses with "dubious ownership" — `-c safe.directory=*` waives that (read + * only). */ +function fixtureRosterLog(): string { + return execFileSync("podman", [ + "exec", + fixtureContainer(), + "git", + "-c", + "safe.directory=*", + "-C", + "/srv/git/roster1.git", + "log", + "--oneline", + "master", + ]) + .toString() + .trim(); +} + +/** Subject of the CURRENT tip of roster1.git's master — origin's authoritative + * HEAD. If a push was rejected, this does NOT advance. */ +function fixtureRosterTipSubject(): string { + return execFileSync("podman", [ + "exec", + fixtureContainer(), + "git", + "-c", + "safe.directory=*", + "-C", + "/srv/git/roster1.git", + "log", + "-1", + "--format=%s", + "master", + ]) + .toString() + .trim(); +} + +/** Advance roster1.git's master from OUTSIDE the loop: clone the bare repo to a + * temp dir inside the fixture container, make commit `msg`, push it back. This + * is the OTHER writer — it has nothing to do with the loop's worktree. */ +function advanceOriginFromOutside(msg: string, fileLine: string): void { + // Single sh -c so the temp dir / identity stay in one shell. The bare repos + // are owned by `git` but exec runs as root → safe.directory=* for the read + // side, and we push over the local filesystem path (no ssh needed from inside + // the fixture). receive.denyCurrentBranch is irrelevant for a bare repo, so a + // normal fast-forward push from this fresh clone just works. + const script = [ + "set -e", + "export GIT_AUTHOR_NAME=outsider GIT_AUTHOR_EMAIL=outsider@local", + "export GIT_COMMITTER_NAME=outsider GIT_COMMITTER_EMAIL=outsider@local", + "export HOME=/root", + "git config --global --add safe.directory '*'", + "d=$(mktemp -d)", + "git clone -q /srv/git/roster1.git \"$d/clone\"", + "cd \"$d/clone\"", + `printf '%s\\n' '${fileLine}' >> outside.txt`, + "git add -A", + `git commit -qm '${msg}'`, + "git push -q origin HEAD:master", + "echo PUSHED_OUTSIDE", + ].join("\n"); + const out = execFileSync("podman", [ + "exec", + fixtureContainer(), + "sh", + "-c", + script, + ]).toString().trim(); + if (!out.includes("PUSHED_OUTSIDE")) { + throw new Error(`outside push did not confirm: ${out}`); + } +} + +/** Type a shell command into the focused xterm, run it, give it time to execute. + * Reading the xterm prompt back is flaky, so we pace with a fixed settle delay + * between commands instead of prompt-matching. */ +async function runInTerminal(page: Page, cmd: string, settleMs = 1_500): Promise<void> { + await page.keyboard.type(cmd); + await page.keyboard.press("Enter"); + await page.waitForTimeout(settleMs); +} + +/** Best-effort remove this loop's sandbox container so loops/containers don't + * accumulate in the shared LOOPAT_HOME across the serial suite. Never throws. */ +function cleanupLoopContainer(loopId: string): void { + if (!loopId) return; + try { + const ids = execFileSync("podman", [ + "ps", "-a", + "--filter", `label=loopat.loop-id=${loopId}`, + "--format", "{{.ID}}", + ]).toString().split("\n").map((s) => s.trim()).filter(Boolean); + if (ids.length) execFileSync("podman", ["rm", "-f", ...ids]); + } catch { + // best-effort teardown — never fail the suite on cleanup. + } +} + +// The loop this case created, so afterEach can reap its container. +let createdLoopId = ""; + +test.beforeEach(async ({ page }) => { + createdLoopId = ""; + // Bypass the "Setup Personal Repo" card for the (preconfigured) account. + await page.addInitScript(() => { + localStorage.setItem("loopat:setupPersonalRepoDismissed", "1"); + }); +}); + +test.afterEach(() => { + cleanupLoopContainer(createdLoopId); +}); + +test("concurrent push: loop's stale push is rejected non-ff, then fetch+rebase+re-push resolves and both commits land", async ({ page }) => { + const stamp = Date.now(); + const Y_MSG = `Y-OUTSIDE-${stamp}`; // the OTHER writer's commit (origin moves to this) + const Z_MSG = `Z-LOOP-${stamp}`; // the loop's commit (must rebase on top of Y) + + // ── Step 1: land on /loop, create a loop from roster1 through the real UI. ── + await page.goto("/loop"); + await expect( + page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first(), + ).toBeVisible({ timeout: 15_000 }); + + const loopTitle = `dogfood-concurrent-${stamp}`; + // Capture the create RESPONSE so we learn THIS loop's id authoritatively. The + // suite shares one LOOPAT_HOME and loops accumulate across cases, so reading + // the id from the browser URL can return a STALE loop's id; the create + // response is the only authoritative source. + const createResp = page.waitForResponse( + (resp) => resp.url().includes("/api/v1/loops") && resp.request().method() === "POST", + { timeout: 15_000 }, + ); + + await page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first().click(); + await expect(page.getByText("New loop", { exact: true })).toBeVisible({ timeout: 5_000 }); + await page.getByRole("combobox").first().selectOption("roster1"); + await page.getByPlaceholder("refactor-gateway").fill(loopTitle); + await page.getByRole("button", { name: "create", exact: true }).click(); + + const resp = await createResp; + const respBody = await resp.json(); + // v1 ids carry a `loop_` prefix; the loop URL uses the raw uuid. + const loopId = String(respBody.id ?? respBody.loop?.id ?? "").replace(/^loop_/, ""); + expect(loopId, `create response should carry the new loop id: ${JSON.stringify(respBody)}`).toMatch(/^[a-f0-9-]+$/); + createdLoopId = loopId; + + await expect(page).toHaveURL(new RegExp(`/loop/${loopId}`), { timeout: 15_000 }); + const sidebar = page.locator("aside").first(); + await expect(sidebar).toBeVisible({ timeout: 15_000 }); + await expect(sidebar.getByText(loopTitle)).toBeVisible({ timeout: 10_000 }); + expect(runningContainers(loopId), "no container before the terminal opens").toEqual([]); + + // ── Step 2: open the terminal → backend ensureContainer (worktree the workdir + // off roster1, start the sandbox). No chat turn → no AI tokens. + // Poll podman until the sandbox container is RUNNING. ── + await page.getByRole("button", { name: /terminal/ }).first().click(); + await expect + .poll(() => runningContainers(loopId), { + message: `expected a running podman container labelled loopat.loop-id=${loopId}`, + timeout: 240_000, + intervals: [1_000, 2_000, 5_000], + }) + .not.toEqual([]); + + // First use may still be building the per-loop image behind the + // PreparingOverlay (z-30 backdrop captures pointer events) — wait for it to + // clear before typing into the terminal. + const preparingOverlay = page.getByText("Preparing this loop’s sandbox…"); + await expect(preparingOverlay).toBeHidden({ timeout: 240_000 }); + + // Focus the xterm. The sandbox shell is fish — keep every command fish-valid + // (no `2>&1`, no `(subshell)`; use `; and` / `; or`). + const xterm = page.locator(".xterm-helper-textarea"); + await expect(xterm).toBeVisible({ timeout: 15_000 }); + await xterm.click(); + + // ── Step 2 (cont.): set identity, make the loop's Z-BASE commit, and push it + // so the loop and origin START IN SYNC. This is the honest starting + // point — the loop is NOT yet behind. ── + await runInTerminal(page, "git config user.email loop@local"); + await runInTerminal(page, "git config user.name loop"); + await runInTerminal(page, `echo zbase-${stamp} >> loop.txt`); + await runInTerminal(page, "git add -A"); + await runInTerminal(page, `git commit -m 'Z-BASE-${stamp}'`); + await runInTerminal(page, "git push origin HEAD:master", 6_000); + + // INTEGRATION TRUTH: the Z-BASE push reached origin → loop + origin in sync. + await expect + .poll(() => fixtureRosterTipSubject(), { + message: `expected the loop's Z-BASE push to reach origin (tip should be Z-BASE-${stamp})`, + timeout: 60_000, + intervals: [1_000, 2_000, 3_000], + }) + .toBe(`Z-BASE-${stamp}`); + console.log(`[dogfood] origin tip after loop Z-BASE push: ${fixtureRosterTipSubject()}`); + + // ── Step 3: advance origin from OUTSIDE the loop. The OTHER writer commits Y + // on master and pushes it. The loop's local master ref still points + // at Z-BASE, so the loop is now genuinely BEHIND. ── + advanceOriginFromOutside(Y_MSG, `outside-${stamp}`); + expect( + fixtureRosterTipSubject(), + "after the outside writer pushed Y, origin's tip must be Y", + ).toBe(Y_MSG); + const logAfterY = fixtureRosterLog(); + console.log(`[dogfood] origin log after OUTSIDE writer pushed Y:\n${logAfterY}`); + expect(logAfterY, "origin should contain the outside writer's commit Y").toContain(Y_MSG); + + // ── Step 4: in the loop terminal, commit Z on the (now-stale) base and push. + // The loop has NOT fetched, so its master is still at Z-BASE and the + // push is a NON-FAST-FORWARD → REJECTED. ── + await runInTerminal(page, `echo zwork-${stamp} >> loop.txt`); + await runInTerminal(page, "git add -A"); + await runInTerminal(page, `git commit -m '${Z_MSG}'`); + await runInTerminal(page, "git push origin HEAD:master", 6_000); + + // PROVE THE REJECTION BY STATE (not by scraping the xterm): give the rejected + // push time to round-trip, then assert origin's tip is STILL Y and that Z did + // NOT land. A *successful* push would have moved the tip to Z; a non-ff + // rejection leaves it at Y. We hold this assertion for several seconds to be + // sure Z never sneaks in. + await page.waitForTimeout(4_000); + for (let i = 0; i < 5; i++) { + expect( + fixtureRosterTipSubject(), + "after the loop's STALE push, origin's tip must STILL be Y — the non-ff push was rejected, Z did NOT land", + ).toBe(Y_MSG); + expect( + fixtureRosterLog(), + "the loop's commit Z must NOT be in origin yet — the first (stale) push was rejected", + ).not.toContain(Z_MSG); + await page.waitForTimeout(1_000); + } + console.log(`[dogfood] PROVEN rejected: origin tip still ${Y_MSG}, Z absent — first push was non-ff rejected`); + + // ── Step 5: resolve the ORDINARY git way — fetch, then rebase onto the + // `origin/master` remote-tracking ref, then push again. After the + // rebase the loop's Z sits on top of Y, so the push is a + // fast-forward and succeeds. + // The loop workdir is a NORMAL git worktree: its bare mirror pins + // the standard refspec (+refs/heads/master:refs/remotes/origin/master), + // so `git rebase origin/master` resolves a `refs/remotes/origin/master` + // tracking ref exactly like any plain clone — no `pull --rebase + // origin master` workaround needed. (Asserted below by State.) ── + await runInTerminal(page, "git fetch origin", 6_000); + await runInTerminal(page, "git rebase origin/master", 6_000); + await runInTerminal(page, "git push origin HEAD:master", 6_000); + + // ── ORDINARY-GIT ASSERTION: the sandbox workdir is a normal worktree — it has + // a real `refs/remotes/origin/master` tracking ref (the whole point of the + // standard refspec). `rev-parse --verify` succeeds inside the sandbox. ── + const wdEarly = `/loopat/loop/${loopId}/workdir`; + const originRefProbe = sandboxExec( + loopId, + `git -C ${wdEarly} rev-parse --verify refs/remotes/origin/master && echo ORIGIN_REF_OK`, + ); + console.log(`[dogfood] sandbox origin/master probe:\n${originRefProbe}`); + expect( + originRefProbe, + "sandbox workdir must carry an ordinary refs/remotes/origin/master tracking ref", + ).toContain("ORIGIN_REF_OK"); + const statusSb = sandboxExec(loopId, `git -C ${wdEarly} status -sb | head -1`); + console.log(`[dogfood] sandbox status -sb: ${statusSb}`); + + // DIAGNOSTIC: dump the loop workdir's local git state from the sandbox (truth, + // not the flaky xterm) so a resolution failure is debuggable. + const wd = `/loopat/loop/${loopId}/workdir`; + console.log(`[dogfood] sandbox local log:\n${sandboxExec(loopId, `git -C ${wd} log --oneline -5`)}`); + console.log(`[dogfood] sandbox rebase state: ${sandboxExec(loopId, `git -C ${wd} status -sb; ls ${wd}/.git/rebase-merge ${wd}/.git/rebase-apply 2>/dev/null; true`)}`); + + // ── Step 6: INTEGRATION TRUTH — origin now contains BOTH Y and Z. The conflict + // was resolved; the second writer rebased on top of the first and + // both landed. Poll for Z (the rebase+push may still be flushing). + await expect + .poll(() => fixtureRosterLog(), { + message: `expected origin to contain the loop's commit ${Z_MSG} after fetch+rebase+re-push`, + timeout: 60_000, + intervals: [1_000, 2_000, 3_000], + }) + .toContain(Z_MSG); + + const finalLog = fixtureRosterLog(); + console.log(`[dogfood] origin log AFTER fetch+rebase+re-push:\n${finalLog}`); + + // Both writers' commits are in origin's history. + expect(finalLog, "origin must contain the OUTSIDE writer's commit Y").toContain(Y_MSG); + expect(finalLog, "origin must contain the LOOP's commit Z").toContain(Z_MSG); + + // And the loop's Z is on TOP (rebased onto Y) — Z is the current tip, Y is + // strictly below it in history. The rebase put the last writer on top. + expect( + fixtureRosterTipSubject(), + "after resolution, origin's tip must be the loop's rebased Z (last writer on top)", + ).toBe(Z_MSG); + const lines = finalLog.split("\n"); + const zIdx = lines.findIndex((l) => l.includes(Z_MSG)); + const yIdx = lines.findIndex((l) => l.includes(Y_MSG)); + expect(zIdx, "Z must appear in the log").toBeGreaterThanOrEqual(0); + expect(yIdx, "Y must appear in the log").toBeGreaterThanOrEqual(0); + expect(zIdx, "Z (rebased) must sit above Y in history — last writer rebased on top").toBeLessThan(yIdx); + + console.log(`[dogfood] PROVEN resolved: non-ff conflict → fetch+rebase+re-push → both Y and Z in origin, Z on top`); +}); diff --git a/dogfood/context-notes-sync/README.md b/dogfood/context-notes-sync/README.md new file mode 100644 index 00000000..865fdd52 --- /dev/null +++ b/dogfood/context-notes-sync/README.md @@ -0,0 +1,87 @@ +# context-notes-sync — a loop syncs its notes context back to origin + +A highest-fidelity e2e journey (real browser → real backend → podman sshd git +fixture → real sandbox container). It exercises **context sync** — the +`docs/context-flow.md` model in the flesh: a loop edits its **notes** context +worktree and pushes back to the team notes origin. + +It spends **~no AI tokens** — there is no chat turn. The edit / commit / push is +driven through the loop's **terminal**, and the result is verified by +**integration truth** (`podman exec` into the fixture notes origin), never the +DOM. + +## What it regresses + +The per-loop `/loopat/context/notes` mount (`V_CONTEXT_NOTES`) is a **git +worktree** whose `origin` is the team notes repo. That repo is cloned **per +user** by the backend's `ensureUserContext`, with the **vault** ssh key — a +different credential/url path than the roster workdir: + +- roster repos clone from absolute `ssh://git@<ip>:<port>/…` urls; +- the **notes** url comes from the **knowledge** repo's `.loopat/config.json` + (seeded in `dogfood/setup.ts` → `seed.sh`) in the **Host-alias** form + `git@loopat-fixture:notes.git`. + +For context sync to work, that Host alias must resolve in **both** places: + +1. **host-side** — the clone in `ensureUserContext`, which runs git with + `GIT_SSH_COMMAND = ssh -F <vault>/.../.ssh/config -i <vault key>` + (`sshCommandForUser`), so the `loopat-fixture` alias (HostName/Port/Identity) + applies; and +2. **inside the sandbox** — the `git push` from the worktree, which authenticates + with the vault's `.ssh` (key + config) mounted at `$HOME/.ssh`. + +The bugs this catches: +- the per-user notes clone **failing** (wrong url, missing vault key, host-key + prompt) → `/loopat/context/notes` is an **empty dir** (see + `ensurePerUserContextWorktree`) instead of a worktree, and the push has no + origin to reach; +- the worktree's `origin` push **not reaching** the fixture notes.git (alias + doesn't resolve in the sandbox, or wrong branch). + +## Flow + +1. Create a loop from `roster1` through the real UI; open the terminal → + backend `ensureContainer`. On loop create, `ensureUserContext` has already + cloned the per-user notes repo (vault key) and `ensureContextMounts` + worktree'd it into `/loopat/context/notes`. Poll `podman` until the sandbox + container is RUNNING. +2. In the terminal, `cd /loopat/context/notes` and confirm it's a real git + worktree (`git rev-parse --is-inside-work-tree`, SOFT check via a sentinel). +3. Write a new file, `git add`, `git commit`, `git push origin HEAD:master`. +4. **Integration truth**: `podman exec <fixtureContainer> git -C + /srv/git/notes.git log --oneline --all` must show the new commit — it reached + the fixture notes origin. + +## Assertions + +Behavioral + integration truth, never screenshots: +- a sandbox container comes up for the loop (the per-user notes clone + worktree + succeeded, or the container wouldn't be usable); +- (soft) `/loopat/context/notes` reads as a git worktree, not an empty dir; +- the loop's notes commit **reaches the fixture notes.git** — the hard proof + the worktree's vault-key origin is wired end to end, host-side AND in-sandbox. + +## Findings + +- **No product bug surfaced for the per-user notes path.** The landmine we + feared — the per-user notes clone failing against the fixture — does **not** + reproduce: `ensureUserContext`/`sshCommandForUser` already pass + `-F <vault ssh config>`, so the `git@loopat-fixture:notes.git` Host alias + resolves host-side, and the same vault ssh config mounted at `$HOME/.ssh` + makes it resolve in the sandbox for the push. Both `cloned per-user context + git@loopat-fixture:notes.git` (setup) and the commit reaching `notes.git` + (assertion) are observed green. +- **Pre-existing, harmless gap (NOT this case's path):** the **workspace-default** + clone in `ensureWorkspaceDirs` uses the host's default ssh (no vault key) and + fails against the fixture (`Permission denied (publickey)`), then falls back to + a local origin. Loops never use the workspace-default context — they use the + per-user path — so this does not affect context sync. Left as-is (it's a + bootstrap display mirror only). + +## Run + +```sh +export IDEALAB_API_KEY=$(cat ~/.loopat/personal/simpx/.loopat/vaults/default/envs/IDEALAB_API_KEY) +bunx playwright test --config dogfood/playwright.config.ts dogfood/context-notes-sync/journey.spec.ts +``` diff --git a/dogfood/context-notes-sync/journey.spec.ts b/dogfood/context-notes-sync/journey.spec.ts new file mode 100644 index 00000000..70989724 --- /dev/null +++ b/dogfood/context-notes-sync/journey.spec.ts @@ -0,0 +1,241 @@ +/** + * context-notes-sync — a loop edits its NOTES context worktree and pushes back + * to the fixture notes origin (the docs/context-flow.md model in the flesh). + * + * Unlike first-5-minutes (which pushes the loop's WORKDIR off a roster repo), + * this exercises CONTEXT SYNC: the per-loop `/loopat/context/notes` mount is a + * git worktree whose `origin` is the team notes repo, cloned PER-USER with the + * VAULT key by the backend's `ensureUserContext` (the notes url comes from the + * knowledge repo's `.loopat/config.json` — set in seed.sh). The loop writes a + * file there, commits, and pushes; the push must reach the fixture notes.git. + * + * Why this is the high-value case: the per-user knowledge/notes clone path is a + * DIFFERENT credential/url path than the roster workdir. roster repos clone with + * absolute `ssh://git@<ip>:<port>/…` urls; the notes url in seed.sh is the Host- + * alias form `git@loopat-fixture:notes.git`, which must resolve BOTH host-side + * (the clone in ensureUserContext, via GIT_SSH_COMMAND `-F <vault config>`) AND + * inside the sandbox (the push, via the vault ssh config mounted at $HOME/.ssh). + * + * Truth is INTEGRATION, not the DOM: we drive the push from the terminal (no + * chat turn → ~no AI tokens) and verify the commit landed by reading the fixture + * notes.git log via `podman exec`. + * + * The harness (dogfood/playwright.config.ts + setup.ts) already booted the real + * stack and preconfigured the `test` user ALREADY ONBOARDED with the idealab + * provider, roster repo roster1, and a knowledge repo whose `.loopat/config.json` + * points notes at the fixture notes.git. We arrive logged in via storageState. + */ +import { test, expect, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** The fixture sshd container id, recorded by setup.ts in .test-meta.json. */ +function fixtureContainer(): string { + const meta = JSON.parse( + readFileSync(join(import.meta.dirname, "..", ".test-meta.json"), "utf8"), + ); + if (!meta.fixtureContainer) throw new Error("fixtureContainer missing from .test-meta.json"); + return meta.fixtureContainer as string; +} + +/** Names of RUNNING sandbox containers for this loop id (empty array = none). */ +function runningContainers(loopId: string): string[] { + return execFileSync("podman", [ + "ps", + "--filter", + `label=loopat.loop-id=${loopId}`, + "--filter", + "status=running", + "--format", + "{{.Names}}", + ]) + .toString() + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** `git log --oneline` of the fixture's bare notes.git — the origin's TRUTH. + * The bare repo is owned by `git`; `podman exec` runs as root, so git refuses + * with "dubious ownership" — `-c safe.directory=*` waives that (we only read). */ +function fixtureNotesLog(): string { + return execFileSync("podman", [ + "exec", + fixtureContainer(), + "git", + "-c", + "safe.directory=*", + "-C", + "/srv/git/notes.git", + "log", + "--oneline", + "--all", + ]) + .toString() + .trim(); +} + +/** Type a shell command into the focused xterm, run it, and give it time to + * execute. Reading the xterm prompt back is flaky, so we pace with a fixed + * settle delay between commands instead of prompt-matching. */ +async function runInTerminal(page: Page, cmd: string, settleMs = 1_500): Promise<void> { + await page.keyboard.type(cmd); + await page.keyboard.press("Enter"); + await page.waitForTimeout(settleMs); +} + +/** Best-effort remove this loop's sandbox container so loops/containers don't + * accumulate in the shared LOOPAT_HOME across the serial suite. Never throws. */ +function cleanupLoopContainer(loopId: string): void { + if (!loopId) return; + try { + const ids = execFileSync("podman", [ + "ps", "-a", + "--filter", `label=loopat.loop-id=${loopId}`, + "--format", "{{.ID}}", + ]).toString().split("\n").map((s) => s.trim()).filter(Boolean); + if (ids.length) execFileSync("podman", ["rm", "-f", ...ids]); + } catch { + // best-effort teardown — never fail the suite on cleanup. + } +} + +// The loop this case created, so afterEach can reap its container. +let createdLoopId = ""; + +test.beforeEach(async ({ page }) => { + createdLoopId = ""; + // Bypass the "Setup Personal Repo" card for the (preconfigured) account. + await page.addInitScript(() => { + localStorage.setItem("loopat:setupPersonalRepoDismissed", "1"); + }); +}); + +test.afterEach(() => { + cleanupLoopContainer(createdLoopId); +}); + +test("context sync: loop edits its notes worktree and pushes back to the fixture notes origin", async ({ page }) => { + // ── Step 1: land on /loop, logged in; create a loop from roster1. ── + await page.goto("/loop"); + await expect( + page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first(), + ).toBeVisible({ timeout: 15_000 }); + + const loopTitle = `dogfood-notes-${Date.now()}`; + // Capture the create RESPONSE so we learn THIS loop's id authoritatively. The + // suite shares one LOOPAT_HOME and loops accumulate across cases, so reading + // the id from the browser URL right after the click can return a STALE loop's + // id; the create response is the only authoritative source. + const createResp = page.waitForResponse( + (resp) => resp.url().includes("/api/v1/loops") && resp.request().method() === "POST", + { timeout: 15_000 }, + ); + + await page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first().click(); + await expect(page.getByText("New loop", { exact: true })).toBeVisible({ timeout: 5_000 }); + await page.getByRole("combobox").first().selectOption("roster1"); + await page.getByPlaceholder("refactor-gateway").fill(loopTitle); + await page.getByRole("button", { name: "create", exact: true }).click(); + + const resp = await createResp; + expect(resp.request().postDataJSON().title).toBe(loopTitle); + const respBody = await resp.json(); + // The v1 API ids carry a `loop_` prefix; the loop URL uses the raw uuid. + const loopId = String(respBody.id ?? respBody.loop?.id ?? "").replace(/^loop_/, ""); + expect(loopId, `create response should carry the new loop id: ${JSON.stringify(respBody)}`).toMatch(/^[a-f0-9-]+$/); + createdLoopId = loopId; + + await expect(page).toHaveURL(new RegExp(`/loop/${loopId}`), { timeout: 15_000 }); + + const sidebar = page.locator("aside").first(); + await expect(sidebar).toBeVisible({ timeout: 15_000 }); + await expect(sidebar.getByText(loopTitle)).toBeVisible({ timeout: 10_000 }); + expect(runningContainers(loopId), "no container before the terminal opens").toEqual([]); + + // ── Step 2: open the terminal → backend ensureContainer (and, on loop create, + // ensureUserContext already cloned the per-user notes repo with the + // vault key + ensureContextMounts worktree'd it into the loop). Wait + // until the sandbox container is RUNNING. If the per-user notes clone + // had failed, the worktree would be empty and the push below would + // fail — that's the gap this case is built to surface. ── + await page.getByRole("button", { name: /terminal/ }).first().click(); + await expect + .poll(() => runningContainers(loopId), { + message: `expected a running podman container labelled loopat.loop-id=${loopId}`, + timeout: 240_000, + intervals: [1_000, 2_000, 5_000], + }) + .not.toEqual([]); + + // First use may still be building the per-loop image — the PreparingOverlay + // captures pointer events until the terminal is usable. + const preparingOverlay = page.getByText("Preparing this loop’s sandbox…"); + await expect(preparingOverlay).toBeHidden({ timeout: 240_000 }); + + // ── Step 3: drive the terminal. The sandbox shell is fish (no `2>&1`, no + // `(subshell)`; use `; and` / `; or`). ── + const xterm = page.locator(".xterm-helper-textarea"); + await expect(xterm).toBeVisible({ timeout: 15_000 }); + await xterm.click(); + + const beforeLog = fixtureNotesLog(); + console.log(`[dogfood] fixture notes.git log BEFORE:\n${beforeLog}`); + + // Go to the notes context dir (the V_CONTEXT_NOTES mount) and confirm it is a + // REAL git worktree, not an empty dir (which is what an empty/failed per-user + // notes clone would leave — see ensurePerUserContextWorktree). SOFT check via + // a sentinel; the HARD proof is the push reaching origin below. + await runInTerminal(page, "cd /loopat/context/notes"); + await runInTerminal( + page, + 'git rev-parse --is-inside-work-tree > /dev/null 2>/dev/null; and echo DOGFOOD_NOTES_GIT_OK; or echo DOGFOOD_NOTES_GIT_FATAL', + ); + await expect(page.locator(".xterm-screen")).toBeVisible(); + let termText = ""; + for (let i = 0; i < 10; i++) { + termText = await page.locator(".xterm-screen").innerText().catch(() => ""); + if (termText.includes("DOGFOOD_NOTES_GIT")) break; + await page.waitForTimeout(1_000); + } + if (termText.includes("DOGFOOD_NOTES_GIT")) { + expect( + termText, + "/loopat/context/notes must be a real git worktree (per-user notes clone must have succeeded)", + ).not.toContain("DOGFOOD_NOTES_GIT_FATAL"); + expect(termText).toContain("DOGFOOD_NOTES_GIT_OK"); + console.log("[dogfood] notes worktree git OK (read from xterm)"); + } else { + console.log("[dogfood] terminal buffer unreadable (flaky xterm) — relying on push-to-origin as proof"); + } + + // ── Step 4: write a file into notes, add + commit + push to origin. The push + // SUCCEEDING is the hard proof the worktree's origin (the per-user + // notes clone, vault-key auth) is wired correctly. ── + const stamp = Date.now(); + const noteFile = `dogfood-note-${stamp}.md`; + const commitMsg = `dogfood notes sync ${stamp}`; + await runInTerminal(page, "git config user.email dogfood@local"); + await runInTerminal(page, "git config user.name dogfood"); + await runInTerminal(page, `echo notes-sync-${stamp} > ${noteFile}`); + await runInTerminal(page, "git add -A"); + await runInTerminal(page, `git commit -m '${commitMsg}'`); + // The worktree opens on branch loop/<id> from origin's default (master). Push + // it to master so it lands on the fixture's default branch. + await runInTerminal(page, "git push origin HEAD:master", 5_000); + + // ── INTEGRATION TRUTH: poll the fixture notes origin until the commit lands. + // Don't trust the terminal DOM for the push result. ── + await expect + .poll(() => fixtureNotesLog(), { + message: `expected the notes commit "${commitMsg}" to reach fixture notes.git`, + timeout: 60_000, + intervals: [1_000, 2_000, 3_000], + }) + .toContain(commitMsg); + + const afterLog = fixtureNotesLog(); + console.log(`[dogfood] fixture notes.git log AFTER push:\n${afterLog}`); + expect(afterLog, "the notes origin must have received the loop's commit").toContain(commitMsg); +}); diff --git a/dogfood/first-5-minutes/README.md b/dogfood/first-5-minutes/README.md new file mode 100644 index 00000000..54d26726 --- /dev/null +++ b/dogfood/first-5-minutes/README.md @@ -0,0 +1,82 @@ +# dogfood / first-5-minutes + +A single highest-fidelity Playwright e2e that simulates a real user's **first five +minutes** with loopat — end to end, no mocks. + +Where the other suite (`e2e/`) tests UI logic against a mocked/isolated backend, +this one boots a **real** stack and drives it through a real browser: + +``` +browser (Playwright) + │ storageState = already logged in as `test` + ▼ +backend (isolated LOOPAT_HOME, preconfigured ALREADY-ONBOARDED: + idealab provider + self-contained vault (fresh ssh key) + roster repo `roster1`) + │ + ▼ +podman ── real per-loop sandbox container + │ + ▼ +fixture sshd+git container (loopat-dogfood-sshd) ── real git origin +``` + +The whole point: integration bugs that L1–L3 logic tests cannot catch (workdir +gitdir paths, empty repos page, renamed ssh key, config-hash drift) only show up +when a **real sandbox + real config + real git** are wired together. If the stack +can't actually run, this test **fails — it never skips green**. + +See the design + plan: +- `docs/superpowers/specs/2026-06-02-critical-path-e2e-design.md` +- `docs/superpowers/plans/2026-06-02-dogfood-first-5-minutes.md` + +## What the harness sets up (before any test runs) + +Brought up by `dogfood/playwright.config.ts` + `dogfood/setup.ts`: + +1. **fixture sshd git server** — a podman container (`loopat-dogfood-sshd`) running + sshd + git, with bare repos `knowledge.git`, `notes.git`, `roster1.git`. The + per-run fresh pubkey (see #3) is seeded into its `authorized_keys`. +2. **isolated backend** — its own temp `LOOPAT_HOME`, `knowledge` + `gitHost` + pointing at the fixture sshd over an ssh `Host loopat-fixture` alias. +3. **already-onboarded user `test`** — personal config has the `idealab` provider + and a roster repo `roster1` (`git@loopat-fixture:roster1.git`). The vault is + **self-contained**: setup generates a FRESH `id_ed25519` keypair (never lifted + from any real vault — a real key in the repo reads as a leaked credential), + and the one secret a fixture can't fake — `IDEALAB_API_KEY` — is taken from + the environment (never read from disk, never committed). +4. **login state** — saved to `dogfood/.auth.json` and loaded via `storageState`, + so the spec opens already authenticated. + +## The journey (`journey.spec.ts`) + +This file covers Task 3 of the plan: **create a loop from a roster repo and +confirm its sandbox container actually comes up.** It does NOT send a chat +message (no AI key burned) — that is Task 4. + +| Step | Action (through the real UI) | Assertion | +|------|------------------------------|-----------| +| 1 | `goto /loop` (setup-repo card dismissed via localStorage) | the always-present **+ New Loop** button is visible (a fresh account has no loops yet, so no `aside` sidebar) | +| 2 | click `+ New Loop`, pick `roster1` in the **Repo** select, name the loop, click **create** | the create request hits `POST /api/v1/loops` with `repo: "roster1"`, and the browser navigates to `/loop/<id>` | +| 3 | the new loop is in the sidebar | the sidebar (`aside`) shows the loop's title; podman has **no** container for it yet | +| 4 | open the **▷ terminal** panel | opens `/ws/loop/<id>/term`, which makes the backend `ensureContainer`: git-worktree the workdir off the roster1 mirror (cloned over real ssh with the fresh vault key) + start the sandbox. No chat message → no AI tokens spent | +| 5 | poll podman directly until the loop's sandbox is up | a container labelled `loopat.loop-id=<id>` reaches state **running**. This is the integration truth — the badge/WS can race, podman cannot lie. If the ssh clone of roster1 had failed, the worktree (and thus the container) never come up and this poll times out — exactly the signal we want | + +Real container startup is slow (image pull on a cold cache + worktree). The test +timeout is generous (5 min, set in the config) and there are no retries (each run +is non-deterministic and may cost money once chat is added). The podman poll uses +a 4-minute budget within that. + +## Running it + +```sh +export IDEALAB_API_KEY=$(cat ~/.loopat/personal/simpx/.loopat/vaults/default/envs/IDEALAB_API_KEY) +bunx playwright test --config dogfood/playwright.config.ts +``` + +Preconditions enforced at config load (fail, never skip): +- `podman` must be installed. +- `IDEALAB_API_KEY` must be set in the environment (real AI needs a real key; + it is never read from disk and never committed). + +If those are missing the config throws immediately — a dogfood test that goes +green without running proves nothing. diff --git a/dogfood/first-5-minutes/fixtures/Containerfile b/dogfood/first-5-minutes/fixtures/Containerfile new file mode 100644 index 00000000..2cf6528c --- /dev/null +++ b/dogfood/first-5-minutes/fixtures/Containerfile @@ -0,0 +1,12 @@ +# fixture git server: sshd + git, bare repos under /srv/git. +# loop containers (host network) ssh in as user `git` with the vault id_ed25519. +FROM alpine:3.20 +RUN apk add --no-cache openssh git && \ + adduser -D -s /bin/sh git && passwd -d git && \ + mkdir -p /srv/git /home/git/.ssh && \ + chown -R git:git /srv/git /home/git/.ssh && chmod 700 /home/git/.ssh && \ + ssh-keygen -A +COPY seed.sh /seed.sh +RUN chmod +x /seed.sh +EXPOSE 22 +CMD ["/usr/sbin/sshd", "-D", "-e"] diff --git a/dogfood/first-5-minutes/fixtures/seed.sh b/dogfood/first-5-minutes/fixtures/seed.sh new file mode 100644 index 00000000..b8f8aeeb --- /dev/null +++ b/dogfood/first-5-minutes/fixtures/seed.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Run inside the fixture container after start: install the loop's public key, +# create bare repos, seed knowledge with .loopat/config.json. +# arg1 = loop pubkey (may be empty on a first-run seed). +# arg2 = absolute ssh base for the notes pointer, e.g. +# `ssh://git@<hostIp>:<sshdPort>`. The notes pointer lives in the +# knowledge repo's .loopat/config.json and is consumed by BOTH +# first-5-minutes and first-run; an env-specific Host alias resolves in +# only one vault config, so we write the env-agnostic ABSOLUTE url here. +# The host-side caller knows the published port; this script (running +# inside the container) does not, so it must be passed in. +set -e +PUBKEY="$1" +NOTES_SSH_BASE="$2" +if [ -z "$NOTES_SSH_BASE" ]; then + echo "seed.sh: missing arg2 (absolute ssh base for the notes pointer)" >&2 + exit 1 +fi +echo "$PUBKEY" > /home/git/.ssh/authorized_keys +chown git:git /home/git/.ssh/authorized_keys && chmod 600 /home/git/.ssh/authorized_keys + +export GIT_AUTHOR_NAME=fixture GIT_AUTHOR_EMAIL=fixture@local +export GIT_COMMITTER_NAME=fixture GIT_COMMITTER_EMAIL=fixture@local +for r in knowledge notes roster1 roster2; do + git init --bare -q "/srv/git/$r.git" + git -C "/srv/git/$r.git" config receive.denyCurrentBranch updateInstead +done +# seed knowledge with notes pointer; roster1 with a file +work=$(mktemp -d) +git clone -q /srv/git/knowledge.git "$work/k" +mkdir -p "$work/k/.loopat" +printf '{\n "notes": { "git": "%s/srv/git/notes.git" }\n}\n' "$NOTES_SSH_BASE" > "$work/k/.loopat/config.json" +git -C "$work/k" add -A && git -C "$work/k" commit -qm init && git -C "$work/k" push -q origin master +# notes needs at least one commit so `git ls-remote --exit-code HEAD` succeeds +# (an empty bare repo has no HEAD — the onboarding ssh-access probe checks HEAD). +git clone -q /srv/git/notes.git "$work/n" && echo "# notes" > "$work/n/README.md" +git -C "$work/n" add -A && git -C "$work/n" commit -qm init && git -C "$work/n" push -q origin master +git clone -q /srv/git/roster1.git "$work/r" && echo hello > "$work/r/README.md" +git -C "$work/r" add -A && git -C "$work/r" commit -qm init && git -C "$work/r" push -q origin master +git clone -q /srv/git/roster2.git "$work/r2" && echo hello2 > "$work/r2/README.md" +git -C "$work/r2" add -A && git -C "$work/r2" commit -qm init && git -C "$work/r2" push -q origin master +chown -R git:git /srv/git +# Make the bare repos reachable via a home-relative ssh url too. The clients +# use `git@host:<name>.git`, which over ssh resolves relative to git's HOME +# (/home/git), not /srv/git — without these links the clone fails with +# "does not appear to be a git repository". +for r in knowledge notes roster1 roster2; do + ln -sfn "/srv/git/$r.git" "/home/git/$r.git" +done +chown -h git:git /home/git/*.git +echo "seeded" diff --git a/dogfood/first-5-minutes/journey.spec.ts b/dogfood/first-5-minutes/journey.spec.ts new file mode 100644 index 00000000..1048a138 --- /dev/null +++ b/dogfood/first-5-minutes/journey.spec.ts @@ -0,0 +1,312 @@ +/** + * first-5-minutes — the WHOLE first five minutes, end to end: + * Task 3: create a loop from a roster repo through the real UI → sandbox + * container running. + * Task 4: type an instruction in the chat input, send it, and assert the + * REAL AI replies (a non-empty assistant message, no error event). + * Task 5: open the terminal panel, run `git status` (the regression: the + * workdir gitdir bug made this `fatal: not a git repository`), then + * make a change + add + commit + push, and verify — via INTEGRATION + * TRUTH (podman exec into the fixture origin), not the terminal DOM — + * that the commit actually reached `roster1.git`. + * + * The harness (dogfood/playwright.config.ts + setup.ts) has already booted the + * real stack and preconfigured the `test` user as ALREADY ONBOARDED with the + * `idealab` provider and a roster repo `roster1`. We arrive logged in via + * storageState. + * + * Why poll podman instead of the sidebar "Ready" badge: the badge is driven by + * the /ws/loop-status WebSocket, which races (the update can land before the + * sidebar subscribes, or after teardown). podman is the integration truth — it + * cannot lie about whether a container is up. The container is labelled + * `loopat.loop-id=<id>` (podman.ts) with the same id the loop URL carries. + */ +import { test, expect, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** The fixture sshd container id, recorded by setup.ts in .test-meta.json. */ +function fixtureContainer(): string { + const meta = JSON.parse( + readFileSync(join(import.meta.dirname, "..", ".test-meta.json"), "utf8"), + ); + if (!meta.fixtureContainer) throw new Error("fixtureContainer missing from .test-meta.json"); + return meta.fixtureContainer as string; +} + +/** Names of RUNNING sandbox containers for this loop id (empty array = none). */ +function runningContainers(loopId: string): string[] { + return execFileSync("podman", [ + "ps", + "--filter", + `label=loopat.loop-id=${loopId}`, + "--filter", + "status=running", + "--format", + "{{.Names}}", + ]) + .toString() + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** `git log --oneline` of the fixture's bare roster1.git — the origin's TRUTH. */ +function fixtureRosterLog(): string { + // The bare repo is owned by the `git` user; `podman exec` defaults to root, so + // git refuses with "dubious ownership". `-c safe.directory=*` waives that — we + // only read the log. + return execFileSync("podman", [ + "exec", + fixtureContainer(), + "git", + "-c", + "safe.directory=*", + "-C", + "/srv/git/roster1.git", + "log", + "--oneline", + ]) + .toString() + .trim(); +} + +/** Type a shell command into the focused xterm, run it, and give it time to + * execute. Reading the xterm prompt back is flaky, so we pace with a fixed + * settle delay between commands instead of prompt-matching. */ +async function runInTerminal(page: Page, cmd: string, settleMs = 1_500): Promise<void> { + await page.keyboard.type(cmd); + await page.keyboard.press("Enter"); + await page.waitForTimeout(settleMs); +} + +/** Best-effort remove this loop's sandbox container so loops/containers don't + * accumulate in the shared LOOPAT_HOME across the serial suite. Never throws. */ +function cleanupLoopContainer(loopId: string): void { + if (!loopId) return; + try { + const ids = execFileSync("podman", [ + "ps", "-a", + "--filter", `label=loopat.loop-id=${loopId}`, + "--format", "{{.ID}}", + ]).toString().split("\n").map((s) => s.trim()).filter(Boolean); + if (ids.length) execFileSync("podman", ["rm", "-f", ...ids]); + } catch { + // best-effort teardown — never fail the suite on cleanup. + } +} + +// The loop this case created, so afterEach can reap its container. +let createdLoopId = ""; + +test.beforeEach(async ({ page }) => { + createdLoopId = ""; + // Bypass the "Setup Personal Repo" card for the (preconfigured) account. + await page.addInitScript(() => { + localStorage.setItem("loopat:setupPersonalRepoDismissed", "1"); + }); +}); + +test.afterEach(() => { + cleanupLoopContainer(createdLoopId); +}); + +test("first 5 minutes: create loop → container running → AI replies → terminal git push reaches origin", async ({ page }) => { + // ── Step 1: land on /loop, logged in. With zero loops the page shows the + // empty state (no `aside` sidebar yet), so assert on the always- + // present "+ New Loop" button instead. ── + await page.goto("/loop"); + await expect( + page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first(), + ).toBeVisible({ timeout: 15_000 }); + + const loopTitle = `dogfood-${Date.now()}`; + + // Capture the create RESPONSE so we learn THIS loop's id authoritatively. The + // suite shares one LOOPAT_HOME and loops accumulate across cases, so reading + // the id from the browser URL right after the click can return a STALE loop's + // id; the create response is the only authoritative source. + const createResp = page.waitForResponse( + (resp) => resp.url().includes("/api/v1/loops") && resp.request().method() === "POST", + { timeout: 15_000 }, + ); + + // ── Step 2: open NewLoopDialog, pick the roster1 repo, create. ── + await page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first().click(); + await expect(page.getByText("New loop", { exact: true })).toBeVisible({ timeout: 5_000 }); + + // The Repo <select> is the first combobox; its option values are repo names. + await page.getByRole("combobox").first().selectOption("roster1"); + await page.getByPlaceholder("refactor-gateway").fill(loopTitle); + await page.getByRole("button", { name: "create", exact: true }).click(); + + const resp = await createResp; + const body = resp.request().postDataJSON(); + expect(body.title).toBe(loopTitle); + expect(body.repo).toBe("roster1"); + const respBody = await resp.json(); + // The v1 API ids carry a `loop_` prefix; the loop URL uses the raw uuid. + const loopId = String(respBody.id ?? respBody.loop?.id ?? "").replace(/^loop_/, ""); + expect(loopId, `create response should carry the new loop id: ${JSON.stringify(respBody)}`).toMatch(/^[a-f0-9-]+$/); + createdLoopId = loopId; + + // ── Step 3: navigated to the new loop's page; the sidebar now lists it. ── + await expect(page).toHaveURL(new RegExp(`/loop/${loopId}`), { timeout: 15_000 }); + + const sidebar = page.locator("aside"); + await expect(sidebar).toBeVisible({ timeout: 15_000 }); + await expect(sidebar.getByText(loopTitle)).toBeVisible({ timeout: 10_000 }); + + // No container exists for this brand-new loop yet — nothing has touched it. + expect(runningContainers(loopId), "no container should exist before the terminal opens").toEqual([]); + + // ── Step 4: open the terminal panel. This opens the /ws/loop/:id/term socket, + // which makes the backend `ensureContainer` for the loop's sandbox + // — git-worktree the workdir off the roster1 mirror (cloned over + // real ssh with the fresh vault key) and start the container. No + // chat message is sent, so no AI tokens are spent. ── + await page.getByRole("button", { name: /terminal/ }).first().click(); + + // ── Step 5: wait until podman actually has a RUNNING container for this loop. + // Real startup (image pull on a cold cache + worktree) is slow → + // generous timeout. If the ssh clone of roster1 had failed (bad key + // perms / missing authorized_keys / wrong Host alias), the workdir + // worktree would fail and the container would never come up — this + // poll would time out, which is exactly the signal we want. ── + await expect + .poll(() => runningContainers(loopId), { + message: `expected a running podman container labelled loopat.loop-id=${loopId}`, + timeout: 240_000, + intervals: [1_000, 2_000, 5_000], + }) + .not.toEqual([]); + + // ── Step 6: the sandbox container is up, but on first use the backend may + // still be building the per-loop image (mise toolchain install). + // While that runs, LoopPage shows the PreparingOverlay — a z-30 + // backdrop that captures pointer events so you can't type into a + // not-yet-ready terminal or fire a chat turn that just queues. + // Wait for it to clear before driving chat/terminal. ── + const preparingOverlay = page.getByText("Preparing this loop’s sandbox…"); + await expect(preparingOverlay).toBeHidden({ timeout: 240_000 }); + + // ── Task 4: send a real instruction in the chat input and assert the REAL AI + // replies. This is the step that spends idealab tokens. The AI is + // non-deterministic, so we assert BEHAVIOR, not words: a non-empty + // assistant message appears and NO error event surfaces. + // (A backend error event renders as an assistant message prefixed + // with "⚠️" — see useLoopRuntime.tsx — so we assert no message + // contains that.) ── + const composer = page.getByRole("textbox", { name: "Message input" }); + await expect(composer).toBeVisible({ timeout: 15_000 }); + await composer.click(); + await composer.fill( + "Reply with one short line that includes the word ACKNOWLEDGED. Do not run any commands and do not touch git — just reply.", + ); + await page.getByRole("button", { name: "Send message" }).click(); + + // The assistant turn streams in. Wait for an assistant message with actual + // text content. Real AI + a tool-running turn can take a while → generous. + const assistantMessages = page.locator('[data-role="assistant"]'); + await expect + .poll( + async () => { + const n = await assistantMessages.count(); + for (let i = 0; i < n; i++) { + const t = (await assistantMessages.nth(i).innerText()).trim(); + if (t.length > 0) return t; + } + return ""; + }, + { + message: "expected a non-empty assistant reply from the real AI", + timeout: 180_000, + intervals: [1_000, 2_000, 5_000], + }, + ) + .not.toBe(""); + + // No error event anywhere in the transcript. + const reply = (await assistantMessages.last().innerText()).trim(); + const allAssistantText = (await assistantMessages.allInnerTexts()).join("\n"); + expect(allAssistantText, "no error event should appear in the chat").not.toContain("⚠️"); + console.log(`[dogfood] assistant reply (first 200 chars): ${reply.slice(0, 200)}`); + + // ── Task 5: terminal git — THE regression. The terminal panel is already + // open (Step 4). The shell lands in the loop's workdir, which is a + // git worktree off the roster1 mirror. The shipped bug made + // `git status` there return "fatal: not a git repository"; assert + // it does NOT. xterm output is flaky to read, so this is a SOFT + // check; the push is verified host-side via podman exec. ── + const beforeLog = fixtureRosterLog(); + console.log(`[dogfood] fixture roster1.git log BEFORE terminal:\n${beforeLog}`); + + const xterm = page.locator(".xterm-helper-textarea"); + await expect(xterm).toBeVisible({ timeout: 15_000 }); + await xterm.click(); + // The sandbox shell is fish, not bash — keep every command fish-valid + // (no `2>&1`, no `(subshell)`; use `; and` / `; or`). + + // `git status` with a sentinel that survives the flaky xterm read. Reading the + // xterm buffer is best-effort (the WebGL renderer makes innerText unreliable), + // so this is a SOFT check: if we CAN read it, it must show OK, never FATAL. + // The hard proof that the workdir is a real git repo is the push below + // reaching the origin — `git push` simply cannot succeed from a non-repo. + await runInTerminal( + page, + 'git status > /dev/null 2>/dev/null; and echo DOGFOOD_GIT_OK; or echo DOGFOOD_GIT_FATAL', + ); + // Give the shell a moment, then try to read the rendered buffer. + await expect(page.locator(".xterm-screen")).toBeVisible(); + let termText = ""; + for (let i = 0; i < 10; i++) { + termText = await page.locator(".xterm-screen").innerText().catch(() => ""); + if (termText.includes("DOGFOOD_GIT")) break; + await page.waitForTimeout(1_000); + } + if (termText.includes("DOGFOOD_GIT")) { + expect( + termText, + "git status in the workdir must NOT be `fatal: not a git repository` (the shipped bug)", + ).not.toContain("DOGFOOD_GIT_FATAL"); + expect(termText).toContain("DOGFOOD_GIT_OK"); + console.log("[dogfood] terminal git status: DOGFOOD_GIT_OK (read from xterm)"); + } else { + console.log("[dogfood] terminal buffer unreadable (flaky xterm) — relying on push-to-origin as proof of a working workdir git"); + } + + // Make a change + add + commit + push from the terminal. If the AI in Task 4 + // already committed+pushed, this adds an independent terminal commit on top — + // either way we prove the workdir git works AND the push reaches the origin. + // Configure identity inline (the sandbox may not have a global one). The push + // SUCCEEDING is the hard proof the workdir is a real git repository. + const stamp = Date.now(); + const commitMsg = `dogfood terminal commit ${stamp}`; + await runInTerminal(page, "git config user.email dogfood@local"); + await runInTerminal(page, "git config user.name dogfood"); + await runInTerminal(page, `echo terminal-${stamp} >> dogfood-terminal.txt`); + await runInTerminal(page, "git add -A"); + await runInTerminal(page, `git commit -m '${commitMsg}'`); + // No retry mask: the loop's vault key is bind-mounted into $HOME/.ssh at + // container-create time with deterministic 0600/0700 perms (podman.ts + // ensureSandboxSshPerms), so the FIRST ssh from the sandbox authenticates + // reliably. (The old retry guarded a flake that, root-caused, was the + // workspace-default boot clone's host-ssh "Permission denied (publickey)" + // log noise + a since-removed AI/terminal git race — not the loop push.) + await runInTerminal(page, "git push origin HEAD:master", 6_000); + + // ── INTEGRATION TRUTH: poll the fixture origin until the new commit lands. + // Don't trust the terminal DOM for the push result. ── + await expect + .poll(() => fixtureRosterLog(), { + message: `expected the terminal commit "${commitMsg}" to reach fixture roster1.git`, + timeout: 60_000, + intervals: [1_000, 2_000, 3_000], + }) + .toContain(commitMsg); + + const afterLog = fixtureRosterLog(); + console.log(`[dogfood] fixture roster1.git log AFTER push:\n${afterLog}`); + expect(afterLog, "the origin must have received the terminal commit").toContain(commitMsg); +}); diff --git a/dogfood/first-run/fixtures/fixture-provider.ts b/dogfood/first-run/fixtures/fixture-provider.ts new file mode 100644 index 00000000..6d0e46c4 --- /dev/null +++ b/dogfood/first-run/fixtures/fixture-provider.ts @@ -0,0 +1,244 @@ +/** + * Fixture git-host provider — a 1:1-shaped mirror of the real internal + * git-host provider (../../code.ts), but operating against the dogfood FIXTURE + * sshd container instead of any real platform. Dropped into + * LOOPAT_HOME/extensions/providers/ by the first-run setup, so loopat loads it + * as the active provider and runs the SAME onboarding gate code.ts drives. + * + * Duck-typed GitHostProvider. It does NOT import loopat and contains NO internal + * hostnames / endpoints / keys — everything it needs comes from the environment: + * + * FIXTURE_CONTAINER podman container id of the fixture sshd (for `podman exec` + * git-init / authorized_keys ops — the fixture's "platform API") + * FIXTURE_GIT_HOST host:port reachable for ssh git urls (e.g. 10.0.0.1:22003) + * FIXTURE_TOKEN the (fake) token the onboarding UI submits; authenticate + * validates the submitted token equals this marker + * FIXTURE_LOGIN the login authenticate returns (the user's name on the host) + * FIXTURE_AI_BASE_URL the AI provider base url seeded into config.json (env ref; + * the real value is never committed) + * + * gitAuthMode "ssh-deploy-key": loopat generates the host deploy key, this + * provider registers it on the fixture (appends to authorized_keys), and the + * personal repo is cloned/pushed over ssh — exactly the GitHub path. The vault's + * own id_ed25519 (generated in seedDefaults) is what reaches the team repos + * (knowledge / notes); the test seeds THAT pubkey into authorized_keys in step 7. + */ +const env = (k: string): string => { + const v = process.env[k] + if (!v || !v.trim()) throw new Error(`fixture-provider: missing env ${k}`) + return v.trim() +} + +// podman exec into the fixture sshd container — the fixture's "platform API". +// Uses execFile (no shell at the podman layer); the inner `sh -c` strings are +// built only from fixture-internal constants, never user input. +async function podmanExec(args: string[]): Promise<string> { + const cp = await import("node:child_process") + const { promisify } = await import("node:util") + const run = promisify(cp.execFile) + const { stdout } = await run("podman", ["exec", env("FIXTURE_CONTAINER"), ...args], { timeout: 30000 }) + return stdout.toString() +} + +// ssh git url for a bare repo on the fixture (absolute host:port — no internal +// hostname; the host comes from env). +const repoUrl = (name: string) => `ssh://git@${env("FIXTURE_GIT_HOST")}/srv/git/${name}.git` + +export default { + id: "fixture", + label: "Fixture (dogfood)", + tokenHelp: "Paste the fixture token (any value the test provides).", + gitAuthMode: "ssh-deploy-key" as const, + + // ── (1) authenticate — validate the submitted token against the env marker. ── + async authenticate(cred: any) { + if ((cred?.token ?? "").trim() !== env("FIXTURE_TOKEN")) { + throw new Error("fixture auth failed: bad token") + } + return { login: env("FIXTURE_LOGIN"), email: env("FIXTURE_LOGIN") + "@fixture.local" } + }, + + // ── listRepos — EMPTY on a first run (no personal repo yet). ── + async listRepos(_cred: any) { + let out = "" + try { out = await podmanExec(["sh", "-c", "ls /srv/git 2>/dev/null || true"]) } catch { return [] } + const personal = out + .split(/\s+/) + .map((s) => s.trim()) + .filter((s) => s.endsWith(".git")) + .map((s) => s.replace(/\.git$/, "")) + .filter((n) => !["knowledge", "notes", "roster1", "roster2"].includes(n)) + return personal.map((name) => ({ name, path: env("FIXTURE_LOGIN") + "/" + name })) + }, + + // ── (2) ensureRepo — git init --bare the personal repo on the fixture. ── + async ensureRepo(_cred: any, name: string, _opts: any) { + const exists = (await podmanExec(["sh", "-c", "test -d /srv/git/" + name + ".git && echo yes || echo no"])).trim() + if (exists === "yes") return { url: repoUrl(name), created: false } + await podmanExec(["sh", "-c", + "git init --bare -q /srv/git/" + name + ".git && " + + "git -C /srv/git/" + name + ".git config receive.denyCurrentBranch updateInstead && " + + "chown -R git:git /srv/git/" + name + ".git && " + + "ln -sfn /srv/git/" + name + ".git /home/git/" + name + ".git && chown -h git:git /home/git/" + name + ".git", + ]) + return { url: repoUrl(name), created: true } + }, + + // ── (3) registerDeployKey — append the deploy PUBLIC key to authorized_keys so + // loopat can clone/push the personal repo over ssh. ── + async registerDeployKey(_cred: any, _repo: any, _title: string, pubkey: string, _readOnly: boolean) { + const key = pubkey.trim().replace(/'/g, "") + await podmanExec(["sh", "-c", + "grep -qxF '" + key + "' /home/git/.ssh/authorized_keys 2>/dev/null || " + + "echo '" + key + "' >> /home/git/.ssh/authorized_keys; " + + "chown git:git /home/git/.ssh/authorized_keys && chmod 600 /home/git/.ssh/authorized_keys", + ]) + }, + + // grantAccess — no-op on the fixture (single `git` account owns every repo). + async grantAccess(_cred: any, _repo: any, _user: string, _level: string) {}, + + // ── seedDefaults — bake team defaults into the fresh personal repo (mirrors + // code.ts): config.json (AI provider, env-ref apiKey; knowledge pointer) + // + a vault id_ed25519 (git-crypt encrypted). ── + async seedDefaults(ctx: any) { + const fs = await import("node:fs/promises") + const path = await import("node:path") + const cp = await import("node:child_process") + const { promisify } = await import("node:util") + const run = promisify(cp.execFile) + + // 1. config.json — PLAINTEXT in git, so apiKey is an env-var ref; the real + // value lands in the encrypted vault env (filled by the AI-key onboarding + // step). baseUrl comes from env (no internal endpoint committed). + const config = { + providers: { + default: "idealab", + idealab: { + model: "claude-opus-4-7", + baseUrl: env("FIXTURE_AI_BASE_URL"), + apiKey: "${IDEALAB_API_KEY}", + maxContextTokens: 1000000, + models: [{ id: "claude-opus-4-7", enabled: true }], + enabled: true, + }, + }, + knowledge: { git: repoUrl("knowledge") }, + repos: [ + { name: "roster1", git: repoUrl("roster1") }, + { name: "roster2", git: repoUrl("roster2") }, + ], + } + await fs.mkdir(path.join(ctx.repoDir, ".loopat"), { recursive: true }) + await fs.writeFile( + path.join(ctx.repoDir, ".loopat", "config.json"), + JSON.stringify(config, null, 2) + "\n", + ) + + // 2. vault ssh keypair — STANDARD name id_ed25519 so ssh auto-resolves it. + // Lives under the vault, so git-crypt encrypts it. This is the key that + // reaches the team repos; its pubkey is seeded into authorized_keys by the + // test in step 7 (mirrors "add the key on the platform"). + const sshDir = path.join(ctx.vaultDir, "mounts", "home", ".ssh") + await fs.mkdir(sshDir, { recursive: true }) + const keyPath = path.join(sshDir, "id_ed25519") + await run("ssh-keygen", ["-t", "ed25519", "-N", "", "-q", "-C", "loopat:" + ctx.login, "-f", keyPath]) + + // 3. ssh config — accept new host keys non-interactively (no host-key prompt). + await fs.writeFile( + path.join(sshDir, "config"), + ["Host *", " StrictHostKeyChecking accept-new", ""].join("\n"), + ) + }, + + // ── onboarding(ctx) — 1:1 with code.ts: 3 gated steps. ── + async onboarding(ctx: any) { + // 1) personal repo missing → send the user to the real setup page. + if (!ctx.personalRepoImported) { + return { + done: false, + show: { + kind: "route", + path: "/settings/personal-repo", + title: "Set up your personal repo", + description: + "loopat doesn't store your data — set up a personal repo first; your key / ssh / memory live encrypted inside it. We'll continue automatically once it's done.", + }, + } + } + + // 2) no usable AI provider key yet → ask for one (stored in the vault). + const has = (k: string) => typeof ctx.vaultEnvs?.[k] === "string" && ctx.vaultEnvs[k].trim().length > 0 + const providers = (ctx.config?.providers ?? {}) as Record<string, any> + const hasProviderKey = Object.values(providers).some( + (p) => p && typeof p.apiKey === "string" && p.apiKey.trim().length > 0, + ) + if (!hasProviderKey && !has("IDEALAB_API_KEY")) { + return { + done: false, + show: { + kind: "form", + title: "Set your AI API key", + description: "Stored in your own encrypted vault, never on the server.", + submitLabel: "Save", + require: "any", + fields: [ + { name: "IDEALAB_API_KEY", label: "IdeaLab API Key", type: "password", action: "vault-env" }, + ], + }, + } + } + + // 3) team-repo ssh access: the vault key must reach knowledge + notes. Probe + // with `git ls-remote`; cache success in a marker so we only pay once. + if (ctx.repoDir) { + const fs = await import("node:fs/promises") + const path = await import("node:path") + const cp = await import("node:child_process") + const { promisify } = await import("node:util") + const run = promisify(cp.execFile) + + const sshDir = path.join(ctx.repoDir, ".loopat", "vaults", "default", "mounts", "home", ".ssh") + const keyPath = path.join(sshDir, "id_ed25519") + const configPath = path.join(sshDir, "config") + const marker = path.join(ctx.repoDir, ".loopat", ".team-access-ok") + const haveMarker = await fs.stat(marker).then(() => true).catch(() => false) + const haveKey = await fs.stat(keyPath).then(() => true).catch(() => false) + + if (!haveMarker && haveKey) { + await fs.chmod(keyPath, 0o600).catch(() => {}) + const sshCmd = + "ssh -F " + configPath + " -i " + keyPath + + " -o IdentitiesOnly=yes -o IdentityAgent=none" + + " -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null" + const teamRepos = [repoUrl("knowledge"), repoUrl("notes")] + const canAccess = async (url: string) => { + try { + await run("git", ["ls-remote", "--exit-code", url, "HEAD"], { + env: { ...process.env, GIT_SSH_COMMAND: sshCmd }, timeout: 25000, + }) + return true + } catch { return false } + } + const ok = (await Promise.all(teamRepos.map(canAccess))).every(Boolean) + if (ok) { + await fs.writeFile(marker, "ok\n").catch(() => {}) + } else { + const pub = (await fs.readFile(keyPath + ".pub", "utf8").catch(() => "")).trim() + return { + done: false, + show: { + kind: "info", + title: "Authorize access to the team repos (knowledge / notes)", + description: + "Your SSH key can't reach the knowledge / notes repos yet. Add the public key below to the platform's SSH Keys, then click re-check.", + values: pub ? [{ label: "Your SSH public key (id.pub)", value: pub }] : [], + }, + } + } + } + } + + return { done: true } + }, +} diff --git a/dogfood/first-run/journey.spec.ts b/dogfood/first-run/journey.spec.ts new file mode 100644 index 00000000..6a87015e --- /dev/null +++ b/dogfood/first-run/journey.spec.ts @@ -0,0 +1,480 @@ +/** + * first-run — the REAL first-time-user cold-start journey, end to end. + * + * This supersedes the old "preset onboarded + storageState" first-5-minutes: it + * starts from a TRULY EMPTY LOOPAT_HOME with the FIXTURE git-host provider as the + * active provider, and drives the entire onboarding through the real browser. No + * shortcuts — every step is a real action with integration-truth assertions + * (podman / git / disk), not screenshots. + * + * 11 steps (per docs/superpowers/specs/2026-06-03-first-run-journey-redesign.md): + * 1. empty LOOPAT_HOME (setup) — no user, no onboarded preset, no storageState. + * 2. register through the UI. + * 3. login (auto for the first/admin user) → lands on the onboarding gate. + * 4. onboarding GATE blocks: can't reach context; loop-create returns 403. + * 5. configure the personal repo via the real PersonalRepoPanel: token → + * list repos (EMPTY) → create → provider ensureRepo + registerDeployKey + + * seedDefaults → git-crypt auto-init → back up the git-crypt key. + * 6. still gated: the vault ssh key isn't on the "platform" yet → onboarding + * shows the "add your pubkey" info step. + * 7. TEST SEED: copy the vault id_ed25519.pub into the fixture authorized_keys + * (simulates the user adding the key on the platform's SSH Keys page). + * 8. re-check → onboarding done; enter context → background clone → see the + * knowledge repo content. + * 9. create a loop → sandbox container reaches RUNNING. + * 10. AI COMPLETES: one chat turn tells the AI to create AI_DONE.txt, commit + * it ('ai done'), and push to origin. INTEGRATION TRUTH: the fixture + * roster1.git origin log shows the 'ai done' commit (spends one idealab + * turn). + * 11. HUMAN COMPLETES: THEN in the real UI terminal (xterm, fish) the user + * makes a separate change, commits, and `git push`es to origin. + * INTEGRATION TRUTH: the fixture origin log also shows the human commit. + * + * Doctrine: origin is the source of truth → "done" means PUSHED to origin. The + * two completions run in SEQUENCE on the same workdir — the AI turn fully + * finishes before the human acts — so there is no push race; both land in the + * fixture roster1.git origin, proving both "AI done" and "human done" are real. + */ +import { test, expect, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +type Meta = { + loopatHome: string; + testServerPort: number; + vitePort: number; + sshdPort: number; + fixtureContainer: string; + hostIp: string; + fixtureToken: string; +}; + +function meta(): Meta { + return JSON.parse(readFileSync(join(import.meta.dirname, ".test-meta.json"), "utf8")) as Meta; +} + +const TEST_USER = "test"; +const TEST_PASSWORD = "test123"; + +/** The registered user's id — the single dir under personal/. */ +function testUserId(loopatHome: string): string { + const dirs = readdirSync(join(loopatHome, "personal"), { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + if (dirs.length !== 1) throw new Error(`expected one personal/<user> dir, got: ${dirs.join(", ")}`); + return dirs[0]; +} + +/** Append a pubkey to the fixture's authorized_keys (step 7 — "add it on the + * platform"). Uses `podman exec -i` so stdin reaches `cat` (without -i the + * pipe is closed and nothing gets appended); the fixture is the test's own + * resource. */ +function seedPubKeyOntoFixture(container: string, pubkey: string): void { + execFileSync("podman", [ + "exec", "-i", container, "sh", "-c", + "cat >> /home/git/.ssh/authorized_keys && " + + "chown git:git /home/git/.ssh/authorized_keys && chmod 600 /home/git/.ssh/authorized_keys", + ], { input: pubkey + "\n" }); +} + +/** Type a shell command into the focused xterm, run it, and give it time to + * execute. Reading the xterm prompt back is flaky, so we pace with a fixed + * settle delay between commands instead of prompt-matching (the same pattern + * first-5-minutes uses to drive the real terminal). */ +async function runInTerminal(page: Page, cmd: string, settleMs = 1_500): Promise<void> { + await page.keyboard.type(cmd); + await page.keyboard.press("Enter"); + await page.waitForTimeout(settleMs); +} + +function runningContainers(loopId: string): string[] { + return execFileSync("podman", [ + "ps", "--filter", `label=loopat.loop-id=${loopId}`, + "--filter", "status=running", "--format", "{{.Names}}", + ]).toString().split("\n").map((s) => s.trim()).filter(Boolean); +} + +function sandboxContainer(loopId: string): string { + const names = runningContainers(loopId); + if (names.length !== 1) throw new Error(`expected one running container for ${loopId}, got: ${names.join(", ")}`); + return names[0]; +} + +function sandboxExec(loopId: string, cmd: string): string { + return execFileSync("podman", ["exec", sandboxContainer(loopId), "sh", "-lc", cmd]).toString(); +} + +/** `git log --all --oneline` of the fixture's bare roster1.git — the origin's + * TRUTH. `--all` so a commit pushed to ANY ref (HEAD:master or otherwise) + * shows up. The bare repo is owned by the `git` user and podman exec defaults + * to root, so `-c safe.directory=*` waives git's "dubious ownership" refusal + * (read-only log). */ +function fixtureRosterLog(container: string): string { + return execFileSync("podman", [ + "exec", container, + "git", "-c", "safe.directory=*", + "-C", "/srv/git/roster1.git", + "log", "--all", "--oneline", + ]).toString().trim(); +} + +function cleanupLoopContainer(loopId: string): void { + if (!loopId) return; + try { + const ids = execFileSync("podman", [ + "ps", "-a", "--filter", `label=loopat.loop-id=${loopId}`, "--format", "{{.ID}}", + ]).toString().split("\n").map((s) => s.trim()).filter(Boolean); + if (ids.length) execFileSync("podman", ["rm", "-f", ...ids]); + } catch {} +} + +let createdLoopId = ""; +test.afterAll(() => cleanupLoopContainer(createdLoopId)); + +// One serial test — the whole journey is a single ordered story over one stack. +test("first-run: empty install → register → onboard (personal repo + git-crypt + ssh) → context → loop → AI → terminal", async ({ page }) => { + test.setTimeout(420_000); + const m = meta(); + const { loopatHome, fixtureContainer, fixtureToken } = m; + + // ════ Step 1: empty LOOPAT_HOME — assert no user exists yet. ════ + expect(existsSync(join(loopatHome, "personal")), "personal/ must not exist on a true first run").toBeFalsy(); + + await page.goto("/"); + // The login/register page is the whole screen — no chrome, no tabs. The "Register" + // tab and the submit button share the label, so scope each precisely. + const form = page.locator("form"); + await expect(form).toBeVisible({ timeout: 20_000 }); + + // ════ Step 2: register through the UI. ════ + // Switch to the Register tab (the tab button lives OUTSIDE the form). + await page.locator("button", { hasText: /^Register$/ }).first().click(); + await page.getByPlaceholder("simpx").fill(TEST_USER); + await page.locator('input[type="password"]').fill(TEST_PASSWORD); + // The first account ever created bootstraps admin/active → auto session, no + // pending notice. Capture the register response to confirm. + const regResp = page.waitForResponse( + (r) => r.url().includes("/api/auth/register") && r.request().method() === "POST", + { timeout: 20_000 }, + ); + await form.getByRole("button", { name: /^Register$/ }).click(); + const reg = await (await regResp).json(); + expect(reg.user, `register should return a user: ${JSON.stringify(reg)}`).toBeTruthy(); + expect(reg.user.status, "first user must bootstrap active").toBe("active"); + console.log(`[first-run] registered ${reg.user.id} (${reg.user.role}/${reg.user.status})`); + + // ════ Step 3: logged in → onboarding gate. The gate's first remediation is a + // "route" to /settings/personal-repo, so the Shell navigates us there. ════ + await expect(page).toHaveURL(/\/settings\/personal-repo/, { timeout: 20_000 }); + console.log("[first-run] onboarding gate routed to personal-repo setup"); + + // ════ Step 4: the gate BLOCKS. Prove (a) loop-create is 403 and (b) we can't + // reach context (the gate redirects back to the personal-repo route). ════ + const apiBase = `http://127.0.0.1:${m.vitePort}`; + const cookies = (await page.context().cookies()).map((c) => `${c.name}=${c.value}`).join("; "); + const createBlocked = await page.request.post(`${apiBase}/api/v1/loops`, { + headers: { cookie: cookies, "content-type": "application/json" }, + data: { title: "should-be-blocked", repo: "roster1" }, + }); + expect(createBlocked.status(), "loop-create must be 403 while onboarding is incomplete").toBe(403); + const blockedBody = await createBlocked.json(); + expect(JSON.stringify(blockedBody)).toContain("onboarding"); + console.log("[first-run] gate confirmed: loop-create 403 (onboarding incomplete)"); + + // Try to navigate to context — the gate sends us back to the personal-repo route. + await page.goto("/context/knowledge"); + await expect(page).toHaveURL(/\/settings\/personal-repo/, { timeout: 15_000 }); + console.log("[first-run] gate confirmed: context redirects back to onboarding"); + + // ════ Step 5: configure the personal repo via the real PersonalRepoPanel. ════ + // Wizard step 1 — paste the fixture token, click Next → lists repos (EMPTY). + const tokenInput = page.getByPlaceholder(/personal access \/ private token/i); + await expect(tokenInput).toBeVisible({ timeout: 15_000 }); + await tokenInput.fill(fixtureToken); + + const reposResp = page.waitForResponse( + (r) => r.url().includes("/api/personal/repos") && r.request().method() === "POST", + { timeout: 20_000 }, + ); + await page.getByRole("button", { name: /^Next$/ }).click(); + const reposBody = await (await reposResp).json(); + expect(reposBody.ok, `listRepos should succeed with a valid token: ${JSON.stringify(reposBody)}`).toBeTruthy(); + expect(reposBody.repos, "first-run repo list must be EMPTY").toEqual([]); + console.log("[first-run] personal repo picker: empty (first run) — will create one"); + + // Wizard step 2 — empty list shows the "type a name" path; default name is + // prefilled (loopat-personal). Advance to confirm. + await expect(page.getByText(/no existing repos found/i)).toBeVisible({ timeout: 10_000 }); + await page.getByRole("button", { name: /^Next$/ }).click(); + + // Wizard step 3 — confirm "Create & set up": provider ensureRepo + + // registerDeployKey + clone + git-crypt init + seedDefaults + push. + const githubResp = page.waitForResponse( + (r) => r.url().includes("/api/personal/github") && r.request().method() === "POST", + { timeout: 120_000 }, + ); + await page.getByRole("button", { name: /Create & set up/i }).click(); + const ghBody = await (await githubResp).json(); + expect(ghBody.ok, `personal repo setup should succeed: ${JSON.stringify(ghBody)}`).toBeTruthy(); + expect(ghBody.autoInitialized, "a fresh repo must auto-init git-crypt").toBeTruthy(); + expect(ghBody.cryptKey, "auto-init must return a git-crypt key to back up").toBeTruthy(); + console.log(`[first-run] personal repo created + git-crypt'd (repo: ${ghBody.repo}, created: ${ghBody.created})`); + + // INTEGRATION TRUTH: the personal repo is now a real bare repo on the fixture, + // and on disk the vault holds an encrypted id_ed25519 + a config.json. + const personalCfgPath = join(loopatHome, "personal", testUserId(loopatHome), ".loopat", "config.json"); + await expect.poll(() => existsSync(personalCfgPath), { timeout: 15_000 }).toBeTruthy(); + const personalCfg = JSON.parse(readFileSync(personalCfgPath, "utf8")); + expect(personalCfg.providers?.idealab, "seedDefaults must seed the idealab provider").toBeTruthy(); + expect(personalCfg.knowledge?.git, "seedDefaults must seed the knowledge pointer").toContain("knowledge.git"); + expect(personalCfg.providers.idealab.apiKey, "apiKey must be an env-var ref, not a real key").toBe("${IDEALAB_API_KEY}"); + console.log("[first-run] integration-truth: personal config.json seeded (env-ref apiKey, knowledge pointer)"); + + // Back up the git-crypt key → acknowledge → Done. + await expect(page.getByText(/back up your git-crypt key/i)).toBeVisible({ timeout: 15_000 }); + await page.getByRole("checkbox").check(); + await page.getByRole("button", { name: /^Done$/ }).click(); + + // ════ Step 6: still gated — vault ssh key isn't on the platform yet → the + // onboarding info step asks to add the pubkey. (Personal repo imported, AI + // key seeded as env-ref? No — apiKey is an env REF; the vault env isn't set + // yet, so first the AI-key form may appear. Drive whatever the gate shows + // next until we reach the ssh-access info step, then beyond.) ════ + // After the route remediation clears (imported=true), the Shell re-checks + // onboarding. Next gate is the AI key form (vault env empty). Fill it. + // OnboardingForm uses the field label as the input placeholder (no htmlFor), + // so the accessible name is the placeholder text. + await expect(page.getByText("Set your AI API key")).toBeVisible({ timeout: 30_000 }); + const aiKeyField = page.getByPlaceholder("IdeaLab API Key"); + await expect(aiKeyField).toBeVisible({ timeout: 30_000 }); + // Use the same env key the harness exported — the test process has it. + const idealabKey = process.env.IDEALAB_API_KEY!; + expect(idealabKey, "IDEALAB_API_KEY must be in the test env").toBeTruthy(); + await aiKeyField.fill(idealabKey); + const submitOb = page.waitForResponse( + (r) => r.url().includes("/api/onboarding/submit") && r.request().method() === "POST", + { timeout: 60_000 }, + ); + await page.getByRole("button", { name: /^Save$/ }).click(); + await submitOb; + console.log("[first-run] AI key saved to vault"); + + // Now the ssh-access info step: the vault key can't reach knowledge/notes yet. + await expect(page.getByText(/Authorize access to the team repos/i)).toBeVisible({ timeout: 30_000 }); + console.log("[first-run] gate confirmed: ssh-access info step shown (vault key not on platform)"); + + // ════ Step 7: TEST SEED — "add the key on the platform". This mirrors a real + // user COPYING the ssh public key shown on the onboarding info step and + // PASTING it on the platform's SSH Keys page. So we read the key straight + // off the rendered page (the <code> in OnboardingInfo that renders + // show.values[].value), not from the /api/onboarding JSON. + // + // Robustness: the displayed key can in principle race a vault re-sync that + // rewrites the working-tree key after the info step first renders (the + // reason the original read it from the API). So we (a) read the page value + // only after the info step is stable, and (b) cross-check it against the + // probe's own key from /api/onboarding — they MUST match, which guarantees + // the key we seed is exactly the one the provider's ls-remote probe uses. ════ + // The pubkey is the <code> next to the "Your SSH public key" label in the + // OnboardingInfo card. Wait for it to render a complete ssh-ed25519 line. + const pubCode = page.locator("code", { hasText: /^ssh-ed25519 / }); + await expect(pubCode).toBeVisible({ timeout: 30_000 }); + await expect.poll(async () => (await pubCode.innerText()).trim(), { + message: "the info step must render a full ssh-ed25519 pubkey", + timeout: 15_000, intervals: [500, 1000], + }).toMatch(/^ssh-ed25519 \S+/); + const pub = (await pubCode.innerText()).trim(); + expect(pub, "the rendered pubkey must be a complete ssh-ed25519 line").toMatch(/^ssh-ed25519 \S+/); + + // Cross-check the displayed key against the probe's key (guards a vault re-sync + // race): the key the user sees on the page is exactly the one the probe uses. + const obInfo = await (await page.request.get(`${apiBase}/api/onboarding`, { headers: { cookie: cookies } })).json(); + expect(obInfo.done, "should still be gated at the ssh-access info step").toBe(false); + expect(obInfo.show?.kind, "the gate should be on the info step").toBe("info"); + const probePub = (obInfo.show.values?.[0]?.value ?? "").trim(); + expect(pub, "the page-displayed pubkey must match the probe's key (no vault-sync drift)").toBe(probePub); + + seedPubKeyOntoFixture(fixtureContainer, pub); + console.log(`[first-run] seeded UI-displayed vault pubkey onto fixture: ${pub.slice(0, 40)}…`); + + // ════ Step 8: re-check → onboarding done → enter context → knowledge content. ════ + await page.getByRole("button", { name: /重新检查|re-?check/i }).click(); + // The gate clears; the Shell renders the normal app. Navigate to context. + await expect.poll(async () => { + const ob = await (await page.request.get(`${apiBase}/api/onboarding`, { headers: { cookie: cookies } })).json(); + return ob.done === true; + }, { message: "onboarding must report done after the pubkey is added", timeout: 60_000, intervals: [1000, 2000, 3000] }).toBeTruthy(); + console.log("[first-run] onboarding DONE"); + + // The gate cleared — context is now reachable (no redirect back to onboarding). + await page.goto("/context/knowledge"); + await expect(page).toHaveURL(/\/context\/knowledge/, { timeout: 20_000 }); + await expect(page.getByText(/Set up your personal repo|Authorize access to the team repos/i)) + .toHaveCount(0, { timeout: 10_000 }); + console.log("[first-run] gate cleared: context page reachable"); + + // ════ Step 9: create a loop → loopat clones the context (knowledge) on demand + // with the now-authorized vault key, then the sandbox container runs. ════ + await page.goto("/loop"); + await expect(page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first()).toBeVisible({ timeout: 20_000 }); + const loopTitle = `firstrun-${Date.now()}`; + const createResp = page.waitForResponse( + (r) => r.url().includes("/api/v1/loops") && r.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first().click(); + await expect(page.getByText("New loop", { exact: true })).toBeVisible({ timeout: 10_000 }); + await page.getByRole("combobox").first().selectOption("roster1"); + await page.getByPlaceholder("refactor-gateway").fill(loopTitle); + await page.getByRole("button", { name: "create", exact: true }).click(); + const resp = await createResp; + expect(resp.status(), "loop-create must now succeed (gate cleared)").toBeLessThan(300); + const respBody = await resp.json(); + const loopId = String(respBody.id ?? respBody.loop?.id ?? "").replace(/^loop_/, ""); + expect(loopId, `create response should carry a loop id: ${JSON.stringify(respBody)}`).toMatch(/^[a-f0-9-]+$/); + createdLoopId = loopId; + await expect(page).toHaveURL(new RegExp(`/loop/${loopId}`), { timeout: 20_000 }); + console.log(`[first-run] loop created: ${loopId.slice(0, 8)}`); + + // INTEGRATION TRUTH (step 8 payoff): loop creation ran ensureUserContext, which + // cloned the knowledge repo with the now-authorized vault key. Assert it landed + // on disk — proof the ssh-pubkey seed actually granted team-repo access. + const userCtxDir = join(loopatHome, "context", "users", testUserId(loopatHome)); + const knowledgeDir = join(userCtxDir, "knowledge"); + await expect.poll(() => existsSync(join(knowledgeDir, ".git")), { + message: "knowledge repo must clone (vault key authorized) and land on disk after loop create", + timeout: 60_000, intervals: [1000, 2000, 3000], + }).toBeTruthy(); + console.log("[first-run] integration-truth: knowledge repo cloned on disk (vault key authorized)"); + + // INTEGRATION TRUTH: notes must clone too. ensureUserContext reads the notes + // pointer from the knowledge repo's .loopat/config.json (now an env-agnostic + // absolute ssh url — see seed.sh) and clones it with the same vault key. Until + // the url-scheme fix, the notes pointer was a Host alias this vault's ssh + // config never defined, so the notes clone silently failed; now it must land + // on disk AND carry the seeded content (README.md from seed.sh's initial + // commit) — proof notes is as real as knowledge. + const notesDir = join(userCtxDir, "notes"); + await expect.poll(() => existsSync(join(notesDir, ".git")), { + message: "notes repo must clone (env-agnostic absolute ssh url, vault key authorized) and land on disk", + timeout: 60_000, intervals: [1000, 2000, 3000], + }).toBeTruthy(); + expect(existsSync(join(notesDir, "README.md")), "the notes working tree must carry the seeded README.md").toBeTruthy(); + console.log("[first-run] integration-truth: notes repo cloned on disk with seeded content (README.md)"); + + // Open terminal → ensureContainer → poll until RUNNING. + await page.getByRole("button", { name: /terminal/ }).first().click(); + await expect.poll(() => runningContainers(loopId), { + message: `expected a running container labelled loopat.loop-id=${loopId}`, + timeout: 300_000, intervals: [1000, 2000, 5000], + }).not.toEqual([]); + console.log("[first-run] loop sandbox container RUNNING"); + + // Wait for the per-loop image build overlay to clear before chatting. + const preparing = page.getByText("Preparing this loop’s sandbox…"); + await expect(preparing).toBeHidden({ timeout: 300_000 }); + + // ════ Step 10: AI COMPLETES — one chat turn does a small, DETERMINISTIC task + // AND pushes it to origin. "Done" = pushed to origin (doctrine). The task + // wording is non-deterministic but the COMMIT SUBJECT is fixed ('ai done'), + // so we assert integration truth on the subject, not on AI prose. ════ + const beforeAiLog = fixtureRosterLog(fixtureContainer); + console.log(`[first-run] fixture roster1.git log BEFORE AI turn:\n${beforeAiLog}`); + + const composer = page.getByRole("textbox", { name: "Message input" }); + await expect(composer).toBeVisible({ timeout: 20_000 }); + await composer.click(); + await composer.fill( + "In the current working directory, create a file named AI_DONE.txt containing exactly the word DONE. " + + "Then stage it, commit with the exact message 'ai done', and push it to origin with: git push origin HEAD:master . " + + "Report when the push succeeds.", + ); + await page.getByRole("button", { name: "Send message" }).click(); + + // Wait for the AI turn to finish: a non-empty assistant reply, no error event. + const assistantMessages = page.locator('[data-role="assistant"]'); + await expect.poll(async () => { + const n = await assistantMessages.count(); + for (let i = 0; i < n; i++) { + const t = (await assistantMessages.nth(i).innerText()).trim(); + if (t.length > 0) return t; + } + return ""; + }, { message: "expected a non-empty assistant reply from the real AI", timeout: 240_000, intervals: [2000, 3000, 5000] }).not.toBe(""); + const allText = (await assistantMessages.allInnerTexts()).join("\n"); + expect(allText, "no error event should appear in the chat").not.toContain("⚠️"); + console.log(`[first-run] AI replied: ${(await assistantMessages.last().innerText()).trim().slice(0, 160)}`); + + // INTEGRATION TRUTH: the AI's 'ai done' commit reached the fixture origin. + // The AI turn is slow + the push lands a moment after the reply renders, so + // poll the origin log with a generous budget. + await expect.poll(() => fixtureRosterLog(fixtureContainer), { + message: "expected the AI's 'ai done' commit to reach fixture roster1.git origin", + timeout: 120_000, intervals: [2000, 3000, 5000], + }).toContain("ai done"); + console.log("[first-run] integration-truth: AI commit 'ai done' reached origin (AI is DONE)"); + + // ════ Step 11: HUMAN COMPLETES — THEN, in the REAL UI terminal (xterm, fish), + // the user makes a SEPARATE change, commits, and pushes to origin. The AI + // turn above has fully finished, so the human acts on the same workdir + // with no push race. ORDINARY git now works (the worktree has a real + // origin tracking ref), and we push the loop branch to origin's default + // branch (HEAD:master) like any contributor. INTEGRATION TRUTH: the + // fixture origin log also carries the human commit. ════ + const xterm = page.locator(".xterm-helper-textarea"); + await expect(xterm).toBeVisible({ timeout: 20_000 }); + await xterm.click(); + + // Soft-check that the workdir git works (sentinel survives the flaky xterm + // read); the HARD proof is the push reaching origin below — `git push` cannot + // succeed from a non-repo. The sandbox shell is fish: `; and` / `; or`. + await runInTerminal( + page, + "git status > /dev/null 2>/dev/null; and echo FIRSTRUN_GIT_OK; or echo FIRSTRUN_GIT_FATAL", + ); + await expect(page.locator(".xterm-screen")).toBeVisible(); + let termText = ""; + for (let i = 0; i < 10; i++) { + termText = await page.locator(".xterm-screen").innerText().catch(() => ""); + if (termText.includes("FIRSTRUN_GIT")) break; + await page.waitForTimeout(1_000); + } + if (termText.includes("FIRSTRUN_GIT")) { + expect(termText, "git status typed in the UI terminal must NOT be `fatal: not a git repository`") + .not.toContain("FIRSTRUN_GIT_FATAL"); + expect(termText).toContain("FIRSTRUN_GIT_OK"); + console.log("[first-run] terminal git status: FIRSTRUN_GIT_OK (read from xterm)"); + } else { + console.log("[first-run] xterm buffer unreadable (flaky) — relying on push-to-origin as proof of a working workdir git"); + } + + // The human makes a separate change, commits, and pushes — through the real + // terminal UI. The deterministic commit subject is our integration-truth probe. + const stamp = Date.now(); + const humanMsg = `human done ${stamp}`; + await runInTerminal(page, "git config user.email human@local"); + await runInTerminal(page, "git config user.name human"); + await runInTerminal(page, `echo human-${stamp} >> HUMAN_DONE.txt`); + await runInTerminal(page, "git add -A"); + await runInTerminal(page, `git commit -m '${humanMsg}'`); + // ORDINARY git: the worktree tracks origin/<default>, so we push the loop + // branch to origin's default branch like any contributor would. + await runInTerminal(page, "git push origin HEAD:master", 6_000); + + // INTEGRATION TRUTH: the human commit reached the fixture origin. Don't trust + // the xterm DOM for the push result — poll the origin log. + await expect.poll(() => fixtureRosterLog(fixtureContainer), { + message: `expected the human commit "${humanMsg}" to reach fixture roster1.git origin`, + timeout: 60_000, intervals: [1000, 2000, 3000], + }).toContain(humanMsg); + console.log(`[first-run] integration-truth: human commit "${humanMsg}" reached origin (human is DONE)`); + + // Final proof: BOTH completions are in the origin log together. + const afterLog = fixtureRosterLog(fixtureContainer); + console.log(`[first-run] fixture roster1.git log AFTER both pushes:\n${afterLog}`); + expect(afterLog, "origin must carry the AI commit").toContain("ai done"); + expect(afterLog, "origin must carry the human commit").toContain(humanMsg); + + console.log("[first-run] PROVEN: full cold-start journey green — AI-push + human-push both reached origin"); +}); diff --git a/dogfood/first-run/playwright.config.ts b/dogfood/first-run/playwright.config.ts new file mode 100644 index 00000000..eae2bec0 --- /dev/null +++ b/dogfood/first-run/playwright.config.ts @@ -0,0 +1,113 @@ +/** + * dogfood/first-run — the REAL first-time-user cold-start journey. + * + * Unlike the other dogfood cases (which preset an ALREADY-ONBOARDED user + + * storageState to skip login), this one boots a TRULY EMPTY LOOPAT_HOME and + * drives the whole first-run flow through the browser: register -> login -> + * onboarding gate -> personal-repo setup (git-crypt) -> seed the ssh pubkey onto + * the "platform" -> context populates -> loop -> AI -> terminal. + * + * The active git-host provider is the FIXTURE provider + * (first-run/fixtures/fixture-provider.ts), installed into + * LOOPAT_HOME/extensions/providers/ by setup.ts. It mirrors the real internal + * provider but operates the fixture sshd, with every endpoint/key from env. + * + * Preconditions are FAIL-not-skip: podman, git-crypt, IDEALAB_API_KEY, and the + * AI base url env var (FIRST_RUN_AI_BASE_URL) must all be present. + * + * Ports + temp LOOPAT_HOME are decided here (config-load time) and recorded in + * first-run/.test-meta.json; the fixture container + backend come up in setup.ts. + */ +import { defineConfig } from "@playwright/test"; +import { mkdtempSync, writeFileSync, readFileSync, renameSync } from "node:fs"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { createServer } from "node:net"; +import { execSync } from "node:child_process"; + +const META = join(import.meta.dirname, ".test-meta.json"); + +function requireCmd(cmd: string, hint: string): void { + try { + execSync(cmd, { stdio: "ignore" }); + } catch { + throw new Error(`[dogfood:first-run] ${hint}`); + } +} + +function requireEnv(name: string, hint: string): void { + if (!process.env[name] || !process.env[name]!.trim()) { + throw new Error(`[dogfood:first-run] ${name} not set — ${hint}`); + } +} + +function tryPort(port: number): boolean { + try { + const s = createServer(); + s.listen(port, "127.0.0.1"); + s.close(); + return true; + } catch { + return false; + } +} + +function pickPorts(): { testServerPort: number; vitePort: number; sshdPort: number } { + // 23001+ range — away from the other dogfood config (22001) and e2e (20001). + for (let p = 23001; p < 24000; p += 3) { + if (tryPort(p) && tryPort(p + 1) && tryPort(p + 2)) { + return { testServerPort: p, vitePort: p + 1, sshdPort: p + 2 }; + } + } + throw new Error("no free port triple found in 23001-24000"); +} + +requireCmd("podman --version", "podman not found — this test runs a real sshd container and must not be skipped"); +requireCmd("git-crypt --version", "git-crypt not found — first-run uses REAL git-crypt; install it"); +requireEnv("IDEALAB_API_KEY", "export it before running (real AI needs a real key; we never read it from disk)"); +requireEnv( + "FIRST_RUN_AI_BASE_URL", + "set the AI provider base url (the fixture provider seeds it as config.json baseUrl; never bake an internal endpoint into committed files)", +); + +// Workers reload this config; only the main process picks ports + writes META. +const isWorker = process.env.TEST_WORKER_INDEX !== undefined; + +let testServerPort = 0; +let vitePort = 0; +let sshdPort = 0; +let loopatHome = ""; + +if (isWorker) { + const m = JSON.parse(readFileSync(META, "utf8")); + ({ testServerPort, vitePort, sshdPort, loopatHome } = m); +} else { + ({ testServerPort, vitePort, sshdPort } = pickPorts()); + // basename -> server WORKSPACE -> podman image tag; lowercase only. + const raw = mkdtempSync(join(tmpdir(), "loopat-firstrun-")); + const lower = join(tmpdir(), basename(raw).toLowerCase()); + if (lower !== raw) renameSync(raw, lower); + loopatHome = lower; + writeFileSync(META, JSON.stringify({ loopatHome, testServerPort, vitePort, sshdPort })); +} + +export default defineConfig({ + testDir: import.meta.dirname, + timeout: 420_000, + retries: 0, + workers: 1, + globalSetup: "./setup.ts", + globalTeardown: "./teardown.ts", + use: { + baseURL: `http://127.0.0.1:${vitePort}`, + trace: "on-first-retry", + screenshot: "only-on-failure", + // NO storageState — this case does its OWN register + login through the UI. + }, + webServer: { + command: + `env ENV=test HOST=127.0.0.1 PORT=${testServerPort} bun --cwd=${join(import.meta.dirname, "..", "..", "web")} run dev -- --port ${vitePort}`, + port: vitePort, + reuseExistingServer: false, + }, +}); diff --git a/dogfood/first-run/setup.ts b/dogfood/first-run/setup.ts new file mode 100644 index 00000000..39b179c6 --- /dev/null +++ b/dogfood/first-run/setup.ts @@ -0,0 +1,142 @@ +/** + * dogfood/first-run global setup — boot the real stack as a TRULY FRESH install. + * + * Unlike the other dogfood setup (which presets an onboarded user + vault + + * storageState), this one does as little as possible so the BROWSER drives the + * whole first-run flow: + * 0. Build + run the fixture sshd container (host port picked in the config). + * 1. Seed the fixture's bare repos (knowledge/notes/roster1/roster2) with an + * EMPTY authorized_keys — no key can reach them yet. The personal-repo flow + * will append the host deploy key itself; the vault key is added in step 7. + * 2. Write a MINIMAL workspace config.json (no knowledge/gitHost — the fixture + * provider + the user's seeded personal config own all of that) and install + * the fixture git-host provider into LOOPAT_HOME/extensions/providers/. + * 3. Spawn the backend on the empty LOOPAT_HOME with the FIXTURE_* env the + * provider reads. NO user, NO vault, NO storageState. + * + * Everything else (register, login, onboarding, personal repo, ssh-pubkey seed, + * loop, chat) happens in journey.spec.ts through the real browser. + */ +import { spawn, execSync, execFileSync } from "node:child_process"; +import { + readFileSync, writeFileSync, mkdirSync, copyFileSync, realpathSync, +} from "node:fs"; +import { join } from "node:path"; + +const META = join(import.meta.dirname, ".test-meta.json"); +const FIXTURE_IMAGE = "loopat-firstrun-sshd:latest"; +// Reuse the shared fixture image build context (same sshd + seed.sh). +const FIXTURE_DIR = join(import.meta.dirname, "..", "first-5-minutes", "fixtures"); +const PROVIDER_SRC = join(import.meta.dirname, "fixtures", "fixture-provider.ts"); + +// The fixture token the onboarding UI will submit + the login it maps to. +const FIXTURE_TOKEN = "fixture-token-abc123"; +const FIXTURE_LOGIN = "test"; + +async function waitFor(url: string, timeoutMs = 60_000): Promise<void> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const r = await fetch(url); + if (r.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`timed out waiting for ${url}`); +} + +type Meta = { + loopatHome: string; + testServerPort: number; + vitePort: number; + sshdPort: number; +}; + +async function globalSetup() { + const meta = JSON.parse(readFileSync(META, "utf8")) as Meta; + const { loopatHome, testServerPort, vitePort, sshdPort } = meta; + + console.log(`[first-run:setup] LOOPAT_HOME = ${loopatHome} (EMPTY — true first run)`); + console.log(`[first-run:setup] backend :${testServerPort} vite :${vitePort} sshd :${sshdPort}`); + + // ── 0. build + run the fixture sshd container ── + const hostIp = execSync("ip route get 1.1.1.1") + .toString() + .match(/src\s+(\d+\.\d+\.\d+\.\d+)/)?.[1]; + if (!hostIp) throw new Error("[first-run:setup] could not determine host default-route IP for the fixture sshd"); + const fixtureHostPort = `${hostIp}:${sshdPort}`; + console.log(`[first-run:setup] building ${FIXTURE_IMAGE} from ${FIXTURE_DIR}`); + execSync(`podman build -t ${FIXTURE_IMAGE} ${FIXTURE_DIR}`, { stdio: "inherit" }); + const fixtureContainer = execFileSync( + "podman", + ["run", "-d", "-p", `0.0.0.0:${sshdPort}:22`, FIXTURE_IMAGE], + ).toString().trim(); + console.log(`[first-run:setup] fixture sshd up: ${fixtureContainer.slice(0, 12)} on ${fixtureHostPort}`); + writeFileSync(META, JSON.stringify({ ...meta, fixtureContainer, hostIp })); + + // ── 1. seed the fixture repos with an EMPTY authorized_keys ── + // No key reaches the fixture yet. registerDeployKey (personal repo) and the + // step-7 vault-pubkey seed (team repos) populate it through the real flow. + // arg1 (pubkey) is empty — no key reaches the fixture yet. arg2 is the + // absolute ssh base for the notes pointer seed.sh writes into the knowledge + // repo's config.json, so notes resolves env-agnostically (no Host alias, + // which this vault's seedDefaults ssh config does not define). + const notesSshBase = `ssh://git@${hostIp}:${sshdPort}`; + const seedOut = execFileSync("podman", ["exec", fixtureContainer, "/seed.sh", "", notesSshBase]).toString().trim(); + console.log(`[first-run:setup] fixture seed (empty authorized_keys): ${seedOut}`); + + // ── 2. minimal workspace config + install the fixture provider ── + // No knowledge / gitHost block: the fixture provider IS the active provider + // (extensions win outright), and the user's seeded personal config carries the + // knowledge pointer. We keep config.json minimal/empty. + mkdirSync(loopatHome, { recursive: true }); + writeFileSync(join(loopatHome, "config.json"), JSON.stringify({}, null, 2) + "\n"); + + const provDir = join(loopatHome, "extensions", "providers"); + mkdirSync(provDir, { recursive: true }); + copyFileSync(PROVIDER_SRC, join(provDir, "fixture.ts")); + console.log(`[first-run:setup] installed fixture provider -> ${join(provDir, "fixture.ts")}`); + + // ── 3. start the backend on the empty LOOPAT_HOME ── + try { execSync(`fuser -k ${testServerPort}/tcp 2>/dev/null || true`, { stdio: "ignore" }); } catch {} + + const serverDir = realpathSync(join(import.meta.dirname, "..", "..", "server")); + const server = spawn("bun", ["run", "src/index.ts"], { + cwd: serverDir, + env: { + ...process.env, + ENV: "test", + NODE_ENV: "production", + LOOPAT_HOME: loopatHome, + LOOPAT_SERVE_PORT: "0", + PORT: String(testServerPort), + HOST: "127.0.0.1", + // ── fixture provider env (no internal endpoints in committed files) ── + FIXTURE_CONTAINER: fixtureContainer, + FIXTURE_GIT_HOST: fixtureHostPort, + FIXTURE_TOKEN, + FIXTURE_LOGIN, + FIXTURE_AI_BASE_URL: process.env.FIRST_RUN_AI_BASE_URL!, + }, + stdio: "pipe", + }); + server.stdout?.on("data", (d) => process.stdout.write(`[server] ${d}`)); + server.stderr?.on("data", (d) => process.stderr.write(`[server] ${d}`)); + + writeFileSync(META, JSON.stringify({ ...meta, fixtureContainer, hostIp, serverPid: server.pid, fixtureToken: FIXTURE_TOKEN })); + + await waitFor(`http://127.0.0.1:${testServerPort}/api/health`); + const health = await (await fetch(`http://127.0.0.1:${testServerPort}/api/health`)).json(); + if (health.loopatHome !== loopatHome) { + throw new Error( + `stale server on :${testServerPort} has LOOPAT_HOME=${health.loopatHome}, expected ${loopatHome}. ` + + `Kill it manually: fuser -k ${testServerPort}/tcp`, + ); + } + console.log("[first-run:setup] backend ready (fixture provider active)"); + + await waitFor(`http://127.0.0.1:${vitePort}/api/health`); + console.log("[first-run:setup] vite ready"); +} + +export default globalSetup; diff --git a/dogfood/first-run/teardown.ts b/dogfood/first-run/teardown.ts new file mode 100644 index 00000000..6ae95949 --- /dev/null +++ b/dogfood/first-run/teardown.ts @@ -0,0 +1,50 @@ +/** + * dogfood/first-run global teardown — reap everything setup brought up: + * fixture sshd container, backend, podman network, and the temp LOOPAT_HOME. + * Only touches recorded test resources (container id + PID + temp dir). + */ +import { readFileSync, rmSync } from "node:fs"; +import { basename, join } from "node:path"; +import { execSync, execFileSync } from "node:child_process"; + +const META = join(import.meta.dirname, ".test-meta.json"); + +async function globalTeardown() { + try { + const meta = JSON.parse(readFileSync(META, "utf8")); + + if (meta.fixtureContainer) { + try { + execFileSync("podman", ["rm", "-f", meta.fixtureContainer], { stdio: "ignore" }); + console.log(`[first-run:teardown] removed fixture ${String(meta.fixtureContainer).slice(0, 12)}`); + } catch (e) { + console.log(`[first-run:teardown] fixture rm skipped: ${e}`); + } + } + + if (meta.serverPid) { + try { process.kill(meta.serverPid, "SIGTERM"); } catch {} + console.log(`[first-run:teardown] killed server pid=${meta.serverPid}`); + } + if (meta.testServerPort) { + try { execSync(`fuser -k ${meta.testServerPort}/tcp 2>/dev/null || true`, { stdio: "ignore" }); } catch {} + } + + if (meta.loopatHome) { + const network = `loopat-${basename(meta.loopatHome).replace(/^\.+/, "") || "loopat"}`; + try { + execFileSync("podman", ["network", "rm", "-f", network], { stdio: "ignore" }); + console.log(`[first-run:teardown] removed network ${network}`); + } catch {} + } + + if (meta.loopatHome) { + rmSync(meta.loopatHome, { recursive: true, force: true }); + console.log(`[first-run:teardown] removed ${meta.loopatHome}`); + } + } catch (e) { + console.log(`[first-run:teardown] cleanup skipped: ${e}`); + } +} + +export default globalTeardown; diff --git a/dogfood/multi-turn-task/README.md b/dogfood/multi-turn-task/README.md new file mode 100644 index 00000000..75f1022a --- /dev/null +++ b/dogfood/multi-turn-task/README.md @@ -0,0 +1,46 @@ +# multi-turn-task — a real multi-step AI tool-use turn + +Sibling of `first-5-minutes`, but where that case only proves the AI can *reply*, +this one proves the AI can actually *do a multi-step task with tool use* and that +the work lands on disk — verified by INTEGRATION TRUTH, never by the AI's words. + +## What it does + +1. Create a loop from the roster repo `roster1` through the real UI; open the + terminal panel (→ backend `ensureContainer`); poll podman until the loop's + sandbox container is RUNNING; wait for the `PreparingOverlay` to clear. +2. Send ONE chat instruction that forces several distinct tool actions: + > Read `README.md` in the workdir, then create `SUMMARY.md` whose first line is + > the word `DOGFOOD` followed by a space and the README's first line, then + > `git add` and `git commit` it with message `add summary`. +3. Wait for the AI turn to finish (a non-empty assistant reply arrives; no `⚠️` + error event). A real AI + tool turn is slow → generous 180s timeout. +4. INTEGRATION TRUTH — assert the AI actually DID the work, not that it *said* it + did. The roster1 fixture's `README.md` is the single line `hello`, so the + deterministic artifact is `SUMMARY.md` whose first line is exactly + `DOGFOOD hello`. We `podman exec` into the loop's sandbox container and: + - read `/loopat/loop/<id>/workdir/SUMMARY.md` → assert it starts with + `DOGFOOD hello`, and + - read `git log --oneline -1` in the workdir → assert the latest commit + subject is `add summary`. + +## Why a fixed sentinel + +The AI is non-deterministic in wording and formatting. Pinning the artifact to a +fixed sentinel (`DOGFOOD`) + the fixture's known README line (`hello`) + a fixed +commit message (`add summary`) makes the assertion stable across runs while still +requiring the AI to have read the file, written a new file, and committed it. + +## Why podman exec into the sandbox (not the workdir on the host) + +The workdir is bind-mounted into the sandbox at `V_LOOP_WORKDIR = +/loopat/loop/<id>/workdir` (podman.ts), and that is exactly where the AI's tools +operate and where the shell lands. Reading it back through `podman exec` is the +container's own truth — it cannot lie about what the AI produced. The container is +labelled `loopat.loop-id=<id>`, the same id the loop URL carries. + +## Cost + +This case spends REAL idealab tokens (one multi-step AI turn). Keep iterations +lean. The instruction is crisp and deterministic so the AI completes the tool use +reliably on the first green run. diff --git a/dogfood/multi-turn-task/journey.spec.ts b/dogfood/multi-turn-task/journey.spec.ts new file mode 100644 index 00000000..fec905f0 --- /dev/null +++ b/dogfood/multi-turn-task/journey.spec.ts @@ -0,0 +1,261 @@ +/** + * multi-turn-task — proof that the real AI can do a multi-STEP task with tool + * use, not just emit a one-line reply. + * + * first-5-minutes proves the AI *replies*; this proves the AI *acts*. One chat + * instruction forces several distinct tool actions (read a file, write a new + * file, git add, git commit). We then assert the AI actually DID the work via + * INTEGRATION TRUTH — `podman exec` into the loop's sandbox container to read the + * file it produced and the commit it made — never the AI's (non-deterministic) + * words. + * + * Deterministic artifact: the roster1 fixture's README.md is the single line + * `hello` (seed.sh), so SUMMARY.md's first line must be exactly `DOGFOOD hello`, + * and the loop's latest commit subject must be `add summary`. Fixed sentinels = + * a stable assertion despite AI nondeterminism. + * + * The harness (dogfood/playwright.config.ts + setup.ts) already booted the real + * stack and preconfigured the `test` user ALREADY ONBOARDED with the idealab + * provider and roster repo roster1. We arrive logged in via storageState. + */ +import { test, expect, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; + +const README_FIRST_LINE = "hello"; // roster1 README.md, seeded by seed.sh +const SENTINEL = "DOGFOOD"; +const EXPECTED_FIRST_LINE = `${SENTINEL} ${README_FIRST_LINE}`; +const COMMIT_MSG = "add summary"; + +/** Names of RUNNING sandbox containers for this loop id (empty array = none). + * The container is labelled loopat.loop-id=<id> (podman.ts) with the same id + * the loop URL carries. */ +function runningContainers(loopId: string): string[] { + return execFileSync("podman", [ + "ps", + "--filter", + `label=loopat.loop-id=${loopId}`, + "--filter", + "status=running", + "--format", + "{{.Names}}", + ]) + .toString() + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** The single running sandbox container for this loop (throws otherwise). */ +function sandboxContainer(loopId: string): string { + const names = runningContainers(loopId); + if (names.length !== 1) { + throw new Error(`expected exactly one running sandbox container for loop ${loopId}, got: ${names.join(", ")}`); + } + return names[0]; +} + +/** Run a shell command INSIDE the loop's sandbox container and return stdout. + * This is the container's own truth about what the AI produced — the workdir is + * bind-mounted at V_LOOP_WORKDIR = /loopat/loop/<id>/workdir (podman.ts). */ +function sandboxExec(loopId: string, cmd: string): string { + return execFileSync("podman", [ + "exec", + sandboxContainer(loopId), + "sh", + "-lc", + cmd, + ]) + .toString(); +} + +/** Best-effort remove this loop's sandbox container so loops/containers don't + * accumulate in the shared LOOPAT_HOME across the serial suite. Never throws. */ +function cleanupLoopContainer(loopId: string): void { + if (!loopId) return; + try { + const ids = execFileSync("podman", [ + "ps", "-a", + "--filter", `label=loopat.loop-id=${loopId}`, + "--format", "{{.ID}}", + ]).toString().split("\n").map((s) => s.trim()).filter(Boolean); + if (ids.length) execFileSync("podman", ["rm", "-f", ...ids]); + } catch { + // best-effort teardown — never fail the suite on cleanup. + } +} + +// The loop this case created, so afterEach can reap its container. +let createdLoopId = ""; + +test.beforeEach(async ({ page }) => { + createdLoopId = ""; + // Bypass the "Setup Personal Repo" card for the (preconfigured) account. + await page.addInitScript(() => { + localStorage.setItem("loopat:setupPersonalRepoDismissed", "1"); + }); +}); + +test.afterEach(() => { + cleanupLoopContainer(createdLoopId); +}); + +test("multi-turn task: AI reads README, writes a deterministic SUMMARY.md, and commits it", async ({ page }) => { + // ── Step 1: land on /loop, create a loop from roster1 through the real UI. ── + await page.goto("/loop"); + await expect( + page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first(), + ).toBeVisible({ timeout: 15_000 }); + + const loopTitle = `dogfood-mtt-${Date.now()}`; + // Capture the create RESPONSE so we learn THIS loop's id authoritatively. The + // suite shares one LOOPAT_HOME and loops accumulate across cases, so reading + // the id from the browser URL right after the click can return a STALE loop's + // id; the create response is the only authoritative source. + const createResp = page.waitForResponse( + (resp) => resp.url().includes("/api/v1/loops") && resp.request().method() === "POST", + { timeout: 15_000 }, + ); + + await page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first().click(); + await expect(page.getByText("New loop", { exact: true })).toBeVisible({ timeout: 5_000 }); + await page.getByRole("combobox").first().selectOption("roster1"); + await page.getByPlaceholder("refactor-gateway").fill(loopTitle); + await page.getByRole("button", { name: "create", exact: true }).click(); + + const resp = await createResp; + const body = resp.request().postDataJSON(); + expect(body.title).toBe(loopTitle); + expect(body.repo).toBe("roster1"); + const respBody = await resp.json(); + // The v1 API ids carry a `loop_` prefix; the loop URL uses the raw uuid. + const loopId = String(respBody.id ?? respBody.loop?.id ?? "").replace(/^loop_/, ""); + expect(loopId, `create response should carry the new loop id: ${JSON.stringify(respBody)}`).toMatch(/^[a-f0-9-]+$/); + createdLoopId = loopId; + + await expect(page).toHaveURL(new RegExp(`/loop/${loopId}`), { timeout: 15_000 }); + + const sidebar = page.locator("aside").first(); + await expect(sidebar).toBeVisible({ timeout: 15_000 }); + await expect(sidebar.getByText(loopTitle)).toBeVisible({ timeout: 10_000 }); + expect(runningContainers(loopId), "no container before the terminal opens").toEqual([]); + + // ── Step 2: open the terminal → backend ensureContainer (worktree the workdir + // off roster1, start the sandbox). No chat turn yet → no AI tokens. + // Poll podman until the sandbox container is RUNNING. ── + await page.getByRole("button", { name: /terminal/ }).first().click(); + await expect + .poll(() => runningContainers(loopId), { + message: `expected a running podman container labelled loopat.loop-id=${loopId}`, + timeout: 240_000, + intervals: [1_000, 2_000, 5_000], + }) + .not.toEqual([]); + + // ── Step 3: first use may still be building the per-loop image — the + // PreparingOverlay captures pointer events (z-30 backdrop) so a chat + // turn fired now would just queue. Wait for it to clear. ── + const preparingOverlay = page.getByText("Preparing this loop’s sandbox…"); + await expect(preparingOverlay).toBeHidden({ timeout: 240_000 }); + + // ── Step 4: send ONE instruction that forces several tool actions. This is the + // step that spends idealab tokens. Crisp + deterministic so the AI + // completes the full tool chain reliably on the first run. ── + const composer = page.getByRole("textbox", { name: "Message input" }); + await expect(composer).toBeVisible({ timeout: 15_000 }); + await composer.click(); + await composer.fill( + `Do these steps in the current workdir, using your tools:\n` + + `1. Read the file README.md and note its first line.\n` + + `2. Create a new file named SUMMARY.md whose ONLY content is a single line: ` + + `the word ${SENTINEL}, then one space, then README.md's first line. ` + + `(For this repo that line is exactly "${EXPECTED_FIRST_LINE}".)\n` + + `3. Run \`git add SUMMARY.md\` and then \`git commit -m '${COMMIT_MSG}'\`.\n` + + `Do not push. Report the final commit hash when done.`, + ); + await page.getByRole("button", { name: "Send message" }).click(); + + // The assistant turn streams in. Wait for an assistant message with actual + // text content. Real AI + a multi-tool turn is slow → generous timeout. + const assistantMessages = page.locator('[data-role="assistant"]'); + await expect + .poll( + async () => { + const n = await assistantMessages.count(); + for (let i = 0; i < n; i++) { + const t = (await assistantMessages.nth(i).innerText()).trim(); + if (t.length > 0) return t; + } + return ""; + }, + { + message: "expected a non-empty assistant reply from the real AI", + timeout: 180_000, + intervals: [2_000, 3_000, 5_000], + }, + ) + .not.toBe(""); + + // No error event anywhere in the transcript (a backend error renders as an + // assistant message prefixed with "⚠️" — see useLoopRuntime.tsx). + const allAssistantText = (await assistantMessages.allInnerTexts()).join("\n"); + expect(allAssistantText, "no error event should appear in the chat").not.toContain("⚠️"); + console.log(`[dogfood] assistant reply (first 200 chars): ${(await assistantMessages.last().innerText()).trim().slice(0, 200)}`); + + // ── INTEGRATION TRUTH: don't trust the AI's words. Read the artifact + commit + // straight out of the sandbox container. The AI's tool turn may still be + // flushing the last action to disk after the reply renders, so POLL. ── + + // (a) SUMMARY.md exists and its first line is exactly the deterministic sentinel. + await expect + .poll( + () => { + try { + // `head -1` so trailing content (if the AI added any) can't break the + // assertion — we only pin the FIRST line, which is the deterministic part. + return sandboxExec(loopId, `head -1 /loopat/loop/${loopId}/workdir/SUMMARY.md`).trim(); + } catch { + return ""; // file not written yet + } + }, + { + message: `expected SUMMARY.md first line "${EXPECTED_FIRST_LINE}" in the loop workdir`, + timeout: 60_000, + intervals: [1_000, 2_000, 3_000], + }, + ) + .toBe(EXPECTED_FIRST_LINE); + console.log(`[dogfood] SUMMARY.md first line (from sandbox): ${EXPECTED_FIRST_LINE}`); + + // (b) the AI actually committed it — latest commit subject is the expected msg. + await expect + .poll( + () => { + try { + return sandboxExec( + loopId, + `git -C /loopat/loop/${loopId}/workdir log --oneline -1`, + ).trim(); + } catch { + return ""; + } + }, + { + message: `expected the loop's latest commit subject to be "${COMMIT_MSG}"`, + timeout: 60_000, + intervals: [1_000, 2_000, 3_000], + }, + ) + .toContain(COMMIT_MSG); + + const lastCommit = sandboxExec(loopId, `git -C /loopat/loop/${loopId}/workdir log --oneline -1`).trim(); + console.log(`[dogfood] loop workdir latest commit (from sandbox): ${lastCommit}`); + expect(lastCommit, "the AI's commit must be the latest in the workdir").toContain(COMMIT_MSG); + + // (c) SUMMARY.md is tracked by git at that commit (not just an untracked file). + const tracked = sandboxExec( + loopId, + `git -C /loopat/loop/${loopId}/workdir ls-files SUMMARY.md`, + ).trim(); + expect(tracked, "SUMMARY.md must be committed (tracked by git), not left untracked").toBe("SUMMARY.md"); + console.log("[dogfood] PROVEN: AI read README, wrote deterministic SUMMARY.md, and committed it"); +}); diff --git a/dogfood/playwright.config.ts b/dogfood/playwright.config.ts new file mode 100644 index 00000000..42d49166 --- /dev/null +++ b/dogfood/playwright.config.ts @@ -0,0 +1,140 @@ +/** + * dogfood/first-5-minutes — highest-fidelity e2e config. + * + * Unlike e2e/ (logic, mocked), this boots a REAL stack: a podman sshd+git + * fixture container, an isolated backend with its own LOOPAT_HOME preconfigured + * as ALREADY ONBOARDED (idealab provider + the dev vault's ssh key), and Vite. + * The spec (Task 3) drives a real browser -> real container -> real AI -> real + * git push into the fixture origin. podman / IDEALAB_API_KEY missing -> FAIL, + * never skip (a dogfood test that goes green without running proves nothing). + * + * Ports are decided here at config-load time and recorded in + * dogfood/.test-meta.json; the fixture container + backend are brought up in + * setup.ts (Playwright loads this config twice — discovery + runner — so the + * fixture must NOT be started here or the second load collides on the port). + */ +import { defineConfig } from "@playwright/test"; +import { mkdtempSync, writeFileSync, readFileSync, renameSync } from "node:fs"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { createServer } from "node:net"; +import { execSync } from "node:child_process"; + +const META = join(import.meta.dirname, ".test-meta.json"); + +// ── fail-not-skip preconditions ── +function requirePodman(): void { + try { + execSync("podman --version", { stdio: "ignore" }); + } catch { + throw new Error("[dogfood] podman not found — this test runs a real sshd container and must not be skipped"); + } +} + +// The fixture is fully self-contained: the ssh keypair is generated fresh in +// setup.ts, never lifted from any real vault (a real key sitting in the repo / +// on github reads as "leaked credential"). The ONE thing a fixture can't fake +// is a real provider key for real AI — it comes from the environment, never +// from disk, never committed. Missing -> fail (a green dogfood run that didn't +// actually call AI proves nothing). +function requireIdealabKey(): void { + if (!process.env.IDEALAB_API_KEY) { + throw new Error("[dogfood] IDEALAB_API_KEY not set — export it before running (real AI needs a real key; we never read it from disk)"); + } +} + +// ── pick free ports (sshd publish + backend + vite) ── +function tryPort(port: number): boolean { + try { + const s = createServer(); + s.listen(port, "127.0.0.1"); + s.close(); + return true; + } catch { + return false; + } +} + +function pickPorts(): { testServerPort: number; vitePort: number; sshdPort: number } { + // 22001+ range, away from e2e (20001) and common dev ports. + for (let p = 22001; p < 23000; p += 3) { + if (tryPort(p) && tryPort(p + 1) && tryPort(p + 2)) { + return { testServerPort: p, vitePort: p + 1, sshdPort: p + 2 }; + } + } + throw new Error("no free port triple found in 22001–23000"); +} + +requirePodman(); +requireIdealabKey(); + +// Playwright loads this config in BOTH the main process AND each worker process. +// Only the main process runs globalSetup/globalTeardown, so only it may pick +// ports + write META — otherwise a worker's reload would overwrite META with a +// fresh mkdtemp/port pick AFTER setup ran, and teardown would reap the wrong +// (never-started) resources, leaking the real fixture + LOOPAT_HOME. Workers +// carry TEST_WORKER_INDEX; the main process does not. +const isWorker = process.env.TEST_WORKER_INDEX !== undefined; + +let testServerPort = 0; +let vitePort = 0; +let sshdPort = 0; +let loopatHome = ""; + +if (isWorker) { + // Read the values the main process already committed. + const m = JSON.parse(readFileSync(META, "utf8")); + ({ testServerPort, vitePort, sshdPort, loopatHome } = m); +} else { + ({ testServerPort, vitePort, sshdPort } = pickPorts()); + // The basename becomes the server's WORKSPACE, which is baked into podman + // image tags (loopat-sandbox-<workspace>-…). podman rejects uppercase in + // image names, and mkdtemp's XXXXXX suffix is mixed-case — so mkdtemp into a + // lowercase-prefixed dir and lowercase the whole basename. + const raw = mkdtempSync(join(tmpdir(), "loopat-dogfood-")); + const lower = join(tmpdir(), basename(raw).toLowerCase()); + if (lower !== raw) renameSync(raw, lower); + loopatHome = lower; + // Fixture (image build + container run) is started in setup.ts, which records + // the container id back into this meta file. Teardown reads it from there. + writeFileSync( + META, + JSON.stringify({ + loopatHome, + testServerPort, + vitePort, + sshdPort, + }), + ); +} + +export default defineConfig({ + testDir: import.meta.dirname, + // first-run/ is its OWN suite with its own globalSetup (empty LOOPAT_HOME + + // fixture provider). The preset suite boots an ALREADY-ONBOARDED stack, so + // running first-run under it would fail (no fixture provider env). Run it via + // `bun run dogfood:first-run` instead. + testIgnore: ["**/first-run/**", "**/sync/**"], + // Real AI + real container — generous timeout, no retries (each run costs + // money and is non-deterministic). + timeout: 300_000, + retries: 0, + // All cases share ONE backend + fixture + vite per run (globalSetup), and the + // same isolated LOOPAT_HOME / personal config. Run specs serially so they + // don't collide on the shared stack. + workers: 1, + globalSetup: "./setup.ts", + globalTeardown: "./teardown.ts", + use: { + baseURL: `http://127.0.0.1:${vitePort}`, + trace: "on-first-retry", + screenshot: "only-on-failure", + storageState: join(import.meta.dirname, ".auth.json"), + }, + webServer: { + command: + `env ENV=test HOST=127.0.0.1 PORT=${testServerPort} bun --cwd=${join(import.meta.dirname, "..", "web")} run dev -- --port ${vitePort}`, + port: vitePort, + reuseExistingServer: false, + }, +}); diff --git a/dogfood/repos-page/README.md b/dogfood/repos-page/README.md new file mode 100644 index 00000000..7279d97e --- /dev/null +++ b/dogfood/repos-page/README.md @@ -0,0 +1,61 @@ +# repos-page — manage the personal repo roster + +A highest-fidelity e2e journey (real browser → real backend → isolated +`LOOPAT_HOME` on disk). Unlike `first-5-minutes`, it spends **no AI tokens** — +there is no chat turn, no sandbox container. It exercises the repo-roster page +and verifies persistence end to end. + +## What it regresses + +The **repos-are-personal** redesign: the repo roster no longer lives in the +knowledge repo. It lives in `personal/<user>/.loopat/config.json` and is read / +written via `GET` / `PUT /api/context/repos` (server) — surfaced by the +`ReposPane` on the `/context/repos` page (`web/src/pages/ContextPage.tsx`). + +The bugs this catches: +- the page shows **empty** (roster read from the wrong place); +- a Save that **doesn't persist** (writes nowhere, or to the wrong file); +- a Save that **clobbers the rest of personal config** (e.g. wipes the + `providers` block) by overwriting the whole file instead of patching `repos`. + +## Flow + +1. Arrive logged in via `storageState` → `/context/repos`. The preconfigured + `roster1` entry (seeded in `dogfood/setup.ts`) is already listed. +2. Add a second entry through the real UI: **+ add repo** → fill `name` = + `roster2` and the git url of the second fixture bare repo + (`ssh://git@<hostIp>:<sshdPort>/srv/git/roster2.git`) → **Save**. +3. **Reload** the page; assert `roster2` still shows (read back via `GET`, not + from in-memory state). +4. **Integration truth**: read `personal/<user>/.loopat/config.json` off the + test `LOOPAT_HOME` (path from `dogfood/.test-meta.json` → `loopatHome`; the + user id is the single dir under `personal/`) and assert: + - both `roster1` and `roster2` are present, with `roster2`'s git url; + - the unrelated `providers.idealab` block survived the PUT (no clobber). + +## Assertions + +Behavioral + integration truth, never screenshots: +- the preconfigured roster1 is listed on load; +- the Save fires a real `PUT /api/context/repos` carrying both repos; +- after a full page reload the new entry is still rendered; +- the on-disk personal `config.json` contains both repos with the right url; +- the providers block was preserved by the partial save. + +## Fixtures + +Shares the `first-5-minutes/fixtures` image. `seed.sh` creates a second bare +repo `roster2.git` (alongside `knowledge` / `notes` / `roster1`) so the journey +can register a real, distinct repo. No real credentials — the ssh keypair is +generated fresh per run in `setup.ts`. + +## Run + +```sh +export IDEALAB_API_KEY=$(cat ~/.loopat/personal/simpx/.loopat/vaults/default/envs/IDEALAB_API_KEY) +bunx playwright test --config dogfood/playwright.config.ts dogfood/repos-page/journey.spec.ts +``` + +(`IDEALAB_API_KEY` is required by the shared harness preconditions even though +this case never calls AI — `setup.ts` writes it into the vault for the onboarded +provider config.) diff --git a/dogfood/repos-page/journey.spec.ts b/dogfood/repos-page/journey.spec.ts new file mode 100644 index 00000000..d8c83d28 --- /dev/null +++ b/dogfood/repos-page/journey.spec.ts @@ -0,0 +1,159 @@ +/** + * repos-page — managing the personal repo roster through the real UI. + * + * This regresses the "repos-are-personal" redesign: the roster no longer lives + * in the knowledge repo, it lives in personal/<user>/.loopat/config.json and is + * read/written via GET/PUT /api/context/repos. The classic bug here is the page + * showing empty (roster read from the wrong place) or a Save that doesn't + * persist (PUT clobbering the rest of personal config, or writing nowhere). + * + * Flow (no AI tokens spent — this case never sends a chat turn): + * 1. Arrive logged in via storageState → /context/repos. The preconfigured + * `roster1` entry (seeded in setup.ts) must already be listed. + * 2. Add a new entry (`roster2`, pointing at the second fixture bare repo) + * through the real UI (+ add repo → fill name + git url → Save). + * 3. Reload the page and assert the new entry PERSISTS in the UI. + * 4. INTEGRATION TRUTH: read personal/<user>/.loopat/config.json off the test + * LOOPAT_HOME on disk and assert both roster1 AND roster2 are present, and + * that the unrelated `providers` block survived the PUT (no clobber). + * + * The harness (dogfood/playwright.config.ts + setup.ts) already booted the real + * stack and preconfigured the `test` user as ALREADY ONBOARDED with one roster + * repo `roster1` and a `roster2` bare repo waiting in the fixture. + */ +import { test, expect } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +type Meta = { + loopatHome: string; + sshdPort: number; +}; + +function meta(): Meta { + return JSON.parse( + readFileSync(join(import.meta.dirname, "..", ".test-meta.json"), "utf8"), + ) as Meta; +} + +/** The host default-route IP — same address setup.ts used for the fixture git + * urls, so the url we type in the UI matches what the loop would clone. */ +function hostIp(): string { + const ip = execFileSync("ip", ["route", "get", "1.1.1.1"]) + .toString() + .match(/src\s+(\d+\.\d+\.\d+\.\d+)/)?.[1]; + if (!ip) throw new Error("could not determine host default-route IP"); + return ip; +} + +/** The registered `test` user's id — the single dir under personal/. */ +function testUserId(loopatHome: string): string { + const personalDir = join(loopatHome, "personal"); + const dirs = readdirSync(personalDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + if (dirs.length !== 1) { + throw new Error(`expected exactly one personal/<user> dir, got: ${dirs.join(", ")}`); + } + return dirs[0]; +} + +/** The user's personal config.json on disk — the integration source of truth. */ +function readPersonalConfig(loopatHome: string): any { + const p = join( + loopatHome, + "personal", + testUserId(loopatHome), + ".loopat", + "config.json", + ); + return JSON.parse(readFileSync(p, "utf8")); +} + +test("repos-page: add a roster entry via the UI → persists across reload + on disk", async ({ page }) => { + const { loopatHome, sshdPort } = meta(); + const roster2Url = `ssh://git@${hostIp()}:${sshdPort}/srv/git/roster2.git`; + + // ── Step 1: land on /context/repos, logged in. roster1 is preconfigured. ── + await page.goto("/context/repos"); + + // The "Repo roster" heading proves we're on the right pane (not e.g. the + // knowledge file tree). + await expect(page.getByRole("heading", { name: "Repo roster" })).toBeVisible({ + timeout: 15_000, + }); + + // The preconfigured roster1 entry must already be listed — its name input + // carries the value "roster1". + const nameInputs = page.locator('input[placeholder="name"]'); + await expect + .poll(async () => nameInputs.evaluateAll((els) => (els as HTMLInputElement[]).map((e) => e.value)), { + message: "the preconfigured roster1 entry should be listed on load", + timeout: 15_000, + }) + .toContain("roster1"); + + // ── Step 2: add roster2 through the UI. ── + const beforeCount = await nameInputs.count(); + await page.getByRole("button", { name: "+ add repo" }).click(); + await expect(nameInputs).toHaveCount(beforeCount + 1); + + // Fill the newly added (last) row. + await nameInputs.last().fill("roster2"); + await page.locator('input[placeholder="git@…/repo.git"]').last().fill(roster2Url); + + // Capture the real PUT so we know the save hit the personal-config API. + const putReq = page.waitForRequest( + (req) => req.url().includes("/api/context/repos") && req.method() === "PUT", + { timeout: 15_000 }, + ); + const saveBtn = page.getByRole("button", { name: /^Save$/ }); + await expect(saveBtn).toBeEnabled(); + await saveBtn.click(); + + const sent = (await putReq).postDataJSON(); + expect(sent.repos.map((r: any) => r.name)).toEqual( + expect.arrayContaining(["roster1", "roster2"]), + ); + + // The UI confirms the save. + await expect(page.getByText("Saved.")).toBeVisible({ timeout: 10_000 }); + + // ── Step 3: reload — the new entry must still be there (read back from the + // personal config via GET, not from any in-memory state). ── + await page.reload(); + await expect(page.getByRole("heading", { name: "Repo roster" })).toBeVisible({ + timeout: 15_000, + }); + await expect + .poll(async () => nameInputs.evaluateAll((els) => (els as HTMLInputElement[]).map((e) => e.value)), { + message: "roster2 must persist across a reload (read from personal config)", + timeout: 15_000, + }) + .toEqual(expect.arrayContaining(["roster1", "roster2"])); + + // The roster2 row carries the git url we typed. + const gitInputs = page.locator('input[placeholder="git@…/repo.git"]'); + const urls = await gitInputs.evaluateAll((els) => (els as HTMLInputElement[]).map((e) => e.value)); + expect(urls).toContain(roster2Url); + + // ── Step 4: INTEGRATION TRUTH — the personal config.json on disk. ── + const cfg = readPersonalConfig(loopatHome); + const names = (cfg.repos ?? []).map((r: any) => r.name); + expect(names, "roster1 + roster2 must both be in personal config.json on disk").toEqual( + expect.arrayContaining(["roster1", "roster2"]), + ); + const r2 = (cfg.repos ?? []).find((r: any) => r.name === "roster2"); + expect(r2?.git, "roster2's git url must be persisted on disk").toBe(roster2Url); + + // The PUT must NOT have clobbered the rest of personal config — the idealab + // provider preconfigured in setup.ts must still be there (regresses a + // whole-file-overwrite bug in the personal-config save path). + expect( + cfg.providers?.idealab, + "the PUT must preserve the unrelated providers block (no whole-file clobber)", + ).toBeTruthy(); + + console.log(`[dogfood] personal config.json repos on disk: ${JSON.stringify(cfg.repos)}`); +}); diff --git a/dogfood/second-loop-warm/README.md b/dogfood/second-loop-warm/README.md new file mode 100644 index 00000000..696571e1 --- /dev/null +++ b/dogfood/second-loop-warm/README.md @@ -0,0 +1,55 @@ +# dogfood/second-loop-warm + +Regresses the **image-reuse** fix (commit `82d8cf5` "content-hash image tag — +drop workspace prefix"). + +## What changed in product code + +The sandbox image tag used to be per-workspace: + +``` +loopat-sandbox-<workspace>:latest # base +loopat-sandbox-<workspace>-<hash>:latest # per-loop (mise.toml) child +``` + +It is now **content-addressed** (no workspace prefix): + +``` +loopat-sandbox:latest # base +loopat-sandbox-<hash>:latest # per-loop child, hash = sha256(baseContainerfile + mise.toml) +``` + +So a second loop resolves the **same image name** the first loop already built +and podman reuses it instead of building a fresh per-workspace copy. Containers +stay workspace-scoped via the `loopat.workspace` label (runtime isolation); only +the *image* is shared. + +## What this journey asserts (integration truth, no AI tokens) + +1. Create **loop A** from `roster1`, open its terminal (→ `ensureContainer`), + poll podman until A's sandbox container is **running**; record A's container + image ID. +2. **Snapshot** every `loopat-sandbox*` image ID present *before* loop B starts. +3. Create **loop B** from `roster2`, open its terminal, poll until B is running; + record B's container image ID. +4. Assert: + - **(a) no rebuild for B** — B's container image ID was already in the + pre-B snapshot, so B built no image of its own. + - **(b) shared image** — B's image ID `===` A's image ID. + +Both assertions fail under the old per-workspace tagging (B would resolve a +different tag → a fresh image → an ID not in the snapshot and `!==` A's). + +We deliberately assert image-ID identity rather than timing (B faster than A), +because timing is flaky on a warm/cold layer cache; image-ID identity is a hard, +deterministic signal. + +## Run + +```sh +export IDEALAB_API_KEY=$(cat ~/.loopat/personal/simpx/.loopat/vaults/default/envs/IDEALAB_API_KEY) +bunx playwright test --config dogfood/playwright.config.ts dogfood/second-loop-warm/journey.spec.ts +``` + +Real podman + a real `IDEALAB_API_KEY` are required (the config fails, never +skips, without them) — though this case sends no chat turn and spends no tokens. diff --git a/dogfood/second-loop-warm/journey.spec.ts b/dogfood/second-loop-warm/journey.spec.ts new file mode 100644 index 00000000..ad391f49 --- /dev/null +++ b/dogfood/second-loop-warm/journey.spec.ts @@ -0,0 +1,265 @@ +/** + * second-loop-warm — regresses the IMAGE-REUSE fix (commit 82d8cf5). + * + * THE FIX: the sandbox image tag dropped its per-workspace prefix + * `loopat-sandbox-<workspace>-<hash>` → content-hash `loopat-sandbox-<hash>` + * (and the base tag `loopat-sandbox-<workspace>:latest` → `loopat-sandbox:latest`). + * Because the tag is now content-addressed, a SECOND loop reuses the FIRST + * loop's already-built image instead of rebuilding its own per-workspace copy. + * + * THE REGRESSION WE GUARD: if the workspace prefix ever creeps back into the + * tag, loop B would resolve a DIFFERENT image name than loop A, podman would + * build/tag a fresh image for B, and B's container would NOT be running off the + * image A already produced. This test catches exactly that. + * + * FLOW (no chat turn → zero AI tokens): + * 1. Create loop A from roster1, open its terminal to trigger ensureContainer, + * poll podman until A's sandbox container is RUNNING (runningContainers). + * 2. SNAPSHOT every loopat-sandbox* image ID that exists now — this is the set + * of images present BEFORE loop B starts. + * 3. Create loop B from roster2, open its terminal, poll until B is RUNNING. + * 4. INTEGRATION TRUTH (podman, not the DOM): + * (a) the image B's container actually runs was ALREADY in the pre-B + * snapshot → B triggered no image build of its own (REUSE), and + * (b) B runs the SAME image ID as A → they share one content-addressed + * image, exactly what the fix intends. + * + * Why image-ID identity instead of timing: timing (B faster than A) is flaky on + * a warm/cold layer cache. "B's image already existed and equals A's" is a hard, + * deterministic signal that cannot pass under the old per-workspace tagging. + * + * The harness (dogfood/playwright.config.ts + setup.ts) already booted the real + * stack and preconfigured the `test` user ALREADY ONBOARDED with the idealab + * provider and roster repos roster1 + roster2. We arrive logged in via + * storageState. + */ +import { test, expect, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +type Meta = { loopatHome: string; sshdPort: number }; + +function meta(): Meta { + return JSON.parse( + readFileSync(join(import.meta.dirname, "..", ".test-meta.json"), "utf8"), + ) as Meta; +} + +/** The registered `test` user's id — the single dir under personal/. */ +function testUserId(loopatHome: string): string { + const personalDir = join(loopatHome, "personal"); + const dirs = readdirSync(personalDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + if (dirs.length !== 1) { + throw new Error(`expected exactly one personal/<user> dir, got: ${dirs.join(", ")}`); + } + return dirs[0]; +} + +/** The host default-route IP — same address setup.ts used for fixture git urls, + * so the url we type in the UI matches what the loop would clone. */ +function hostIp(): string { + const ip = execFileSync("ip", ["route", "get", "1.1.1.1"]) + .toString() + .match(/src\s+(\d+\.\d+\.\d+\.\d+)/)?.[1]; + if (!ip) throw new Error("could not determine host default-route IP"); + return ip; +} + +/** Names of RUNNING sandbox containers for this loop id (empty array = none). */ +function runningContainers(loopId: string): string[] { + return execFileSync("podman", [ + "ps", + "--filter", + `label=loopat.loop-id=${loopId}`, + "--filter", + "status=running", + "--format", + "{{.Names}}", + ]) + .toString() + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** The image ID the (single) running container for this loop is built from. */ +function loopContainerImageId(loopId: string): string { + const names = runningContainers(loopId); + if (names.length !== 1) { + throw new Error(`expected exactly one running container for loop ${loopId}, got: ${names.join(", ")}`); + } + return execFileSync("podman", [ + "inspect", + "--format", + "{{.Image}}", + names[0], + ]) + .toString() + .trim(); +} + +/** All sandbox image IDs currently in podman's local store. The set of images + * that exist at a given moment — used to prove loop B built none of its own. */ +function sandboxImageIds(): Set<string> { + const out = execFileSync("podman", [ + "images", + "--no-trunc", + "--filter", + "reference=loopat-sandbox*", + "--format", + "{{.ID}}", + ]) + .toString() + .split("\n") + // `podman images --no-trunc` prefixes IDs with `sha256:`; container inspect + // `{{.Image}}` returns the bare hex. Normalize to bare hex so the sets compare. + .map((s) => s.trim().replace(/^sha256:/, "")) + .filter(Boolean); + return new Set(out); +} + +/** Create a loop from a roster repo through the real UI; return its loop id once + * the page has navigated to it and the sidebar lists it. */ +async function createLoop(page: Page, repo: string, title: string): Promise<string> { + await expect( + page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first(), + ).toBeVisible({ timeout: 15_000 }); + + // Capture the create RESPONSE so we learn THIS loop's id authoritatively — + // the URL can still read as the previous loop's right after the click. + const createResp = page.waitForResponse( + (resp) => resp.url().includes("/api/v1/loops") && resp.request().method() === "POST", + { timeout: 15_000 }, + ); + + await page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first().click(); + await expect(page.getByText("New loop", { exact: true })).toBeVisible({ timeout: 5_000 }); + await page.getByRole("combobox").first().selectOption(repo); + await page.getByPlaceholder("refactor-gateway").fill(title); + await page.getByRole("button", { name: "create", exact: true }).click(); + + const resp = await createResp; + const reqBody = resp.request().postDataJSON(); + expect(reqBody.title).toBe(title); + expect(reqBody.repo).toBe(repo); + const respBody = await resp.json(); + // The v1 API ids carry a `loop_` prefix; the loop URL uses the raw uuid. + const loopId = String(respBody.id ?? respBody.loop?.id ?? "").replace(/^loop_/, ""); + expect(loopId, `create response should carry the new loop id: ${JSON.stringify(respBody)}`).toMatch(/^[a-f0-9-]+$/); + createdLoopIds.push(loopId); + + // Wait until the page has actually navigated to THIS loop. + await expect(page).toHaveURL(new RegExp(`/loop/${loopId}`), { timeout: 15_000 }); + + // Once a loop is open the page has TWO <aside>s (nav sidebar + editor + // complementary), so scope to the first — the loop list sidebar. + const sidebar = page.locator("aside").first(); + await expect(sidebar).toBeVisible({ timeout: 15_000 }); + await expect(sidebar.getByText(title)).toBeVisible({ timeout: 10_000 }); + + // Brand-new loop — nothing has touched it, so no container yet. + expect(runningContainers(loopId), `no container should exist before loop ${repo}'s terminal opens`).toEqual([]); + return loopId; +} + +/** Open the terminal panel (opens /ws/loop/:id/term → backend ensureContainer) + * and poll podman until this loop's sandbox container is RUNNING. */ +async function startContainer(page: Page, loopId: string): Promise<void> { + await page.getByRole("button", { name: /terminal/ }).first().click(); + await expect + .poll(() => runningContainers(loopId), { + message: `expected a running podman container labelled loopat.loop-id=${loopId}`, + timeout: 240_000, + intervals: [1_000, 2_000, 5_000], + }) + .not.toEqual([]); +} + +/** Best-effort remove a loop's sandbox container so loops/containers don't + * accumulate in the shared LOOPAT_HOME across the serial suite. Never throws. */ +function cleanupLoopContainer(loopId: string): void { + if (!loopId) return; + try { + const ids = execFileSync("podman", [ + "ps", "-a", + "--filter", `label=loopat.loop-id=${loopId}`, + "--format", "{{.ID}}", + ]).toString().split("\n").map((s) => s.trim()).filter(Boolean); + if (ids.length) execFileSync("podman", ["rm", "-f", ...ids]); + } catch { + // best-effort teardown — never fail the suite on cleanup. + } +} + +// The loops this case created, so afterEach can reap their containers. +const createdLoopIds: string[] = []; + +test.beforeEach(async ({ page }) => { + createdLoopIds.length = 0; + // Bypass the "Setup Personal Repo" card for the (preconfigured) account. + await page.addInitScript(() => { + localStorage.setItem("loopat:setupPersonalRepoDismissed", "1"); + }); +}); + +test.afterEach(() => { + for (const id of createdLoopIds) cleanupLoopContainer(id); +}); + +test("second loop reuses the first loop's content-addressed sandbox image (no rebuild)", async ({ page }) => { + const { loopatHome, sshdPort } = meta(); + // roster2 is preconfigured as a bare repo in the fixture but not in the user's + // roster yet; add it so the New Loop dialog offers it. (roster1 is already + // there from setup.ts.) loadPersonalConfig reads fresh from disk (no cache), + // so we append roster2 to the personal config.json on disk directly, + // preserving the providers block and roster1. + const roster2Url = `ssh://git@${hostIp()}:${sshdPort}/srv/git/roster2.git`; + const personalCfgPath = join(loopatHome, "personal", testUserId(loopatHome), ".loopat", "config.json"); + const personalCfg = JSON.parse(readFileSync(personalCfgPath, "utf8")); + if (!personalCfg.repos?.some((r: any) => r.name === "roster2")) { + personalCfg.repos = [...(personalCfg.repos ?? []), { name: "roster2", git: roster2Url }]; + writeFileSync(personalCfgPath, JSON.stringify(personalCfg, null, 2) + "\n"); + } + + await page.goto("/loop"); + + // ── Loop A: create from roster1, open terminal, wait until running. ── + const titleA = `dogfood-warmA-${Date.now()}`; + const loopA = await createLoop(page, "roster1", titleA); + await startContainer(page, loopA); + const imageA = loopContainerImageId(loopA); + console.log(`[dogfood] loop A (${loopA.slice(0, 8)}) container image: ${imageA}`); + + // ── SNAPSHOT: every sandbox image that exists right now, BEFORE loop B. ── + // If loop B is a true reuse, it must NOT add any new image — its container's + // image must already be a member of this set. + const imagesBeforeB = sandboxImageIds(); + expect(imagesBeforeB.has(imageA), "loop A's image must be in the local store").toBeTruthy(); + console.log(`[dogfood] sandbox images present before loop B: ${[...imagesBeforeB].join(", ")}`); + + // ── Loop B: create from roster2, open terminal, wait until running. ── + const titleB = `dogfood-warmB-${Date.now()}`; + const loopB = await createLoop(page, "roster2", titleB); + await startContainer(page, loopB); + const imageB = loopContainerImageId(loopB); + console.log(`[dogfood] loop B (${loopB.slice(0, 8)}) container image: ${imageB}`); + + // ── INTEGRATION TRUTH (a): B's container runs an image that ALREADY existed + // before B started — B built no image of its own (REUSE, not rebuild). ── + expect( + imagesBeforeB.has(imageB), + "loop B's container image must have existed BEFORE B started (no per-loop rebuild) — " + + "if the workspace prefix crept back into the tag, B would build a fresh image and this fails", + ).toBeTruthy(); + + // ── INTEGRATION TRUTH (b): A and B run the SAME content-addressed image. ── + expect( + imageB, + "loop B must share loop A's content-addressed sandbox image (the whole point of the fix)", + ).toBe(imageA); + + console.log(`[dogfood] PROVEN reuse: loop B runs the same pre-existing image as loop A (${imageA})`); +}); diff --git a/dogfood/setup.ts b/dogfood/setup.ts new file mode 100644 index 00000000..3f567ec4 --- /dev/null +++ b/dogfood/setup.ts @@ -0,0 +1,248 @@ +/** + * dogfood global setup — boot the real stack as an ALREADY-ONBOARDED user. + * + * Order matters: + * 0. Build the fixture image and run the sshd container (podman rejects + * `-p 127.0.0.1:0:22`, so the host port was picked in playwright.config.ts). + * Done here (not at config top-level) because Playwright loads the config + * twice and a second `podman run` on the same port would collide. + * 1. Seed the isolated LOOPAT_HOME workspace config (gitHost + knowledge -> + * the fixture sshd) BEFORE the backend starts so it reads them on boot. + * 2. Spawn the backend on the picked free port with the isolated LOOPAT_HOME. + * 3. Register the test user (scaffolds personal/<user>/). + * 4. Write the user's personal config (idealab provider) and build a + * SELF-CONTAINED vault: a FRESH ssh keypair (generated here, not copied + * from any real vault) + IDEALAB_API_KEY taken from the env. Add a + * `loopat-fixture` ssh Host alias -> 127.0.0.1:<sshdPort>. + * 5. Seed the fixture container's authorized_keys with that vault pubkey and + * create the bare repos (seed.sh). + * 6. Save browser storageState for the spec. + * + * The fixture container + ports are already up (decided in playwright.config.ts, + * recorded in .test-meta.json). + */ +import { request } from "@playwright/test"; +import { spawn, execSync, execFileSync } from "node:child_process"; +import { + readFileSync, writeFileSync, mkdirSync, chmodSync, realpathSync, +} from "node:fs"; +import { join } from "node:path"; + +const META = join(import.meta.dirname, ".test-meta.json"); +const FIXTURE_IMAGE = "loopat-dogfood-sshd:latest"; +const FIXTURE_DIR = join(import.meta.dirname, "first-5-minutes", "fixtures"); + +const TEST_USER = "test"; +const TEST_PASSWORD = "test123"; + +async function waitFor(url: string, timeoutMs = 60_000): Promise<void> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const r = await fetch(url); + if (r.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`timed out waiting for ${url}`); +} + +type Meta = { + loopatHome: string; + testServerPort: number; + vitePort: number; + sshdPort: number; +}; + +async function globalSetup() { + const meta = JSON.parse(readFileSync(META, "utf8")) as Meta; + const { loopatHome, testServerPort, vitePort, sshdPort } = meta; + + console.log(`[dogfood:setup] LOOPAT_HOME = ${loopatHome}`); + console.log(`[dogfood:setup] backend :${testServerPort} vite :${vitePort} sshd :${sshdPort}`); + + // ── 0. build + run the fixture sshd container ── + // The fixture sshd must be reachable from TWO places using the SAME ssh + // config (it lives in one vault): the backend, which runs on the HOST, and + // the loop's sandbox CONTAINER, which runs on a podman BRIDGE network (its + // 127.0.0.1 is its own loopback, not the host's). The one address that works + // from both is the host's default-route IP: from the host it's a local + // address, and from a bridge container it's exactly what `host.containers + // .internal` resolves to. So publish the sshd on 0.0.0.0 (not just loopback) + // and point the ssh config's HostName at that IP. (Publishing on 127.0.0.1 + // only — the old way — works host-side but the sandbox push then fails to + // even connect, and with HostName 127.0.0.1 the sandbox would dial its own + // loopback.) + const hostIp = execSync("ip route get 1.1.1.1") + .toString() + .match(/src\s+(\d+\.\d+\.\d+\.\d+)/)?.[1]; + if (!hostIp) throw new Error("[dogfood:setup] could not determine host default-route IP for the fixture sshd"); + console.log(`[dogfood:setup] building ${FIXTURE_IMAGE} from ${FIXTURE_DIR}`); + execSync(`podman build -t ${FIXTURE_IMAGE} ${FIXTURE_DIR}`, { stdio: "inherit" }); + const fixtureContainer = execFileSync( + "podman", + ["run", "-d", "-p", `0.0.0.0:${sshdPort}:22`, FIXTURE_IMAGE], + ).toString().trim(); + console.log(`[dogfood:setup] fixture sshd up: ${fixtureContainer.slice(0, 12)} on ${hostIp}:${sshdPort} (0.0.0.0 published)`); + // Record the container id immediately so teardown can reap it even if a + // later setup step throws. + writeFileSync(META, JSON.stringify({ ...meta, fixtureContainer })); + + // ── 1. workspace config: point knowledge + gitHost at the fixture sshd ── + // The `loopat-fixture` ssh Host alias is defined in the vault ssh config + // (written in step 4); git urls use it so the host:port stays out of the url. + mkdirSync(loopatHome, { recursive: true }); + const workspaceConfig = { + knowledge: { git: `ssh://git@${hostIp}:${sshdPort}/srv/git/knowledge.git` }, + gitHost: { baseUrl: `ssh://git@${hostIp}:${sshdPort}` }, + }; + writeFileSync(join(loopatHome, "config.json"), JSON.stringify(workspaceConfig, null, 2) + "\n"); + + // ── 2. start the backend ── + // Kill any stale backend on the port from a crashed previous run. + try { execSync(`fuser -k ${testServerPort}/tcp 2>/dev/null || true`, { stdio: "ignore" }); } catch {} + + const serverDir = realpathSync(join(import.meta.dirname, "..", "server")); + const server = spawn("bun", ["run", "src/index.ts"], { + cwd: serverDir, + env: { + ...process.env, + ENV: "test", + NODE_ENV: "production", + LOOPAT_HOME: loopatHome, + LOOPAT_SERVE_PORT: "0", + PORT: String(testServerPort), + HOST: "127.0.0.1", + }, + stdio: "pipe", + }); + server.stdout?.on("data", (d) => process.stdout.write(`[server] ${d}`)); + server.stderr?.on("data", (d) => process.stderr.write(`[server] ${d}`)); + + writeFileSync(META, JSON.stringify({ ...meta, fixtureContainer, serverPid: server.pid })); + + await waitFor(`http://127.0.0.1:${testServerPort}/api/health`); + const health = await (await fetch(`http://127.0.0.1:${testServerPort}/api/health`)).json(); + if (health.loopatHome !== loopatHome) { + throw new Error( + `stale server on :${testServerPort} has LOOPAT_HOME=${health.loopatHome}, expected ${loopatHome}. ` + + `Kill it manually: fuser -k ${testServerPort}/tcp`, + ); + } + console.log("[dogfood:setup] backend ready"); + + await waitFor(`http://127.0.0.1:${vitePort}/api/health`); + console.log("[dogfood:setup] vite ready"); + + // ── 3. register the test user ── + const base = `http://127.0.0.1:${vitePort}`; + const api = await request.newContext({ baseURL: base }); + + const reg = await api.post("/api/auth/register", { + data: { username: TEST_USER, password: TEST_PASSWORD }, + }); + const regBody = await reg.json(); + if (!regBody.user) throw new Error(`register failed: ${JSON.stringify(regBody)}`); + const userId = regBody.user.id as string; + console.log(`[dogfood:setup] user: ${userId} (${regBody.user.role}/${regBody.user.status})`); + + // ── 4. preconfigure ALREADY-ONBOARDED: personal config + vault ── + const personalLoopat = join(loopatHome, "personal", userId, ".loopat"); + const vaultDir = join(personalLoopat, "vaults", "default"); + + // Self-contained vault — NOTHING copied from any real vault. + // (a) fresh ssh keypair for this run; its pubkey goes into the fixture's + // authorized_keys (step 5). Standard name id_ed25519 so ssh auto-resolves. + const sshDir = join(vaultDir, "mounts", "home", ".ssh"); + mkdirSync(sshDir, { recursive: true }); + execFileSync("ssh-keygen", ["-t", "ed25519", "-N", "", "-q", "-C", "dogfood-fixture", "-f", join(sshDir, "id_ed25519")]); + chmodSync(join(sshDir, "id_ed25519"), 0o600); + // (b) the ONE real secret a fixture can't fake — the provider key — comes from + // the env, written into this temp (git-ignored, teardown-deleted) vault so + // the loop's ${IDEALAB_API_KEY} resolves. Never read from disk, never committed. + const envsDir = join(vaultDir, "envs"); + mkdirSync(envsDir, { recursive: true }); + writeFileSync(join(envsDir, "IDEALAB_API_KEY"), (process.env.IDEALAB_API_KEY ?? "") + "\n"); + writeFileSync( + join(sshDir, "config"), + [ + "Host loopat-fixture", + ` HostName ${hostIp}`, + ` Port ${sshdPort}`, + " User git", + " IdentityFile ~/.ssh/id_ed25519", + " IdentitiesOnly yes", + " StrictHostKeyChecking accept-new", + " UserKnownHostsFile /dev/null", + "", + "Host *", + " StrictHostKeyChecking accept-new", + "", + ].join("\n"), + ); + // OpenSSH SILENTLY IGNORES ~/.ssh/config if it is group- or world-writable + // (and the dir if it's group/world-writable). writeFileSync/mkdirSync leave + // 0664/0775 here, so the loopat-fixture Host alias would never apply inside + // the sandbox — ssh would try to resolve the literal hostname "loopat-fixture" + // and fail. Lock both down so the alias (HostName/Port/IdentityFile) is read. + chmodSync(join(sshDir, "config"), 0o600); + chmodSync(sshDir, 0o700); + + // Pre-trust the fixture's host key. Remotes use absolute `ssh://git@<ip>:<port>` + // URLs (no Host alias — the mounted ssh config may not take effect inside the + // sandbox), so a host-key prompt would hang the non-interactive `git push`. + // Seed known_hosts directly from the running fixture so ssh trusts it. + try { + const scan = execFileSync("ssh-keyscan", ["-p", String(sshdPort), hostIp]).toString(); + writeFileSync(join(sshDir, "known_hosts"), scan); + chmodSync(join(sshDir, "known_hosts"), 0o644); + } catch (e) { + console.warn(`[dogfood:setup] ssh-keyscan failed (host-key prompt may hang the push): ${e}`); + } + + // Personal config: idealab provider (apiKey resolved from vault), the roster + // repo + knowledge pointer at the fixture. + const personalConfig = { + providers: { + default: "idealab/claude-opus-4-7", + idealab: { + models: [{ id: "claude-opus-4-7", enabled: true }], + baseUrl: "https://idealab.alibaba-inc.com/api/anthropic", + apiKey: "${IDEALAB_API_KEY}", + maxContextTokens: 1000000, + enabled: true, + }, + }, + knowledge: { git: `ssh://git@${hostIp}:${sshdPort}/srv/git/knowledge.git` }, + repos: [ + { name: "roster1", git: `ssh://git@${hostIp}:${sshdPort}/srv/git/roster1.git` }, + ], + }; + mkdirSync(personalLoopat, { recursive: true }); + writeFileSync( + join(personalLoopat, "config.json"), + JSON.stringify(personalConfig, null, 2) + "\n", + ); + console.log(`[dogfood:setup] preconfigured onboarded vault at ${vaultDir}`); + + // ── 5. seed the fixture with the vault pubkey + bare repos ── + const pubkey = readFileSync(join(sshDir, "id_ed25519.pub"), "utf8").trim(); + // Pass the absolute ssh base so seed.sh writes the notes pointer as an + // env-agnostic `ssh://git@<ip>:<port>/srv/git/notes.git` (no Host alias — + // resolves identically in first-5-minutes and first-run). + const notesSshBase = `ssh://git@${hostIp}:${sshdPort}`; + const seedOut = execFileSync( + "podman", + ["exec", fixtureContainer, "/seed.sh", pubkey, notesSshBase], + ).toString().trim(); + console.log(`[dogfood:setup] fixture seed: ${seedOut}`); + + // ── 6. save cookies for the browser spec ── + const state = await api.storageState(); + writeFileSync(join(import.meta.dirname, ".auth.json"), JSON.stringify(state, null, 2)); + console.log(`[dogfood:setup] saved ${state.cookies.length} cookie(s)`); + + await api.dispose(); +} + +export default globalSetup; diff --git a/dogfood/sync/README.md b/dogfood/sync/README.md new file mode 100644 index 00000000..bfe94e26 --- /dev/null +++ b/dogfood/sync/README.md @@ -0,0 +1,41 @@ +# dogfood/sync — context flow across two independent servers + +The fourth tier. Where every other dogfood tier runs ONE loopat install, this +boots **two** — server A (alice) and server B (bob), each its own LOOPAT_HOME, +backend, vite, user, and self-contained vault — that share **one** fixture sshd +git origin. It proves the central claim of `docs/context-flow.md`: a server is a +disposable replica of `origin`, multi-user across servers is the same mechanism +as multi-user on one server, and everyone converges on the SoT. + +Each case writes context on A, lands it on the shared origin, and proves B +converges. Integration truth = the fixture's bare repos read via `podman exec`; +B's own server is the cross-check. The notes/personal "UI loop" is no-AI +(`PUT /api/workspace/file?vault=notes` + `POST /api/notes/save` = ff+rebase +push); S3 swaps that for a real loop AI. Preconditions FAIL-not-skip: `podman`, +`IDEALAB_API_KEY`, `FIRST_RUN_AI_BASE_URL`. + +## Cases + +| Case | Proves | +|------|--------| +| S0 | both servers boot and clone the shared origin | +| S1 | shared repo: A edits notes via UI → push origin → B sees it | +| S2 | shared kn advances → B sees it at LOOP level (B spins a loop, sandbox clones kn from origin, `podman exec cat`); A's personal note stays isolated to A | +| S4 | different files concurrently → git auto-merges, both servers have both | +| S5 | same-file conflict outside-loop → first lands, second ff-fails → kept-local + held back, NOT on SoT (the soul case) | +| S6 | held-back recovery: only `save`/`behind`/`refresh` exist — no discard/force endpoint. take-remote (B rewrites file to origin's content) converges B's view; keep-mine is genuinely impossible (sticky conflicting commit → save always 409); SoT stays "A wins", no buried conflict | +| S7 | deletion propagation: A deletes+saves a note → deletion commit lands origin → B refresh → gone on B; a delete converges like an edit | +| S3 | the writer is a real loop AI on A → B converges at LOOP level (B spins a loop, sandbox clones notes from origin, `podman exec cat` shows the file) (runs last; one idealab turn) | + +## Run + +```sh +export IDEALAB_API_KEY=$(cat ~/.loopat/personal/<you>/.loopat/vaults/default/envs/IDEALAB_API_KEY) +export FIRST_RUN_AI_BASE_URL=https://idealab.alibaba-inc.com/api/anthropic +bun run dogfood:sync +``` + +Five host ports (24001+) and two temp LOOPAT_HOMEs are picked in +`playwright.config.ts`; the fixture + both backends come up in `setup.ts`; +`workers:1` (the two servers share one origin). All urls/keys via env, never +committed. diff --git a/dogfood/sync/loop-helper.ts b/dogfood/sync/loop-helper.ts new file mode 100644 index 00000000..e1cbb86a --- /dev/null +++ b/dogfood/sync/loop-helper.ts @@ -0,0 +1,54 @@ +/** + * Shared B-loop helper for S2/S3: create a real loop on server B through the UI, + * wait for its sandbox to come up, and `podman exec` into B's container to read a + * file that the sandbox cloned from the SHARED origin. This is how B "sees" kn — + * knowledge has no UI pull endpoint; it's only cloned into a sandbox at loop + * creation. So loop-level convergence is the only honest proof. + */ +import { expect, type Browser } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; + +export function runningContainers(loopId: string): string[] { + return execFileSync("podman", ["ps", "--filter", `label=loopat.loop-id=${loopId}`, "--filter", "status=running", "--format", "{{.Names}}"]).toString().split("\n").map(s => s.trim()).filter(Boolean); +} + +export function cleanup(loopId: string) { + if (!loopId) return; + try { + const ids = execFileSync("podman", ["ps", "-a", "--filter", `label=loopat.loop-id=${loopId}`, "--format", "{{.ID}}"]).toString().split("\n").map(s => s.trim()).filter(Boolean); + if (ids.length) execFileSync("podman", ["rm", "-f", ...ids]); + } catch {} +} + +/** Read a file inside a loop's (first) running sandbox container. */ +export function sandboxRead(loopId: string, path: string): string { + const [name] = runningContainers(loopId); + if (!name) throw new Error(`no running container for loop ${loopId}`); + return execFileSync("podman", ["exec", name, "cat", path]).toString().trim(); +} + +/** Create a loop on server B (own context + bob's auth so the cookie is B's), + * pick roster1, wait for its sandbox to be running. Returns the loop id. The + * caller is responsible for cleanup(loopId). */ +export async function createBLoopAndWaitSandbox(browser: Browser, bVite: number, title: string): Promise<string> { + const baseURL = `http://127.0.0.1:${bVite}`; + const ctx = await browser.newContext({ baseURL, storageState: join(import.meta.dirname, ".authB.json") }); + const page = await ctx.newPage(); + await page.addInitScript(() => localStorage.setItem("loopat:setupPersonalRepoDismissed", "1")); + await page.goto(`${baseURL}/loop`); + await expect(page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first()).toBeVisible({ timeout: 20_000 }); + const createResp = page.waitForResponse(r => r.url().includes("/api/v1/loops") && r.request().method() === "POST", { timeout: 30_000 }); + await page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first().click(); + await expect(page.getByText("New loop", { exact: true })).toBeVisible({ timeout: 10_000 }); + await page.getByRole("combobox").first().selectOption("roster1"); + await page.getByPlaceholder("refactor-gateway").fill(title); + await page.getByRole("button", { name: "create", exact: true }).click(); + const loopId = String((await (await createResp).json()).id ?? "").replace(/^loop_/, ""); + expect(loopId).toMatch(/^[a-f0-9-]+$/); + await page.getByRole("button", { name: /terminal/ }).first().click(); + await expect.poll(() => runningContainers(loopId), { timeout: 300_000, intervals: [1000, 2000, 5000] }).not.toEqual([]); + await expect(page.getByText("Preparing this loop’s sandbox…")).toBeHidden({ timeout: 300_000 }); + await ctx.close(); + return loopId; +} diff --git a/dogfood/sync/playwright.config.ts b/dogfood/sync/playwright.config.ts new file mode 100644 index 00000000..08fafb1d --- /dev/null +++ b/dogfood/sync/playwright.config.ts @@ -0,0 +1,124 @@ +/** + * dogfood/sync — context flow across TWO independent loopat servers. + * + * Unlike every other dogfood tier (one server, one LOOPAT_HOME), this boots a + * pair of fully independent loopat installs — server A and server B, each with + * its own LOOPAT_HOME, backend, and vite — that share ONE fixture sshd as the + * common git origin. Each side registers its own user, builds its own + * self-contained vault (fresh ed25519 + IDEALAB_API_KEY from env), and both + * pubkeys go into the fixture's authorized_keys. The cases drive A, push to + * origin, and prove the change converges on B — context flow in the flesh. + * + * Five host ports: A_back A_vite B_back B_vite sshd (24001+, away from the + * other tiers: e2e 20001, smoke 22001, first-run 23001). TWO temp LOOPAT_HOMEs. + * podman / IDEALAB_API_KEY / FIRST_RUN_AI_BASE_URL missing -> FAIL, never skip. + * + * Ports + dirs are decided here at config-load time and recorded in + * sync/.test-meta.json; the fixture container + both backends come up in + * setup.ts (Playwright loads this config twice — discovery + runner — so the + * fixture must NOT start here or the second load collides on the port). workers:1 + * — the two servers share one fixture origin; cases run serially. + */ +import { defineConfig } from "@playwright/test"; +import { mkdtempSync, writeFileSync, readFileSync, renameSync } from "node:fs"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { createServer } from "node:net"; +import { execFileSync } from "node:child_process"; + +const META = join(import.meta.dirname, ".test-meta.json"); + +function requireCmd(cmd: string, args: string[], hint: string): void { + try { + execFileSync(cmd, args, { stdio: "ignore" }); + } catch { + throw new Error(`[dogfood:sync] ${hint}`); + } +} + +function requireEnv(name: string, hint: string): void { + if (!process.env[name] || !process.env[name]!.trim()) { + throw new Error(`[dogfood:sync] ${name} not set — ${hint}`); + } +} + +function tryPort(port: number): boolean { + try { + const s = createServer(); + s.listen(port, "127.0.0.1"); + s.close(); + return true; + } catch { + return false; + } +} + +function pickPorts(): { aBack: number; aVite: number; bBack: number; bVite: number; sshdPort: number } { + // 24001+ range — away from e2e (20001), smoke (22001), first-run (23001). + for (let p = 24001; p < 25000; p += 5) { + if (tryPort(p) && tryPort(p + 1) && tryPort(p + 2) && tryPort(p + 3) && tryPort(p + 4)) { + return { aBack: p, aVite: p + 1, bBack: p + 2, bVite: p + 3, sshdPort: p + 4 }; + } + } + throw new Error("no free port quintuple found in 24001-25000"); +} + +requireCmd("podman", ["--version"], "podman not found — this test runs a real sshd container and must not be skipped"); +requireEnv("IDEALAB_API_KEY", "export it before running (real AI needs a real key; we never read it from disk)"); +requireEnv( + "FIRST_RUN_AI_BASE_URL", + "set the AI provider base url (seeded into each server's personal config; never bake an internal endpoint into committed files)", +); + +const isWorker = process.env.TEST_WORKER_INDEX !== undefined; + +let aBack = 0, aVite = 0, bBack = 0, bVite = 0, sshdPort = 0; +let homeA = "", homeB = ""; + +function mkLower(prefix: string): string { + // podman rejects uppercase in image tags (loopat-sandbox-<basename>); mkdtemp + // suffix is mixed-case → lowercase the basename. + const raw = mkdtempSync(join(tmpdir(), prefix)); + const lower = join(tmpdir(), basename(raw).toLowerCase()); + if (lower !== raw) renameSync(raw, lower); + return lower; +} + +if (isWorker) { + const m = JSON.parse(readFileSync(META, "utf8")); + ({ aBack, aVite, bBack, bVite, sshdPort, homeA, homeB } = m); +} else { + ({ aBack, aVite, bBack, bVite, sshdPort } = pickPorts()); + homeA = mkLower("loopat-sync-a-"); + homeB = mkLower("loopat-sync-b-"); + writeFileSync(META, JSON.stringify({ homeA, homeB, aBack, aVite, bBack, bVite, sshdPort })); +} + +export default defineConfig({ + testDir: import.meta.dirname, + timeout: 420_000, + retries: 0, + workers: 1, + globalSetup: "./setup.ts", + globalTeardown: "./teardown.ts", + use: { + baseURL: `http://127.0.0.1:${aVite}`, + trace: "on-first-retry", + screenshot: "only-on-failure", + storageState: join(import.meta.dirname, ".authA.json"), + }, + // BOTH vites are started by playwright; A is the default baseURL, B is hit by + // absolute url. reuseExistingServer:false so a stale vite never masks a port. + webServer: [ + { + command: `env ENV=test HOST=127.0.0.1 PORT=${aBack} bun --cwd=${join(import.meta.dirname, "..", "..", "web")} run dev -- --port ${aVite}`, + port: aVite, + reuseExistingServer: false, + }, + { + command: `env ENV=test HOST=127.0.0.1 PORT=${bBack} bun --cwd=${join(import.meta.dirname, "..", "..", "web")} run dev -- --port ${bVite}`, + port: bVite, + reuseExistingServer: false, + }, + ], +}); diff --git a/dogfood/sync/setup.ts b/dogfood/sync/setup.ts new file mode 100644 index 00000000..19b314eb --- /dev/null +++ b/dogfood/sync/setup.ts @@ -0,0 +1,172 @@ +/** + * dogfood/sync global setup — boot TWO independent ALREADY-ONBOARDED loopat + * servers that share ONE fixture sshd origin. + * + * Mirrors dogfood/setup.ts, but does it twice and behind one shared fixture: + * 0. build + run ONE fixture sshd = the shared origin; seed kn/notes/roster1. + * 1. For EACH server (A, B): isolated LOOPAT_HOME, workspace config -> the + * shared fixture, a backend on its own port, a registered user, a SELF- + * CONTAINED vault (fresh ed25519 + IDEALAB_API_KEY from env) pointing at + * the SAME team kn/notes/roster repos. BOTH pubkeys are appended to the + * fixture authorized_keys so either server can push. + * 2. Save two storageStates (.authA.json / .authB.json) for the spec. + * + * Both servers point at the same knowledge/notes/roster repos: that's the + * "shared personal repo" of S1 — same kn/notes pointers. The cases that need + * isolation (S2 personal) write their own per-server bits at runtime. + * + * Preconditions are FAIL-not-skip (enforced in the config): podman, + * IDEALAB_API_KEY, FIRST_RUN_AI_BASE_URL. + */ +import { request } from "@playwright/test"; +import { spawn, execSync, execFileSync } from "node:child_process"; +import { + readFileSync, writeFileSync, mkdirSync, chmodSync, realpathSync, +} from "node:fs"; +import { join } from "node:path"; + +const META = join(import.meta.dirname, ".test-meta.json"); +const FIXTURE_IMAGE = "loopat-sync-sshd:latest"; +const FIXTURE_DIR = join(import.meta.dirname, "..", "first-5-minutes", "fixtures"); + +const TEST_PASSWORD = "test123"; + +async function waitFor(url: string, timeoutMs = 60_000): Promise<void> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const r = await fetch(url); + if (r.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`timed out waiting for ${url}`); +} + +type Meta = { + homeA: string; homeB: string; + aBack: number; aVite: number; bBack: number; bVite: number; sshdPort: number; +}; + +/** Bring up one loopat server (workspace config + backend + user + vault) on an + * isolated LOOPAT_HOME against the shared fixture. Returns the user id + vault + * pubkey + backend pid + storage state, so the caller can seed authorized_keys + * with both keys and save per-server cookies. */ +async function bringUpServer(opts: { + tag: string; home: string; back: number; vite: number; + user: string; hostIp: string; sshdPort: number; +}): Promise<{ pid: number; pubkey: string; state: any }> { + const { tag, home, back, vite, user, hostIp, sshdPort } = opts; + const sshBase = `ssh://git@${hostIp}:${sshdPort}`; + + // workspace config: knowledge + gitHost at the shared fixture + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, "config.json"), JSON.stringify({ + knowledge: { git: `${sshBase}/srv/git/knowledge.git` }, + gitHost: { baseUrl: sshBase }, + }, null, 2) + "\n"); + + // backend + try { execSync(`fuser -k ${back}/tcp 2>/dev/null || true`, { stdio: "ignore" }); } catch {} + const serverDir = realpathSync(join(import.meta.dirname, "..", "..", "server")); + const server = spawn("bun", ["run", "src/index.ts"], { + cwd: serverDir, + env: { ...process.env, ENV: "test", NODE_ENV: "production", LOOPAT_HOME: home, LOOPAT_SERVE_PORT: "0", PORT: String(back), HOST: "127.0.0.1" }, + stdio: "pipe", + }); + server.stdout?.on("data", (d) => process.stdout.write(`[${tag}] ${d}`)); + server.stderr?.on("data", (d) => process.stderr.write(`[${tag}] ${d}`)); + await waitFor(`http://127.0.0.1:${back}/api/health`); + await waitFor(`http://127.0.0.1:${vite}/api/health`); + console.log(`[dogfood:sync] ${tag} backend+vite ready (:${back}/:${vite})`); + + // register the user + const base = `http://127.0.0.1:${vite}`; + const api = await request.newContext({ baseURL: base }); + const reg = await api.post("/api/auth/register", { data: { username: user, password: TEST_PASSWORD } }); + const regBody = await reg.json(); + if (!regBody.user) throw new Error(`${tag} register failed: ${JSON.stringify(regBody)}`); + const userId = regBody.user.id as string; + console.log(`[dogfood:sync] ${tag} user ${userId} (${regBody.user.role}/${regBody.user.status})`); + + // self-contained onboarded vault: fresh ssh keypair + IDEALAB_API_KEY + const personalLoopat = join(home, "personal", userId, ".loopat"); + const vaultDir = join(personalLoopat, "vaults", "default"); + const sshDir = join(vaultDir, "mounts", "home", ".ssh"); + mkdirSync(sshDir, { recursive: true }); + execFileSync("ssh-keygen", ["-t", "ed25519", "-N", "", "-q", "-C", `dogfood-sync-${tag}`, "-f", join(sshDir, "id_ed25519")]); + chmodSync(join(sshDir, "id_ed25519"), 0o600); + const envsDir = join(vaultDir, "envs"); + mkdirSync(envsDir, { recursive: true }); + writeFileSync(join(envsDir, "IDEALAB_API_KEY"), (process.env.IDEALAB_API_KEY ?? "") + "\n"); + writeFileSync(join(sshDir, "config"), [ + "Host loopat-fixture", ` HostName ${hostIp}`, ` Port ${sshdPort}`, " User git", + " IdentityFile ~/.ssh/id_ed25519", " IdentitiesOnly yes", + " StrictHostKeyChecking accept-new", " UserKnownHostsFile /dev/null", "", + "Host *", " StrictHostKeyChecking accept-new", "", + ].join("\n")); + chmodSync(join(sshDir, "config"), 0o600); + chmodSync(sshDir, 0o700); + try { + writeFileSync(join(sshDir, "known_hosts"), execFileSync("ssh-keyscan", ["-p", String(sshdPort), hostIp]).toString()); + chmodSync(join(sshDir, "known_hosts"), 0o644); + } catch (e) { console.warn(`[dogfood:sync] ${tag} ssh-keyscan failed: ${e}`); } + + // personal config: idealab provider + shared kn/notes/roster pointers + writeFileSync(join(personalLoopat, "config.json"), JSON.stringify({ + providers: { + default: "idealab/claude-opus-4-7", + idealab: { + models: [{ id: "claude-opus-4-7", enabled: true }], + baseUrl: process.env.FIRST_RUN_AI_BASE_URL, + apiKey: "${IDEALAB_API_KEY}", + maxContextTokens: 1000000, enabled: true, + }, + }, + knowledge: { git: `${sshBase}/srv/git/knowledge.git` }, + repos: [{ name: "roster1", git: `${sshBase}/srv/git/roster1.git` }], + }, null, 2) + "\n"); + + const pubkey = readFileSync(join(sshDir, "id_ed25519.pub"), "utf8").trim(); + const state = await api.storageState(); + await api.dispose(); + return { pid: server.pid!, pubkey, state }; +} + +async function globalSetup() { + const meta = JSON.parse(readFileSync(META, "utf8")) as Meta; + const { homeA, homeB, aBack, aVite, bBack, bVite, sshdPort } = meta; + console.log(`[dogfood:sync] A HOME=${homeA} B HOME=${homeB}`); + console.log(`[dogfood:sync] A :${aBack}/:${aVite} B :${bBack}/:${bVite} sshd :${sshdPort}`); + + // 0. one fixture sshd = shared origin + const hostIp = execSync("ip route get 1.1.1.1").toString().match(/src\s+(\d+\.\d+\.\d+\.\d+)/)?.[1]; + if (!hostIp) throw new Error("[dogfood:sync] could not determine host default-route IP"); + console.log(`[dogfood:sync] building ${FIXTURE_IMAGE}`); + execFileSync("podman", ["build", "-t", FIXTURE_IMAGE, FIXTURE_DIR], { stdio: "inherit" }); + const fixtureContainer = execFileSync("podman", ["run", "-d", "-p", `0.0.0.0:${sshdPort}:22`, FIXTURE_IMAGE]).toString().trim(); + console.log(`[dogfood:sync] fixture up: ${fixtureContainer.slice(0, 12)} on ${hostIp}:${sshdPort}`); + writeFileSync(META, JSON.stringify({ ...meta, fixtureContainer, hostIp })); + + // 1. seed bare repos with EMPTY authorized_keys; both vault keys added later + const seedOut = execFileSync("podman", ["exec", fixtureContainer, "/seed.sh", "", `ssh://git@${hostIp}:${sshdPort}`]).toString().trim(); + console.log(`[dogfood:sync] fixture seed: ${seedOut}`); + + // 2. bring up both servers + const A = await bringUpServer({ tag: "A", home: homeA, back: aBack, vite: aVite, user: "alice", hostIp, sshdPort }); + const B = await bringUpServer({ tag: "B", home: homeB, back: bBack, vite: bVite, user: "bob", hostIp, sshdPort }); + writeFileSync(META, JSON.stringify({ ...meta, fixtureContainer, hostIp, pidA: A.pid, pidB: B.pid })); + + // 3. seed BOTH pubkeys into authorized_keys + execFileSync("podman", ["exec", "-i", fixtureContainer, "sh", "-c", + "cat >> /home/git/.ssh/authorized_keys && chown git:git /home/git/.ssh/authorized_keys && chmod 600 /home/git/.ssh/authorized_keys", + ], { input: A.pubkey + "\n" + B.pubkey + "\n" }); + console.log("[dogfood:sync] both vault pubkeys seeded into fixture authorized_keys"); + + // 4. save storage states + writeFileSync(join(import.meta.dirname, ".authA.json"), JSON.stringify(A.state, null, 2)); + writeFileSync(join(import.meta.dirname, ".authB.json"), JSON.stringify(B.state, null, 2)); + console.log("[dogfood:sync] saved .authA.json / .authB.json"); +} + +export default globalSetup; diff --git a/dogfood/sync/sync.spec.ts b/dogfood/sync/sync.spec.ts new file mode 100644 index 00000000..6d61c539 --- /dev/null +++ b/dogfood/sync/sync.spec.ts @@ -0,0 +1,171 @@ +/** + * dogfood/sync — context flow across TWO independent loopat servers (S1-S5). + * + * Server A (alice) and server B (bob) are separate installs sharing ONE fixture + * sshd origin. Every case writes context on A, lands it on origin, and proves B + * converges. Integration truth = the fixture's bare repos read via `podman exec`; + * B's own view is the cross-check. The notes/personal UI loop is no-AI + * (workspace file write + /api/notes/save = ff+rebase push), exactly the docs/ + * context-flow.md model. S3 swaps the UI write for a real loop AI edit. + */ +import { test, expect, request, type APIRequestContext } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createBLoopAndWaitSandbox, sandboxRead, cleanup } from "./loop-helper"; + +type Meta = { + aVite: number; bVite: number; fixtureContainer: string; hostIp: string; homeB: string; +}; +function meta(): Meta { return JSON.parse(readFileSync(join(import.meta.dirname, ".test-meta.json"), "utf8")); } + +function ctn(): string { return meta().fixtureContainer; } +function fixtureNotesLog(): string { + return execFileSync("podman", ["exec", ctn(), "git", "-c", "safe.directory=*", "-C", "/srv/git/notes.git", "log", "--oneline", "--all"]).toString().trim(); +} +function fixtureKnowledgeLog(): string { + return execFileSync("podman", ["exec", ctn(), "git", "-c", "safe.directory=*", "-C", "/srv/git/knowledge.git", "log", "--oneline", "--all"]).toString().trim(); +} +function fixtureRosterLog(): string { + return execFileSync("podman", ["exec", ctn(), "git", "-c", "safe.directory=*", "-C", "/srv/git/roster1.git", "log", "--oneline", "--all"]).toString().trim(); +} + +async function ctx(vite: number, auth: string): Promise<APIRequestContext> { + return request.newContext({ baseURL: `http://127.0.0.1:${vite}`, storageState: join(import.meta.dirname, auth) }); +} +async function readNote(api: APIRequestContext, path: string): Promise<string | null> { + const r = await api.get(`/api/workspace/file?vault=notes&path=${encodeURIComponent(path)}`); + if (!r.ok()) return null; + return (await r.json()).content; +} +async function writeNote(api: APIRequestContext, path: string, content: string) { + const r = await api.put(`/api/workspace/file?vault=notes&path=${encodeURIComponent(path)}`, { data: { content } }); + expect(r.ok(), `write ${path}: ${r.status()}`).toBeTruthy(); +} +async function saveNotes(api: APIRequestContext) { return api.post("/api/notes/save"); } +async function refreshNotes(api: APIRequestContext) { return api.post("/api/notes/refresh"); } +async function deleteNote(api: APIRequestContext, path: string) { return api.delete(`/api/workspace/file?vault=notes&path=${encodeURIComponent(path)}`); } +async function discardNotes(api: APIRequestContext) { return api.post("/api/notes/discard"); } + +let aApi: APIRequestContext, bApi: APIRequestContext; +test.beforeAll(async () => { const m = meta(); aApi = await ctx(m.aVite, ".authA.json"); bApi = await ctx(m.bVite, ".authB.json"); }); +test.afterAll(async () => { await aApi.dispose(); await bApi.dispose(); }); + +// Prime each server's notes worktree (clone from shared origin). +test("S0 two-server: both servers reach the shared origin", async () => { + await refreshNotes(aApi); await refreshNotes(bApi); + expect((await aApi.get("/api/notes/behind")).ok()).toBeTruthy(); + expect((await bApi.get("/api/notes/behind")).ok()).toBeTruthy(); + console.log("[sync] S0 both servers cloned shared notes origin"); +}); + +test("S1 shared personal repo: A edits notes via UI -> origin -> B sees it", async () => { + const stamp = Date.now(); + const file = `s1-${stamp}.md`, body = `s1 from A ${stamp}`; + await writeNote(aApi, file, body); + const save = await saveNotes(aApi); + expect(save.ok(), `A save: ${save.status()} ${await save.text()}`).toBeTruthy(); + await expect.poll(fixtureNotesLog, { timeout: 60_000, intervals: [1000, 2000] }).toContain(file); + await expect.poll(async () => { await refreshNotes(bApi); return readNote(bApi, file); }, { timeout: 60_000, intervals: [2000, 3000] }).toBe(body); + console.log("[sync] S1 GREEN: A->origin->B notes converged"); +}); + +let s2LoopId = ""; +test.afterAll(() => cleanup(s2LoopId)); +test("S2 same kn shared, personal isolated: A advances knowledge -> B sees at loop level; personal stays local", async ({ browser }) => { + test.setTimeout(420_000); + const stamp = Date.now(); + const beforeK = fixtureKnowledgeLog(); + await writeNote(aApi, `s2-personal-A-${stamp}.md`, "alice-only"); + // A advances kn on origin: this direct-podman kn push stands in for a distill + // (kn promote via loop is gated). A's personal note does NOT cross to B. + execFileSync("podman", ["exec", ctn(), "sh", "-c", + `set -e; export HOME=/root GIT_AUTHOR_NAME=fx GIT_AUTHOR_EMAIL=f@l GIT_COMMITTER_NAME=fx GIT_COMMITTER_EMAIL=f@l; git config --global --add safe.directory '*'; d=$(mktemp -d); git clone -q /srv/git/knowledge.git $d/k; echo kn-${stamp} > $d/k/s2-kn.md; git -C $d/k add -A; git -C $d/k commit -qm 'kn ${stamp}'; git -C $d/k push -q origin master`]); + expect(fixtureKnowledgeLog()).not.toBe(beforeK); + // B SEES shared kn only through a loop: kn has no UI pull endpoint (read-only- + // to-others, cloned only into a sandbox at loop creation). So B spins a real + // loop; its sandbox clones kn from the SHARED origin; we exec in and read it. + const m = meta(); + s2LoopId = await createBLoopAndWaitSandbox(browser, m.bVite, `s2-${stamp}`); + expect(sandboxRead(s2LoopId, "/loopat/context/knowledge/s2-kn.md")).toBe(`kn-${stamp}`); + console.log(`[sync] S2 B loop sandbox read /loopat/context/knowledge/s2-kn.md = kn-${stamp}`); + // Personal isolation: B's notes worktree must NOT carry A's personal note. + await refreshNotes(bApi); + expect(await readNote(bApi, `s2-personal-A-${stamp}.md`)).toBeNull(); + console.log("[sync] S2 GREEN: B sees shared kn at loop level + personal note isolated to A"); +}); + +test("S4 concurrent different files: A and B push different notes -> both land", async () => { + const stamp = Date.now(); + const fa = `s4-A-${stamp}.md`, fb = `s4-B-${stamp}.md`; + await refreshNotes(aApi); await refreshNotes(bApi); + await writeNote(aApi, fa, "A4"); const sa = await saveNotes(aApi); expect(sa.ok()).toBeTruthy(); + await writeNote(bApi, fb, "B4"); const sb = await saveNotes(bApi); expect(sb.ok(), `B save: ${sb.status()} ${await sb.text()}`).toBeTruthy(); + await refreshNotes(aApi); + expect(await readNote(aApi, fa)).toBe("A4"); + await expect.poll(async () => { await refreshNotes(aApi); return readNote(aApi, fb); }, { timeout: 30_000 }).toBe("B4"); + await expect.poll(async () => { await refreshNotes(bApi); return readNote(bApi, fa); }, { timeout: 30_000 }).toBe("A4"); + console.log("[sync] S4 GREEN: different-file concurrent pushes auto-merged, both servers have both"); +}); + +test("S5 same-file conflict: first lands, second held-back kept-local NOT on SoT", async () => { + const stamp = Date.now(), file = `s5-${stamp}.md`; + await refreshNotes(aApi); await refreshNotes(bApi); + await writeNote(aApi, file, "A wins"); expect((await saveNotes(aApi)).ok()).toBeTruthy(); + await expect.poll(fixtureNotesLog, { timeout: 30_000 }).toContain(file); + // B edits the SAME file on its stale base, then saves -> ff fails, held back. + await writeNote(bApi, file, "B conflicting"); const sb = await saveNotes(bApi); + expect(sb.status(), "B's same-file save must be held back (409)").toBe(409); + const body = await sb.json(); expect(body.conflict, JSON.stringify(body)).toBeTruthy(); + // kept-local: B still has its draft; SoT (origin via A) keeps A's content. + expect(await readNote(bApi, file)).toBe("B conflicting"); + await refreshNotes(aApi); expect(await readNote(aApi, file)).toBe("A wins"); + console.log("[sync] S5 GREEN: first landed, second held-back + kept-local, NOT on SoT"); +}); + +// S6 — held-back RECOVERY: S5's other half. Set up the SAME conflict, then prove +// the product's take-remote escape hatch (POST /api/notes/discard) clears it: +// 1. B held-back (409, sticky conflicting commit, every re-save still 409). +// 2. take-remote: discard drops B's edit + resets to origin → B reads "A wins", +// held-back gone, B can save fresh edits again. SoT stays exactly "A wins". +// (keep-mine — landing B's content over A's — is the loop-AI-merge path, not the +// no-AI UI loop; out of scope for the minimal discard fix.) +test("S6 held-back recovery: discard (take-remote) clears the conflict, SoT stays clean", async () => { + const stamp = Date.now(), file = `s6-${stamp}.md`; + await refreshNotes(aApi); await refreshNotes(bApi); + await writeNote(aApi, file, "A wins"); expect((await saveNotes(aApi)).ok()).toBeTruthy(); + await expect.poll(fixtureNotesLog, { timeout: 30_000 }).toContain(file); + await refreshNotes(bApi); + await writeNote(bApi, file, "B conflicting"); + expect((await saveNotes(bApi)).status(), "B held-back").toBe(409); + expect((await saveNotes(bApi)).status(), "still sticky before discard").toBe(409); + // take-remote: the real endpoint clears the held-back state. + expect((await discardNotes(bApi)).ok(), "discard").toBeTruthy(); + expect(await readNote(bApi, file)).toBe("A wins"); + // held-back cleared: a fresh edit on a DIFFERENT file now lands cleanly. + await writeNote(bApi, `s6-after-${stamp}.md`, "B unblocked"); + expect((await saveNotes(bApi)).ok(), "B saves again after discard").toBeTruthy(); + await refreshNotes(aApi); expect(await readNote(aApi, file)).toBe("A wins"); + console.log("[sync] S6 GREEN: discard cleared held-back, B back in sync, SoT = A wins"); +}); + +// S7 — deletion propagation: a delete converges like an edit. A creates+saves a +// note (lands origin, B sees it), then DELETEs + saves; B refresh → gone, and the +// origin log carries the deletion commit. +test("S7 deletion propagation: A deletes a note -> origin -> B sees it gone", async () => { + const stamp = Date.now(), file = `s7-${stamp}.md`; + // S5/S6 may leave B's worktree held-back; the real escape hatch is discard. + await discardNotes(bApi); + await refreshNotes(aApi); await refreshNotes(bApi); + await writeNote(aApi, file, "to be deleted"); expect((await saveNotes(aApi)).ok()).toBeTruthy(); + await expect.poll(fixtureNotesLog, { timeout: 30_000 }).toContain(file); + await expect.poll(async () => { await refreshNotes(bApi); return readNote(bApi, file); }, { timeout: 30_000 }).toBe("to be deleted"); + const logBefore = fixtureNotesLog(); + // A deletes the file and saves -> deletion lands on origin as its own commit. + expect((await deleteNote(aApi, file)).ok(), "A delete").toBeTruthy(); + expect((await saveNotes(aApi)).ok(), "A save deletion").toBeTruthy(); + await expect.poll(fixtureNotesLog, { timeout: 30_000 }).not.toBe(logBefore); + // B refresh -> gone, like any edit. + await expect.poll(async () => { await refreshNotes(bApi); return readNote(bApi, file); }, { timeout: 30_000 }).toBeNull(); + console.log("[sync] S7 GREEN: deletion converged A->origin->B, gone on both + deletion commit on SoT"); +}); diff --git a/dogfood/sync/teardown.ts b/dogfood/sync/teardown.ts new file mode 100644 index 00000000..883fafe8 --- /dev/null +++ b/dogfood/sync/teardown.ts @@ -0,0 +1,40 @@ +/** + * dogfood/sync teardown — reap everything setup brought up: the shared fixture + * sshd, BOTH backends, BOTH per-server podman networks, all loop containers + * spawned across either server, and both temp LOOPAT_HOMEs. Test-only resources. + */ +import { readFileSync, rmSync } from "node:fs"; +import { basename, join } from "node:path"; +import { execSync, execFileSync } from "node:child_process"; + +const META = join(import.meta.dirname, ".test-meta.json"); + +async function globalTeardown() { + try { + const meta = JSON.parse(readFileSync(META, "utf8")); + + // fixture container + if (meta.fixtureContainer) { + try { execFileSync("podman", ["rm", "-f", meta.fixtureContainer], { stdio: "ignore" }); } catch {} + } + + // both backends (SIGTERM + port-kill safety net; ports are always 24000+) + for (const [pid, port] of [[meta.pidA, meta.aBack], [meta.pidB, meta.bBack]]) { + if (pid) { try { process.kill(pid, "SIGTERM"); } catch {} } + if (port) { try { execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { stdio: "ignore" }); } catch {} } + } + + // per-server podman networks + temp HOMEs + for (const home of [meta.homeA, meta.homeB]) { + if (!home) continue; + const network = `loopat-${basename(home).replace(/^\.+/, "") || "loopat"}`; + try { execFileSync("podman", ["network", "rm", "-f", network], { stdio: "ignore" }); } catch {} + rmSync(home, { recursive: true, force: true }); + } + console.log("[dogfood:sync] teardown complete"); + } catch (e) { + console.log(`[dogfood:sync] cleanup skipped: ${e}`); + } +} + +export default globalTeardown; diff --git a/dogfood/sync/zzz-s3-ai.spec.ts b/dogfood/sync/zzz-s3-ai.spec.ts new file mode 100644 index 00000000..76986909 --- /dev/null +++ b/dogfood/sync/zzz-s3-ai.spec.ts @@ -0,0 +1,56 @@ +/** + * S3 — AI loop edits context on A, pushes to origin, B sees it. Same convergence + * as S1 but the writer is a real loop AI instead of the no-AI UI loop, proving + * the two are isomorphic across servers. Costs one idealab turn → runs last. + */ +import { test, expect } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createBLoopAndWaitSandbox, sandboxRead, cleanup, runningContainers } from "./loop-helper"; + +type Meta = { aVite: number; bVite: number; fixtureContainer: string }; +function meta(): Meta { return JSON.parse(readFileSync(join(import.meta.dirname, ".test-meta.json"), "utf8")); } +function fixtureNotesLog(): string { + return execFileSync("podman", ["exec", meta().fixtureContainer, "git", "-c", "safe.directory=*", "-C", "/srv/git/notes.git", "log", "--oneline", "--all"]).toString().trim(); +} + +let loopId = "", bLoopId = ""; +test.afterAll(() => { cleanup(loopId); cleanup(bLoopId); }); + +test("S3 AI loop on A edits notes -> origin -> B sees", async ({ page, browser }) => { + test.setTimeout(420_000); + const stamp = Date.now(), msg = `s3 ai notes ${stamp}`; + await page.addInitScript(() => localStorage.setItem("loopat:setupPersonalRepoDismissed", "1")); + await page.goto("/loop"); + await expect(page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first()).toBeVisible({ timeout: 20_000 }); + const createResp = page.waitForResponse(r => r.url().includes("/api/v1/loops") && r.request().method() === "POST", { timeout: 30_000 }); + await page.getByRole("button", { name: /^\+?\s*New Loop$/i }).first().click(); + await expect(page.getByText("New loop", { exact: true })).toBeVisible({ timeout: 10_000 }); + await page.getByRole("combobox").first().selectOption("roster1"); + await page.getByPlaceholder("refactor-gateway").fill(`s3-${stamp}`); + await page.getByRole("button", { name: "create", exact: true }).click(); + loopId = String((await (await createResp).json()).id ?? "").replace(/^loop_/, ""); + expect(loopId).toMatch(/^[a-f0-9-]+$/); + await page.getByRole("button", { name: /terminal/ }).first().click(); + await expect.poll(() => runningContainers(loopId), { timeout: 300_000, intervals: [1000, 2000, 5000] }).not.toEqual([]); + await expect(page.getByText("Preparing this loop’s sandbox…")).toBeHidden({ timeout: 300_000 }); + + const composer = page.getByRole("textbox", { name: "Message input" }); + await composer.click(); + await composer.fill(`cd /loopat/context/notes, create s3-${stamp}.md with the word DONE, set git user.email ai@local and user.name ai, commit -m '${msg}', push origin HEAD:master. Report when push succeeds.`); + await page.getByRole("button", { name: "Send message" }).click(); + await expect.poll(fixtureNotesLog, { timeout: 240_000, intervals: [2000, 3000, 5000] }).toContain(msg); + console.log("[sync] S3 AI commit reached origin"); + + // Convergence is the point: B's SoT is the SHARED origin, which now carries the + // AI's commit (asserted above). B reaches it AT LOOP LEVEL — a fresh loop on B + // clones notes from origin into its sandbox; we exec into B's container and read + // the AI's actual file. (B's UI worktree may have diverged from earlier cases — + // irrelevant; the SoT is the origin, and the loop clones from it.) + const m = meta(); + bLoopId = await createBLoopAndWaitSandbox(browser, m.bVite, `s3-b-${stamp}`); + expect(sandboxRead(bLoopId, `/loopat/context/notes/s3-${stamp}.md`)).toBe("DONE"); + console.log(`[sync] S3 B loop sandbox read /loopat/context/notes/s3-${stamp}.md = DONE`); + console.log("[sync] S3 GREEN: AI edit on A converged to B at loop level"); +}); diff --git a/dogfood/teardown.ts b/dogfood/teardown.ts new file mode 100644 index 00000000..93c2c78b --- /dev/null +++ b/dogfood/teardown.ts @@ -0,0 +1,60 @@ +/** + * dogfood global teardown — tear down everything setup brought up: + * the fixture sshd container, the backend, and the temp LOOPAT_HOME. + * Only touches test resources (recorded container id + PID + temp dir), + * never dev. + */ +import { readFileSync, rmSync } from "node:fs"; +import { basename, join } from "node:path"; +import { execSync, execFileSync } from "node:child_process"; + +const META = join(import.meta.dirname, ".test-meta.json"); + +async function globalTeardown() { + try { + const meta = JSON.parse(readFileSync(META, "utf8")); + + // ── fixture container ── + if (meta.fixtureContainer) { + try { + execFileSync("podman", ["rm", "-f", meta.fixtureContainer], { stdio: "ignore" }); + console.log(`[dogfood:teardown] removed fixture ${String(meta.fixtureContainer).slice(0, 12)}`); + } catch (e) { + console.log(`[dogfood:teardown] fixture rm skipped: ${e}`); + } + } + + // ── backend ── + if (meta.serverPid) { + try { process.kill(meta.serverPid, "SIGTERM"); } catch {} + console.log(`[dogfood:teardown] killed server pid=${meta.serverPid}`); + } + // Safety net: force-kill by port if SIGTERM didn't take. Test port is + // always 22000+, never a dev port. + if (meta.testServerPort) { + try { execSync(`fuser -k ${meta.testServerPort}/tcp 2>/dev/null || true`, { stdio: "ignore" }); } catch {} + } + + // ── podman network created by the backend's serve path ── + // Named `loopat-<basename(LOOPAT_HOME)>` (see server paths.WORKSPACE / + // podman.LOOPAT_NETWORK). The backend doesn't remove it on SIGTERM, so we + // reap it here. `-f` also disconnects/removes any containers still on it. + if (meta.loopatHome) { + const network = `loopat-${basename(meta.loopatHome).replace(/^\.+/, "") || "loopat"}`; + try { + execFileSync("podman", ["network", "rm", "-f", network], { stdio: "ignore" }); + console.log(`[dogfood:teardown] removed network ${network}`); + } catch {} + } + + // ── temp LOOPAT_HOME ── + if (meta.loopatHome) { + rmSync(meta.loopatHome, { recursive: true, force: true }); + console.log(`[dogfood:teardown] removed ${meta.loopatHome}`); + } + } catch (e) { + console.log(`[dogfood:teardown] cleanup skipped: ${e}`); + } +} + +export default globalTeardown; diff --git a/e2e/globalSetup.ts b/e2e/globalSetup.ts new file mode 100644 index 00000000..3ed479dd --- /dev/null +++ b/e2e/globalSetup.ts @@ -0,0 +1,121 @@ +/** + * Global setup — starts the test backend on the port picked by the config, + * registers a test user, creates test loops, and saves browser storageState. + * + * Ports are chosen at config-load time (playwright.config.ts) and stored in + * .test-meta.json so dev and test never share the same ports. + */ +import { request } from "@playwright/test"; +import { spawn, execSync } from "node:child_process"; +import { readFileSync, mkdtempSync, writeFileSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const META = join(import.meta.dirname, ".test-meta.json"); + +const TEST_USER = "test"; +const TEST_PASSWORD = "test123"; + +const LOOPS = [ + { title: "测试任务:修复登录页bug", archive: false, rfd: false }, + { title: "设计新的 Dashboard 页面", archive: false, rfd: false }, + { title: "优化数据库查询性能", archive: true, rfd: false }, + { title: "接入第三方支付", archive: false, rfd: true }, +]; + +async function waitFor(url: string, timeoutMs = 60_000): Promise<void> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const r = await fetch(url); + if (r.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`timed out waiting for ${url}`); +} + +async function globalSetup() { + const meta = JSON.parse(readFileSync(META, "utf8")); + const { loopatHome, testServerPort, vitePort } = meta as { + loopatHome: string; testServerPort: number; vitePort: number; + }; + + console.log(`[e2e:setup] LOOPAT_HOME = ${loopatHome}`); + console.log(`[e2e:setup] backend :${testServerPort} vite :${vitePort}`); + + // ── start test backend ── + // Kill any stale backend process from a crashed previous run. Only the + // backend port — Vite is managed by Playwright's webServer and is fresh. + try { execSync(`fuser -k ${testServerPort}/tcp 2>/dev/null || true`, { stdio: "ignore" }); } catch {} + + const serverDir = realpathSync(join(import.meta.dirname, "..", "server")); + const server = spawn("bun", ["run", "src/index.ts"], { + cwd: serverDir, + env: { + ...process.env, + ENV: "test", + NODE_ENV: "production", + LOOPAT_HOME: loopatHome, + LOOPAT_SERVE_PORT: "0", + PORT: String(testServerPort), + HOST: "127.0.0.1", + }, + stdio: "pipe", + }); + server.stdout?.on("data", (d) => process.stdout.write(`[server] ${d}`)); + server.stderr?.on("data", (d) => process.stderr.write(`[server] ${d}`)); + + // Update meta with server PID for teardown + writeFileSync(META, JSON.stringify({ ...meta, serverPid: server.pid })); + + // Wait for backend and verify it's ours (not a leftover from a crashed run). + await waitFor(`http://127.0.0.1:${testServerPort}/api/health`); + const health = await (await fetch(`http://127.0.0.1:${testServerPort}/api/health`)).json(); + if (health.loopatHome !== loopatHome) { + throw new Error( + `stale server on :${testServerPort} has LOOPAT_HOME=${health.loopatHome}, expected ${loopatHome}. ` + + `Kill it manually: fuser -k ${testServerPort}/tcp` + ); + } + console.log("[e2e:setup] backend ready"); + + await waitFor(`http://127.0.0.1:${vitePort}/api/health`); + console.log("[e2e:setup] vite ready"); + + // ── register test user ── + const base = `http://127.0.0.1:${vitePort}`; + const api = await request.newContext({ baseURL: base }); + + const reg = await api.post("/api/auth/register", { + data: { username: TEST_USER, password: TEST_PASSWORD }, + }); + const regBody = await reg.json(); + if (!regBody.user) throw new Error(`register failed: ${JSON.stringify(regBody)}`); + console.log(`[e2e:setup] user: ${regBody.user.id} (${regBody.user.role}/${regBody.user.status})`); + + // ── create test loops ── + for (const { title, archive, rfd } of LOOPS) { + const r = await api.post("/api/loops", { + data: { title }, + headers: { "Content-Type": "application/json" }, + }); + const body = await r.json(); + if (!body.id) { + console.log(`[e2e:setup] FAIL "${title}": ${JSON.stringify(body)}`); + continue; + } + if (archive) await api.patch(`/api/loops/${body.id}`, { data: { archived: true } }); + if (rfd) await api.post(`/api/loops/${body.id}/request-drive`); + console.log(`[e2e:setup] ${archive ? "[archived] " : ""}${rfd ? "[RFD] " : ""}"${title}"`); + } + + // ── save cookies for browser tests ── + const state = await api.storageState(); + writeFileSync(join(import.meta.dirname, ".auth.json"), JSON.stringify(state, null, 2)); + console.log(`[e2e:setup] saved ${state.cookies.length} cookie(s)`); + + await api.dispose(); +} + +export default globalSetup; diff --git a/e2e/globalTeardown.ts b/e2e/globalTeardown.ts new file mode 100644 index 00000000..48d983a4 --- /dev/null +++ b/e2e/globalTeardown.ts @@ -0,0 +1,35 @@ +/** + * Global teardown — kill the test backend and remove the temp LOOPAT_HOME. + * Only touches test resources (specific PID + temp dir), never dev. + */ +import { readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; + +const META = join(import.meta.dirname, ".test-meta.json"); + +async function globalTeardown() { + try { + const meta = JSON.parse(readFileSync(META, "utf8")); + + // Best-effort kill by PID, then ensure the port is free. + if (meta.serverPid) { + try { process.kill(meta.serverPid, "SIGTERM"); } catch {} + console.log(`[e2e:teardown] killed server pid=${meta.serverPid}`); + } + // Safety net: if the backend survived SIGTERM, force-kill by port. + // The test port is always in the 20000+ range, never a dev port. + if (meta.testServerPort) { + try { execSync(`fuser -k ${meta.testServerPort}/tcp 2>/dev/null || true`, { stdio: "ignore" }); } catch {} + } + + if (meta.loopatHome) { + rmSync(meta.loopatHome, { recursive: true, force: true }); + console.log(`[e2e:teardown] removed ${meta.loopatHome}`); + } + } catch (e) { + console.log(`[e2e:teardown] cleanup skipped: ${e}`); + } +} + +export default globalTeardown; diff --git a/e2e/loop.spec.ts b/e2e/loop.spec.ts new file mode 100644 index 00000000..cacd0068 --- /dev/null +++ b/e2e/loop.spec.ts @@ -0,0 +1,188 @@ +/** + * /loop page e2e tests. + * + * The test server runs in an isolated temp LOOPAT_HOME. globalSetup + * registers a user and creates test data; the session cookie is loaded + * via storageState. No mocking — these test the real UI against a real + * (isolated) backend. + */ +import { test, expect } from "@playwright/test"; + +test.describe("/loop page", () => { + test.beforeEach(async ({ page }) => { + // Bypass the "Setup Personal Repo" card for fresh accounts. + await page.addInitScript(() => { + localStorage.setItem("loopat:setupPersonalRepoDismissed", "1"); + }); + }); + + test("redirects / to /loop", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveURL(/\/loop/); + }); + + test("shows loop list sidebar with active loops", async ({ page }) => { + await page.goto("/loop"); + + await expect(page.locator("aside")).toBeVisible({ timeout: 10_000 }); + + const sidebar = page.locator("aside"); + + await expect(sidebar.getByText("测试任务:修复登录页bug")).toBeVisible(); + await expect(sidebar.getByText("设计新的 Dashboard 页面")).toBeVisible(); + await expect(sidebar.getByText("接入第三方支付")).toBeVisible(); + + // Archived loop hidden by default + await expect(sidebar.getByText("优化数据库查询性能")).not.toBeVisible(); + }); + + test("search filters loops by title", async ({ page }) => { + await page.goto("/loop"); + const sidebar = page.locator("aside"); + await expect(sidebar).toBeVisible({ timeout: 10_000 }); + + await page.getByPlaceholder("search loops…").fill("Dashboard"); + + await expect(sidebar.getByText("设计新的 Dashboard 页面")).toBeVisible(); + await expect(sidebar.getByText("测试任务:修复登录页bug")).not.toBeVisible(); + }); + + test("search clears when no match", async ({ page }) => { + await page.goto("/loop"); + const sidebar = page.locator("aside"); + await expect(sidebar).toBeVisible({ timeout: 10_000 }); + + await page.getByPlaceholder("search loops…").fill("nonexistent-xyz"); + + await expect(sidebar.getByText("测试任务:修复登录页bug")).not.toBeVisible(); + await expect(sidebar.getByText("设计新的 Dashboard 页面")).not.toBeVisible(); + }); + + test("archive toggle shows/hides archived loops", async ({ page }) => { + await page.goto("/loop"); + const sidebar = page.locator("aside"); + await expect(sidebar).toBeVisible({ timeout: 10_000 }); + + await page.locator('[title="show archived"]').click(); + await expect(sidebar.getByText("优化数据库查询性能")).toBeVisible(); + + await page.locator('[title="hide archived"]').click(); + await expect(sidebar.getByText("优化数据库查询性能")).not.toBeVisible(); + }); + + test("clicking a loop navigates to /loop/:id", async ({ page }) => { + await page.goto("/loop"); + const sidebar = page.locator("aside"); + await expect(sidebar).toBeVisible({ timeout: 10_000 }); + + await sidebar.getByText("设计新的 Dashboard 页面").click(); + + await expect(page).toHaveURL(/\/loop\/[a-f0-9-]+/); + }); + + test("loop detail page shows title and mode buttons", async ({ page }) => { + await page.goto("/loop"); + const sidebar = page.locator("aside"); + await expect(sidebar).toBeVisible({ timeout: 10_000 }); + + // Title appears in sidebar and as a click-to-rename span in the header. + await expect(page.locator('[title="click to rename"]')).toBeVisible(); + await expect(page.getByText("info")).toBeVisible(); + }); + + test("RFD badge shown for RFD-tagged loops", async ({ page }) => { + await page.goto("/loop"); + await expect(page.locator("aside")).toBeVisible({ timeout: 10_000 }); + + // RFD badge (the amber tag on loop row, not the filter tab) + await expect(page.locator("aside span.text-amber-800")).toBeVisible(); + }); + + test("creates a loop via NewLoopDialog hitting v1 API", async ({ page }) => { + await page.goto("/loop"); + const sidebar = page.locator("aside"); + await expect(sidebar).toBeVisible({ timeout: 10_000 }); + + // Capture the create request (set up AFTER navigation so we don't time + // out on unrelated traffic). The v1 endpoint is what we assert below. + const createReq = page.waitForRequest( + (req) => req.url().includes("/api/v1/loops") && req.method() === "POST", + { timeout: 15_000 }, + ); + + // Open NewLoopDialog (header button — App.tsx:168). + await page.getByRole("button", { name: /^\+ New Loop$/i }).click(); + await expect(page.getByText("New loop", { exact: true })).toBeVisible({ timeout: 5_000 }); + + await page.getByPlaceholder("refactor-gateway").fill("v1-e2e-create"); + await page.getByRole("button", { name: "create", exact: true }).click(); + + const req = await createReq; + expect(req.url()).toContain("/api/v1/loops"); + const body = req.postDataJSON(); + expect(body.title).toBe("v1-e2e-create"); + + // Navigation to the new loop's page (the create resolves async). + await expect(page).toHaveURL(/\/loop\/[a-f0-9-]+/, { timeout: 10_000 }); + }); + + test("chat send + receive uses v1 API end-to-end", async ({ page }) => { + // Walks through the full chat chain on the v1 surface: + // - GET /api/v1/loops/:id/events (live SSE subscription) + // - POST /api/v1/loops/:id/messages (user input) + // We assert both fire; the actual model response isn't asserted because + // the test env has no provider configured (would error out). + + // Capture the SSE subscription that useLoopRuntime opens on entry. + const eventsReq = page.waitForRequest( + (req) => /\/api\/v1\/loops\/loop_[a-f0-9-]+\/events/.test(req.url()), + { timeout: 15_000 }, + ); + const sendReq = page.waitForRequest( + (req) => + /\/api\/v1\/loops\/loop_[a-f0-9-]+\/messages/.test(req.url()) && + req.method() === "POST", + { timeout: 15_000 }, + ); + + await page.goto("/loop"); + const sidebar = page.locator("aside"); + await expect(sidebar).toBeVisible({ timeout: 10_000 }); + + // Open one of the seeded loops. + await sidebar.getByText("测试任务:修复登录页bug").click(); + await expect(page).toHaveURL(/\/loop\/[a-f0-9-]+/); + + // /events should fire once the loop page mounts. + const eventsR = await eventsReq; + expect(eventsR.url()).toContain("/api/v1/loops/loop_"); + expect(eventsR.url()).toContain("/events"); + + // Type into the chat composer (aria-label="Message input", Composer.tsx). + const composer = page.getByLabel("Message input"); + await composer.fill("hello v1"); + await page.getByLabel(/Send message|Enqueue message/).click(); + + const sendR = await sendReq; + const body = sendR.postDataJSON(); + expect(body.content).toBe("hello v1"); + // permission_mode is optional but we send the current selector value. + expect(typeof body.permission_mode === "string" || body.permission_mode === undefined).toBe(true); + }); + + test("scope tabs filter loops", async ({ page }) => { + await page.goto("/loop"); + const sidebar = page.locator("aside"); + await expect(sidebar).toBeVisible({ timeout: 10_000 }); + + // "mine" scope (default) — active loops owned by test user + await expect(sidebar.getByText("测试任务:修复登录页bug")).toBeVisible(); + await expect(sidebar.getByText("接入第三方支付")).toBeVisible(); + + // "RFD" scope — only the RFD-tagged loop + await page.getByRole("button", { name: "RFD", exact: true }).click(); + await expect(sidebar.getByText("接入第三方支付")).toBeVisible(); + await expect(sidebar.getByText("测试任务:修复登录页bug")).not.toBeVisible(); + await expect(sidebar.getByText("设计新的 Dashboard 页面")).not.toBeVisible(); + }); +}); diff --git a/package.json b/package.json new file mode 100644 index 00000000..3ba54191 --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "loopat", + "version": "0.1.52", + "description": "Self-hosted AI workspace built around context management — works solo, scales to teams", + "license": "Apache-2.0", + "homepage": "https://github.com/simpx/loopat", + "repository": { + "type": "git", + "url": "git+https://github.com/simpx/loopat.git" + }, + "type": "module", + "bin": { + "loopat": "bin/loopat.mjs" + }, + "files": [ + "bin/", + "scripts/install-sandbox-claude.mjs", + "server/src/", + "!server/src/serve-rs", + "!server/src/port-proxy-rs", + "server/templates/", + "server/package.json", + "web/dist/" + ], + "workspaces": [ + "server", + "web" + ], + "scripts": { + "dev": "HOST=${HOST:-127.0.0.1} LOOPAT_SERVE_HOST=${LOOPAT_SERVE_HOST:-127.0.0.1} bun run --filter '*' dev", + "dev:host": "HOST=0.0.0.0 LOOPAT_SERVE_HOST=0.0.0.0 bun run --filter '*' dev", + "dev:server": "bun --cwd server run dev", + "dev:web": "bun --cwd web run dev", + "dev:serve": "bun --cwd server run serve", + "build": "bun install && (cd web && bun run build)", + "build:web": "cd web && bunx tsc -b && bunx vite build", + "prepublishOnly": "bun install && bun run build:web", + "postinstall": "node scripts/install-sandbox-claude.mjs", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "dogfood": "playwright test --config dogfood/playwright.config.ts", + "dogfood:smoke": "playwright test --config dogfood/playwright.config.ts dogfood/first-5-minutes", + "dogfood:journey": "playwright test --config dogfood/first-run/playwright.config.ts", + "dogfood:first-run": "playwright test --config dogfood/first-run/playwright.config.ts", + "dogfood:sync": "playwright test --config dogfood/sync/playwright.config.ts" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.150", + "@anthropic-ai/sandbox-runtime": "^0.0.52", + "@scalar/hono-api-reference": "^0.10.19", + "bun": "^1.3.14", + "bun-pty": "^0.4.8", + "hono": "^4.12.18", + "smol-toml": "^1.6.1" + }, + "devDependencies": { + "@playwright/test": "^1.60.0" + } +} diff --git a/phase1-prototype/.gitignore b/phase1-prototype/.gitignore deleted file mode 100644 index a547bf36..00000000 --- a/phase1-prototype/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/phase1-prototype/README.md b/phase1-prototype/README.md deleted file mode 100644 index 616ab766..00000000 --- a/phase1-prototype/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# 1001 Phase 1 — Hi-Fi Prototype - -Phase 1 产出:高保真、可交互(signal 驱动)的 1001 4 个一级概念 -(Loop / Focus / Context / Chat)UI 原型。 - -不是真实实现 —— 数据全是 mock,但 fork / transfer-driver / spawn / -attach 这些动作都通过 signals 串起来,演示起来"像真的"。 - -## 跑起来 - -```sh -bun install -bun dev -``` - -打开 <http://localhost:5173>。 - -## 技术栈 - -- Vite + SolidJS + Tailwind v4 -- 不用 router(单页 SPA,顶部 tab 切换) -- 不用后端(module-level signals + 内存 mock) - -## 目录 - -``` -src/ -├── App.tsx # 4-tab shell -├── state.ts # 共享 signals(loops / currentLoopId / forkLoop ...) -├── pages/ # 一个 page / 一级概念 -│ ├── loop.tsx -│ ├── focus.tsx -│ ├── context.tsx -│ └── chat.tsx -├── components/ # 复用组件(待补) -└── mock/ # 数据 fixtures(待补) -``` - -## 当前状态 - -- [x] Vite + Solid + Tailwind 脚手架 -- [x] 4-tab shell(左 logo+tabs / 右 user),signal 驱动;🧶 logo + favicon -- [x] Loop tab: - - chat-first 布局;右 panel 可 toggle(files / editor / terminal)50/50 split - - 6 个真实 archetype loop:code 讨论(loopctl)/ research(llama-research)/ 线上问题(mirror-llama-3)/ context 整理(ccx-refine)/ 设计(1001-design)/ 上线(gateway-launch,他人 driver) - - 富 chat 内容:text / diff card / todo card / artifact card / command card / 系统 marker(driver-change / RFD / claim) - - **driver 状态机**:driver=ME → 显示 "release (RFD)";rfd=true → 显示 "RFD · 可被认领";他人 + rfd → "claim drive" - - **context 显示**:每个 loop 头部 chips 显示 knowledge 范围 + mounted repos - - **CodeMirror 6 编辑器**(python / markdown / js syntax highlight) - - fork 永远可用 -- [x] Focus tab:pinned / focus(8d expires)/ active loops 三段 -- [x] Context tab:Knowledge / Agents / Repos sub-nav;Knowledge 含 wikilink + backlinks -- [x] Chat tab:channels + DMs + 消息流 -- [ ] Loop attach UI(多 client mirror 演示)— 还没加 -- [ ] 更复杂真实的 Focus mock - -## 跟根目录文档的关系 - -详细概念定义、阶段性目标、设计决策见根目录: - -- `../1001-story.md` —— 对外故事 -- `../1001-mvp.md` —— 内部 MVP 工作文档(Phase 1-4 目标) diff --git a/phase1-prototype/bun.lock b/phase1-prototype/bun.lock deleted file mode 100644 index 91697744..00000000 --- a/phase1-prototype/bun.lock +++ /dev/null @@ -1,357 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "phase1-prototype", - "dependencies": { - "@codemirror/lang-javascript": "^6.2.5", - "@codemirror/lang-markdown": "^6.5.0", - "@codemirror/lang-python": "^6.2.1", - "@solidjs/router": "^0.16.1", - "codemirror": "^6.0.2", - "highlight.js": "^11.11.1", - "marked": "^18.0.3", - "marked-highlight": "^2.2.4", - "solid-js": "^1.9.12", - }, - "devDependencies": { - "@tailwindcss/vite": "^4.2.4", - "@types/node": "^24.12.2", - "tailwindcss": "^4.2.4", - "typescript": "~6.0.2", - "vite": "^8.0.10", - "vite-plugin-solid": "^2.11.12", - }, - }, - }, - "packages": { - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], - - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - - "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], - - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], - - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], - - "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], - - "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], - - "@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="], - - "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], - - "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw=="], - - "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="], - - "@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="], - - "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="], - - "@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="], - - "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], - - "@codemirror/view": ["@codemirror/view@6.41.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="], - - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], - - "@lezer/css": ["@lezer/css@1.3.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg=="], - - "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], - - "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], - - "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], - - "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], - - "@lezer/markdown": ["@lezer/markdown@1.6.3", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw=="], - - "@lezer/python": ["@lezer/python@1.1.18", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg=="], - - "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], - - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - - "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], - - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="], - - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="], - - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="], - - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="], - - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="], - - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="], - - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="], - - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="], - - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="], - - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="], - - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="], - - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="], - - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="], - - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="], - - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], - - "@solidjs/router": ["@solidjs/router@0.16.1", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-IhyjedgC6LRpw/8CPGGI89FrV+r0xTHzOl2c4CRyzYQ1bLepJxbVI1LLKvsavMWY5TRBRacV7hAeOhuTXkjiqg=="], - - "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], - - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="], - - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="], - - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="], - - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="], - - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="], - - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="], - - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="], - - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="], - - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="], - - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="], - - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="], - - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="], - - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], - - "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], - - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], - - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], - - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], - - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - - "@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - - "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.6", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-v3P1MW46Lm7VMpAkq0QfyzLWWkC8fh+0aE5Km4msIgDx5kjenHU0pF2s+4/NH8CQn/kla6+Hvws+2AF7bfV5qQ=="], - - "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA=="], - - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], - - "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.349", "", {}, "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A=="], - - "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], - - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - - "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], - - "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], - - "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], - - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - - "marked": ["marked@18.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA=="], - - "marked-highlight": ["marked-highlight@2.2.4", "", { "peerDependencies": { "marked": ">=4 <19" } }, "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ=="], - - "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], - - "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], - - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], - - "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], - - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "seroval": ["seroval@1.5.3", "", {}, "sha512-BXe0x4buEeYiIKaRUnth1WqCILQ3k4O67KP/B4pC3pVz0Mv2c96ngA9QDREUYxWY1sb2RZVRqwI9RcpVMyHCVw=="], - - "seroval-plugins": ["seroval-plugins@1.5.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-LhVh4KjjkKmCxOUjoaUwtqbDjyMfnA535yEmmGDuwZcIYtw8ns6tZmeszNTECeUg/3sJpnEjsz/KhQrcPXPw1Q=="], - - "solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="], - - "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], - - "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], - - "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="], - - "vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], - - "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], - - "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], - - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], - - "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], - } -} diff --git a/phase1-prototype/package.json b/phase1-prototype/package.json deleted file mode 100644 index 7b15c241..00000000 --- a/phase1-prototype/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "phase1-prototype", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview" - }, - "dependencies": { - "@codemirror/lang-javascript": "^6.2.5", - "@codemirror/lang-markdown": "^6.5.0", - "@codemirror/lang-python": "^6.2.1", - "@solidjs/router": "^0.16.1", - "codemirror": "^6.0.2", - "highlight.js": "^11.11.1", - "marked": "^18.0.3", - "marked-highlight": "^2.2.4", - "solid-js": "^1.9.12" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.2.4", - "@types/node": "^24.12.2", - "tailwindcss": "^4.2.4", - "typescript": "~6.0.2", - "vite": "^8.0.10", - "vite-plugin-solid": "^2.11.12" - } -} diff --git a/phase1-prototype/src/App.tsx b/phase1-prototype/src/App.tsx deleted file mode 100644 index 1d0669e3..00000000 --- a/phase1-prototype/src/App.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { Show, createSignal, onCleanup, onMount } from "solid-js" -import { Router, Route, Navigate, useLocation, useNavigate, A } from "@solidjs/router" -import "./index.css" -import { LoopPage, NewLoopDialog } from "./pages/loop" -import { FocusPage } from "./pages/focus" -import { ContextPage } from "./pages/context" -import { ChatPage } from "./pages/chat" -import { - loops, - createLoop, - newLoopDialogOpen, - setNewLoopDialogOpen, -} from "./state" - -// Workspace member count is hardcoded for prototype; in real impl this would -// derive from the workspace member roster (which #all channel renders). -// 5 = simpx + panlilu + 3 agents (coo / ops-bot / growth-bot) -const WORKSPACE_MEMBER_COUNT = 5 -const INVITE_LINK = "https://loopat.ai/invite/1001-x9k2-tmp" - -type Tab = "loop" | "focus" | "context" | "chat" - -const TABS: { id: Tab; label: string; icon: string }[] = [ - { id: "loop", label: "Loop", icon: "⑂" }, - { id: "focus", label: "Focus", icon: "◉" }, - { id: "chat", label: "Chat", icon: "✦" }, - { id: "context", label: "Context", icon: "⌘" }, -] - -function Layout(props: { children?: any }) { - const loc = useLocation() - const navigate = useNavigate() - const activeTab = () => (loc.pathname.split("/")[1] || "loop") as Tab - const [workspaceMenuOpen, setWorkspaceMenuOpen] = createSignal(false) - const [inviteCopied, setInviteCopied] = createSignal(false) - let workspaceRef: HTMLDivElement | undefined - const handleDocClick = (e: MouseEvent) => { - if (workspaceRef && !workspaceRef.contains(e.target as Node)) { - setWorkspaceMenuOpen(false) - } - } - onMount(() => document.addEventListener("click", handleDocClick)) - onCleanup(() => document.removeEventListener("click", handleDocClick)) - - const copyInvite = async () => { - let ok = false - try { - await navigator.clipboard.writeText(INVITE_LINK) - ok = true - } catch { - // fallback for non-secure contexts (HTTP / older browsers) - const ta = document.createElement("textarea") - ta.value = INVITE_LINK - ta.style.position = "fixed" - ta.style.opacity = "0" - document.body.appendChild(ta) - ta.select() - try { - ok = document.execCommand("copy") - } catch { - ok = false - } - document.body.removeChild(ta) - } - if (!ok) { - alert("自动复制失败 · 请从下方输入框手动选中 + Ctrl+C") - return - } - setInviteCopied(true) - setTimeout(() => setInviteCopied(false), 1800) - } - - return ( - <div class="h-full w-full flex flex-col bg-gray-50 text-gray-900"> - <header class="h-12 shrink-0 border-b border-gray-200 bg-white flex items-center px-4 gap-4"> - <div class="relative shrink-0" ref={workspaceRef}> - <button - type="button" - onClick={() => setWorkspaceMenuOpen(!workspaceMenuOpen())} - class={ - workspaceMenuOpen() - ? "flex items-center gap-2 px-2 h-8 rounded bg-gray-100" - : "flex items-center gap-2 px-2 h-8 rounded hover:bg-gray-100" - } - title="workspace menu" - > - <span class="text-lg leading-none">🧶</span> - <span class="text-sm text-gray-900 font-medium">loopat</span> - <span class="text-gray-300">·</span> - <span class="font-mono text-sm text-gray-600">1001</span> - <span class="text-gray-400 text-xs">{workspaceMenuOpen() ? "▴" : "▾"}</span> - </button> - <Show when={workspaceMenuOpen()}> - <div class="absolute left-0 top-full mt-1 w-72 bg-white border border-gray-200 rounded-md shadow-lg z-50 text-[13px]"> - <div class="px-3 py-2.5 border-b border-gray-100"> - <div class="flex items-center gap-2"> - <span class="text-base">🧶</span> - <div class="flex-1 min-w-0"> - <div class="text-gray-900 font-medium">loopat · 1001</div> - <div class="text-[11px] text-gray-500">{WORKSPACE_MEMBER_COUNT} members</div> - </div> - </div> - </div> - <div class="px-3 py-2"> - <div class="text-[11px] text-gray-500 mb-1">invite link</div> - <div class="flex items-center gap-1"> - <input - readonly - value={INVITE_LINK} - onClick={(e) => e.currentTarget.select()} - class="flex-1 px-2 py-1 text-[11px] font-mono bg-gray-50 border border-gray-200 rounded text-gray-700 outline-none focus:border-gray-400" - title="点击全选 · 也可点右侧 📋 自动复制" - /> - <button - type="button" - onClick={copyInvite} - class="px-2 py-1 text-[11px] rounded bg-gray-100 hover:bg-gray-200 text-gray-700" - title="复制到剪贴板" - > - {inviteCopied() ? "✓" : "📋"} - </button> - </div> - <div class="text-[10px] text-gray-400 mt-1"> - 受邀人通过 link 登录后自动落到 #all - </div> - </div> - <div class="border-t border-gray-100" /> - <button - type="button" - onClick={() => { - setWorkspaceMenuOpen(false) - navigate("/chat/all") - }} - class="w-full px-3 py-2 text-left flex items-center gap-2 text-gray-700 hover:bg-gray-50" - > - <span class="text-gray-500">👥</span> - <span>view members in #all</span> - </button> - <div class="border-t border-gray-100" /> - <button - type="button" - disabled - class="w-full px-3 py-2 text-left flex items-center gap-2 text-gray-400 cursor-default" - title="prototype: not implemented" - > - <span>⚙</span> - <span>workspace settings</span> - </button> - <button - type="button" - disabled - class="w-full px-3 py-2 text-left flex items-center gap-2 text-gray-400 cursor-default" - title="prototype: not implemented" - > - <span>↪</span> - <span>switch workspace</span> - </button> - </div> - </Show> - </div> - <nav class="flex items-center gap-1"> - {TABS.map((t) => ( - <A - href={`/${t.id}`} - class={ - activeTab() === t.id - ? "px-3 h-8 rounded text-sm bg-gray-900 text-white flex items-center gap-1.5" - : "px-3 h-8 rounded text-sm text-gray-600 hover:bg-gray-100 flex items-center gap-1.5" - } - > - <span class="opacity-70">{t.icon}</span> - <span>{t.label}</span> - </A> - ))} - </nav> - <div class="flex-1" /> - <button - type="button" - onClick={() => setNewLoopDialogOpen(true)} - class="flex items-center gap-1.5 px-3 h-8 rounded text-sm bg-gray-900 text-white hover:bg-gray-700" - title="create new loop (⌘N)" - > - <span class="text-base leading-none">+</span> - <span>New Loop</span> - </button> - <button - type="button" - class="flex items-center gap-2 px-2 h-8 rounded hover:bg-gray-100" - title="account" - > - <span class="w-6 h-6 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center"> - S - </span> - <span class="text-sm text-gray-700">simpx</span> - <span class="text-gray-400 text-xs">▾</span> - </button> - </header> - <main class="flex-1 min-h-0 min-w-0 overflow-hidden">{props.children}</main> - - <Show when={newLoopDialogOpen()}> - <NewLoopDialog - onClose={() => setNewLoopDialogOpen(false)} - onCreate={(opts) => { - const id = createLoop(opts) - setNewLoopDialogOpen(false) - navigate(`/loop/${id}`) - }} - /> - </Show> - </div> - ) -} - -function App() { - return ( - <Router root={Layout}> - <Route path="/" component={() => <Navigate href="/loop" />} /> - <Route path="/loop" component={() => <Navigate href={`/loop/${loops()[0].id}`} />} /> - <Route path="/loop/:id" component={LoopPage} /> - <Route path="/focus" component={FocusPage} /> - <Route path="/context" component={() => <Navigate href="/context/knowledge" />} /> - <Route path="/context/:sub" component={ContextPage} /> - <Route path="/context/:sub/*path" component={ContextPage} /> - <Route path="/chat" component={() => <Navigate href="/chat/all" />} /> - <Route path="/chat/dm/:name" component={ChatPage} /> - <Route path="/chat/:id" component={ChatPage} /> - </Router> - ) -} - -export default App diff --git a/phase1-prototype/src/components/code-editor.tsx b/phase1-prototype/src/components/code-editor.tsx deleted file mode 100644 index ea6b4568..00000000 --- a/phase1-prototype/src/components/code-editor.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/** - * SolidJS wrapper around CodeMirror 6. - * - * Lifts the editor view, syncs `value` prop to doc on external change, - * forwards changes via `onChange`. Picks language extension by file ext. - */ -import { onMount, onCleanup, createEffect } from "solid-js" -import { EditorView, basicSetup } from "codemirror" -import { EditorState, Compartment } from "@codemirror/state" -import { python } from "@codemirror/lang-python" -import { markdown } from "@codemirror/lang-markdown" -import { javascript } from "@codemirror/lang-javascript" - -const langExt = (path: string) => { - if (path.endsWith(".py")) return python() - if (path.endsWith(".md")) return markdown() - if (path.endsWith(".ts") || path.endsWith(".tsx") || path.endsWith(".js") || path.endsWith(".jsx")) - return javascript({ typescript: path.endsWith(".ts") || path.endsWith(".tsx") }) - return [] -} - -const baseTheme = EditorView.theme({ - "&": { fontSize: "13px", height: "100%", backgroundColor: "transparent" }, - ".cm-scroller": { - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", - lineHeight: "1.55", - }, - ".cm-content": { padding: "10px 0" }, - ".cm-gutters": { - backgroundColor: "transparent", - borderRight: "1px solid #f3f4f6", - color: "#9ca3af", - }, - ".cm-activeLine": { backgroundColor: "#f9fafb" }, - ".cm-activeLineGutter": { backgroundColor: "transparent" }, - ".cm-cursor": { borderLeftColor: "#111827" }, - ".cm-selectionBackground, ::selection": { backgroundColor: "#e0e7ff !important" }, - ".cm-focused": { outline: "none" }, -}) - -export function CodeEditor(props: { - path: string - value: string - onChange: (next: string) => void -}) { - let parent: HTMLDivElement | undefined - let view: EditorView | undefined - const langCompartment = new Compartment() - - onMount(() => { - const updateListener = EditorView.updateListener.of((u) => { - if (u.docChanged) { - props.onChange(u.state.doc.toString()) - } - }) - view = new EditorView({ - parent, - state: EditorState.create({ - doc: props.value, - extensions: [ - basicSetup, - langCompartment.of(langExt(props.path)), - EditorView.lineWrapping, - baseTheme, - updateListener, - ], - }), - }) - }) - - onCleanup(() => view?.destroy()) - - // Path changed → swap doc + language. - createEffect(() => { - const path = props.path - const value = props.value - if (!view) return - const current = view.state.doc.toString() - if (current !== value) { - view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } }) - } - view.dispatch({ effects: langCompartment.reconfigure(langExt(path)) }) - }) - - return <div ref={parent} class="h-full w-full overflow-auto" /> -} diff --git a/phase1-prototype/src/components/icon.tsx b/phase1-prototype/src/components/icon.tsx deleted file mode 100644 index 58929f87..00000000 --- a/phase1-prototype/src/components/icon.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Tiny icon helper. Maps a name to a unicode glyph. - * Replaces opencode's @opencode-ai/ui Icon component for the POC. - */ -const GLYPHS: Record<string, string> = { - enter: "+", - "close-small": "×", - folder: "▸", - "chevron-down": "▾", - "chevron-right": "▸", - "file-tree": "▤", - archive: "▦", - "magnifying-glass": "⌕", - fork: "⑂", - terminal: "▷_", - prompt: "›", - brain: "✦", -} - -export function Icon(props: { name: string; class?: string }) { - const ch = GLYPHS[props.name] ?? "•" - return <span class={`inline-block leading-none ${props.class ?? ""}`}>{ch}</span> -} diff --git a/phase1-prototype/src/components/markdown.tsx b/phase1-prototype/src/components/markdown.tsx deleted file mode 100644 index 551cb8ca..00000000 --- a/phase1-prototype/src/components/markdown.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Markdown renderer with code-block syntax highlighting. - * - * Pipeline: text → marked (parses markdown) → marked-highlight (calls - * hljs.highlight on every code block) → HTML string → injected via - * innerHTML into a <div class="prose">. - * - * This is the renderer for ALL chat content: user messages, AI replies, - * and structured tool calls (diffs / file reads / command output) that - * upstream code converts to markdown code-fenced blocks. - * - * Languages registered: python / go / javascript / typescript / bash / - * diff / yaml / json / markdown / xml. Unrecognized langs render - * unhighlighted (still in a <pre><code>). - */ -import { marked } from "marked" -import { markedHighlight } from "marked-highlight" -import hljs from "highlight.js/lib/core" -import python from "highlight.js/lib/languages/python" -import go from "highlight.js/lib/languages/go" -import javascript from "highlight.js/lib/languages/javascript" -import typescript from "highlight.js/lib/languages/typescript" -import bash from "highlight.js/lib/languages/bash" -import diff from "highlight.js/lib/languages/diff" -import yaml from "highlight.js/lib/languages/yaml" -import json from "highlight.js/lib/languages/json" -import markdown from "highlight.js/lib/languages/markdown" -import xml from "highlight.js/lib/languages/xml" -import "highlight.js/styles/github.css" - -hljs.registerLanguage("python", python) -hljs.registerLanguage("go", go) -hljs.registerLanguage("javascript", javascript) -hljs.registerLanguage("typescript", typescript) -hljs.registerLanguage("bash", bash) -hljs.registerLanguage("sh", bash) -hljs.registerLanguage("shell", bash) -hljs.registerLanguage("diff", diff) -hljs.registerLanguage("patch", diff) -hljs.registerLanguage("yaml", yaml) -hljs.registerLanguage("yml", yaml) -hljs.registerLanguage("json", json) -hljs.registerLanguage("markdown", markdown) -hljs.registerLanguage("md", markdown) -hljs.registerLanguage("xml", xml) -hljs.registerLanguage("html", xml) - -marked.use( - markedHighlight({ - langPrefix: "hljs language-", - highlight(code, lang) { - if (lang && hljs.getLanguage(lang)) { - return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value - } - return code - }, - }), -) - -marked.setOptions({ gfm: true, breaks: false }) - -export function Markdown(props: { text: string; class?: string }) { - const html = () => marked.parse(props.text) as string - return <div class={`prose ${props.class ?? ""}`} innerHTML={html()} /> -} diff --git a/phase1-prototype/src/index.css b/phase1-prototype/src/index.css deleted file mode 100644 index fefbc4ea..00000000 --- a/phase1-prototype/src/index.css +++ /dev/null @@ -1,94 +0,0 @@ -@import "tailwindcss"; - -html, body, #root { - height: 100%; -} -body { - margin: 0; - font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif; - -webkit-font-smoothing: antialiased; -} - -/* Minimal prose styles for rendered markdown blocks */ -.prose { - color: #111827; /* gray-900 */ - font-size: 14px; - line-height: 1.6; -} -.prose h1 { font-size: 22px; font-weight: 600; margin: 1em 0 0.5em; } -.prose h2 { font-size: 18px; font-weight: 600; margin: 1em 0 0.4em; } -.prose h3 { font-size: 15px; font-weight: 600; margin: 0.8em 0 0.3em; } -.prose p { margin: 0.6em 0; } -.prose ul, .prose ol { margin: 0.6em 0; padding-left: 1.4em; } -.prose ul { list-style: disc; } -.prose ol { list-style: decimal; } -.prose li { margin: 0.2em 0; } -.prose a { color: #2563eb; text-decoration: underline; text-underline-offset: 2px; } -.prose a:hover { color: #1d4ed8; } -.prose code { - background: #f3f4f6; - border-radius: 3px; - padding: 1px 5px; - font-size: 0.9em; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} -/* hljs code blocks own the visual styling; <pre> is just a wrapper */ -.prose pre { - margin: 0.6em 0; - padding: 0; - background: transparent; - border: none; - overflow: visible; -} -.prose pre code.hljs { - display: block; - border: 1px solid #e5e7eb; - border-radius: 6px; - padding: 10px 12px; - font-size: 12.5px; - line-height: 1.55; - overflow-x: auto; - background: #fafafa; -} -/* GitHub-style diff line backgrounds (full row tint) */ -.prose pre code.language-diff .hljs-addition { - display: block; - background: #e6ffec; - color: #1a7f37; -} -.prose pre code.language-diff .hljs-deletion { - display: block; - background: #ffebe9; - color: #cf222e; -} -.prose pre code.language-diff .hljs-meta { - display: block; - background: #ddf4ff; - color: #0969da; -} -.prose blockquote { - border-left: 3px solid #d1d5db; - padding-left: 0.8em; - color: #4b5563; - margin: 0.6em 0; -} -.prose table { - border-collapse: collapse; - margin: 0.6em 0; - font-size: 13px; -} -.prose th, .prose td { - border: 1px solid #e5e7eb; - padding: 4px 8px; - text-align: left; -} -.prose th { background: #f9fafb; font-weight: 600; } - -/* prose-chat: in-chat variant — trim outer margins, keep tables compact */ -.prose-chat > :first-child { margin-top: 0; } -.prose-chat > :last-child { margin-bottom: 0; } -.prose-chat p { margin: 0.4em 0; } -.prose-chat ul, .prose-chat ol { margin: 0.4em 0; } -.prose-chat table { margin: 0.5em 0; font-size: 12.5px; } -.prose-chat th, .prose-chat td { padding: 3px 8px; } - diff --git a/phase1-prototype/src/index.tsx b/phase1-prototype/src/index.tsx deleted file mode 100644 index f67cd206..00000000 --- a/phase1-prototype/src/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -/* @refresh reload */ -import { render } from 'solid-js/web' -import './index.css' -import App from './App.tsx' - -const root = document.getElementById('root') - -render(() => <App />, root!) diff --git a/phase1-prototype/src/mock/files.ts b/phase1-prototype/src/mock/files.ts deleted file mode 100644 index c6ecf294..00000000 --- a/phase1-prototype/src/mock/files.ts +++ /dev/null @@ -1,739 +0,0 @@ -/** - * Per-loop workspace mocks: file tree + file contents. - * - * Each loop has its own workdir with realistic-looking files. Artifacts - * created in chat (knowledge/*.md, CHANGELOG.md, etc.) live as actual - * entries in fileContents, so clicking the artifact card opens the - * file in the editor with proper content. - */ -export type FileNode = - | { - kind: "folder" - name: string - children: FileNode[] - mount?: "ro" | "rw" | "selective" - revision?: string - secret?: boolean - onSync?: () => void - display?: "section" - hint?: string - } - | { - kind: "file" - name: string - path: string - modified?: boolean - staged?: boolean - readonly?: boolean - linkTo?: string - } - -export type LoopWorkspace = { - fileTree: FileNode[] - fileContents: Record<string, string> -} - -// ----- prototype-hifi: simpx 这条 loop 干的事就是这个 prototype 本身 ----- - -const prototypeHifi: LoopWorkspace = { - fileTree: [ - { - kind: "folder", - name: "src", - children: [ - { kind: "file", name: "App.tsx", path: "src/App.tsx", modified: true }, - { kind: "file", name: "state.ts", path: "src/state.ts", modified: true }, - { - kind: "folder", - name: "pages", - children: [ - { kind: "file", name: "loop.tsx", path: "src/pages/loop.tsx", modified: true }, - { kind: "file", name: "focus.tsx", path: "src/pages/focus.tsx", modified: true }, - { kind: "file", name: "chat.tsx", path: "src/pages/chat.tsx", modified: true }, - { kind: "file", name: "context.tsx", path: "src/pages/context.tsx", modified: true }, - ], - }, - { - kind: "folder", - name: "components", - children: [ - { kind: "file", name: "code-editor.tsx", path: "src/components/code-editor.tsx" }, - { kind: "file", name: "icon.tsx", path: "src/components/icon.tsx" }, - { kind: "file", name: "markdown.tsx", path: "src/components/markdown.tsx" }, - ], - }, - { - kind: "folder", - name: "mock", - children: [{ kind: "file", name: "files.ts", path: "src/mock/files.ts", modified: true }], - }, - { kind: "file", name: "index.tsx", path: "src/index.tsx" }, - { kind: "file", name: "index.css", path: "src/index.css" }, - ], - }, - { kind: "file", name: "index.html", path: "index.html" }, - { kind: "file", name: "package.json", path: "package.json" }, - { kind: "file", name: "vite.config.ts", path: "vite.config.ts" }, - { kind: "file", name: "README.md", path: "README.md" }, - ], - fileContents: { - "README.md": `# 1001 Phase 1 — Hi-Fi Prototype - -Phase 1 产出:高保真、可交互(signal 驱动)的 4 一级概念 -(Loop / Focus / Chat / Context)UI 原型。 - -## 跑起来 -\`\`\`sh -bun install -bun dev -\`\`\` - -## 技术栈 -- Vite + SolidJS + Tailwind v4 -- 不用 router 之外的库,纯 signal -- 不用后端,module-level signals + 内存 mock`, - "package.json": `{ - "name": "phase1-prototype", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build" - }, - "dependencies": { - "@solidjs/router": "^0.16.1", - "solid-js": "^1.9.12", - "@codemirror/lang-javascript": "^6.2.5", - "marked": "^18.0.3" - } -}`, - "index.html": `<!doctype html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> - <title>1001 · loopat</title> - </head> - <body> - <div id="root"></div> - <script type="module" src="/src/index.tsx"></script> - </body> -</html>`, - }, -} - -// ----- loopat-runtime-spike: simpx fork 了 opencode,加 driver/rfd 字段 ----- - -const loopatRuntimeSpike: LoopWorkspace = { - fileTree: [ - { - kind: "folder", - name: "packages", - children: [ - { - kind: "folder", - name: "server", - children: [ - { - kind: "folder", - name: "src", - children: [ - { kind: "file", name: "session.ts", path: "packages/server/src/session.ts", modified: true, staged: true }, - { kind: "file", name: "project.ts", path: "packages/server/src/project.ts" }, - { kind: "file", name: "attach.ts", path: "packages/server/src/attach.ts", modified: true, staged: true }, - { kind: "file", name: "ws-server.ts", path: "packages/server/src/ws-server.ts" }, - ], - }, - ], - }, - { - kind: "folder", - name: "desktop", - children: [{ kind: "file", name: "main.ts", path: "packages/desktop/main.ts" }], - }, - { - kind: "folder", - name: "cli", - children: [{ kind: "file", name: "index.ts", path: "packages/cli/index.ts" }], - }, - ], - }, - { kind: "file", name: "package.json", path: "package.json" }, - { kind: "file", name: "README.md", path: "README.md" }, - { kind: "file", name: "ATTACH-SPEC.md", path: "ATTACH-SPEC.md", modified: true, staged: true }, - ], - fileContents: { - "packages/server/src/session.ts": `// opencode session = chat history + tool calls + working state -// 1001 fork: 加 driver / rfd 字段 - -export class Session { - id: string - projectId: string - driver: string // 1001 extension: who drives this loop - rfd: boolean // 1001 extension: released for drive - messages: Message[] - - constructor(id: string, projectId: string, driver: string) { - this.id = id - this.projectId = projectId - this.driver = driver - this.rfd = false - this.messages = [] - } - - release() { - this.rfd = true - } - - claim(by: string) { - this.driver = by - this.rfd = false - } -}`, - "ATTACH-SPEC.md": `# Attach 协议草稿 - -## Topic -\`/loop/<id>\` — ws subscription - -## Events (server → client) -- \`snapshot\` — 完整 loop state -- \`message\` — 新 chat 增量 -- \`timeline\` — driver-change / rfd / claim / fork -- \`tool-call\` — AI 调工具中间状态 - -## Events (client → server) -- \`user-input\` — 当前 driver 发的消息 -- \`claim\` — 非 driver 想 claim drive - -明早跟 panlilu 对一遍,看 trpc + ws 那边怎么对应。`, - "README.md": `# loopat (fork of sst/opencode) - -1001 prototype branch — 加 driver / rfd / focus / chat-as-context 等 1001 语义。 - -\`\`\`sh -bun install -bun run --filter=server dev -\`\`\``, - }, -} - -// ----- loopat-ts-mvp: panlilu 的自建路线 (Next.js + tRPC + Prisma + WS) ----- - -const loopatTsMvp: LoopWorkspace = { - fileTree: [ - { - kind: "folder", - name: "prisma", - children: [ - { kind: "file", name: "schema.prisma", path: "prisma/schema.prisma", modified: true, staged: true }, - { kind: "file", name: "seed.ts", path: "prisma/seed.ts" }, - ], - }, - { - kind: "folder", - name: "src", - children: [ - { - kind: "folder", - name: "server", - children: [ - { - kind: "folder", - name: "api", - children: [ - { kind: "file", name: "loopRouter.ts", path: "src/server/api/loopRouter.ts", modified: true, staged: true }, - { kind: "file", name: "focusRouter.ts", path: "src/server/api/focusRouter.ts" }, - { kind: "file", name: "root.ts", path: "src/server/api/root.ts" }, - ], - }, - { - kind: "folder", - name: "ws", - children: [ - { kind: "file", name: "attach.ts", path: "src/server/ws/attach.ts", modified: true }, - ], - }, - { kind: "file", name: "db.ts", path: "src/server/db.ts" }, - ], - }, - { - kind: "folder", - name: "app", - children: [ - { - kind: "folder", - name: "dashboard", - children: [{ kind: "file", name: "page.tsx", path: "src/app/dashboard/page.tsx" }], - }, - { kind: "file", name: "layout.tsx", path: "src/app/layout.tsx" }, - { kind: "file", name: "page.tsx", path: "src/app/page.tsx" }, - ], - }, - { - kind: "folder", - name: "trpc", - children: [{ kind: "file", name: "react.tsx", path: "src/trpc/react.tsx" }], - }, - ], - }, - { kind: "file", name: "package.json", path: "package.json" }, - { kind: "file", name: "README.md", path: "README.md" }, - ], - fileContents: { - "prisma/schema.prisma": `model Loop { - id String @id @default(cuid()) - name String - archetype LoopArchetype @default(code) - workdir String - branch String? - driverName String - rfd Boolean @default(false) - forkedFrom String? - status LoopStatus @default(active) - timeline TimelineEvent[] - chatMounts ChatMount[] - focuses FocusOnLoop[] - createdAt DateTime @default(now()) -} - -model TimelineEvent { - id String @id @default(cuid()) - loopId String - kind TimelineEventKind - byName String - fromVal String? - toVal String? - note String? - createdAt DateTime @default(now()) - loop Loop @relation(fields: [loopId], references: [id]) -} - -model ChatMount { - loopId String - channelId String - upTo Int - loop Loop @relation(fields: [loopId], references: [id]) - @@id([loopId, channelId]) -} - -enum LoopArchetype { code research online context_refine design } -enum LoopStatus { active idle archived } -enum TimelineEventKind { create driver_change rfd claim fork focus_pin }`, - "src/server/api/loopRouter.ts": `import { z } from "zod" -import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc" - -export const loopRouter = createTRPCRouter({ - list: protectedProcedure - .input(z.object({ scope: z.enum(["mine", "all", "rfd"]).default("mine") }).optional()) - .query(async ({ ctx, input }) => { - const where = (input?.scope === "mine" - ? { driverName: ctx.session.user.name } - : input?.scope === "rfd" - ? { rfd: true, status: "active" as const } - : { status: { not: "archived" as const } }) - return ctx.db.loop.findMany({ where, orderBy: { createdAt: "desc" } }) - }), - - get: protectedProcedure - .input(z.object({ id: z.string() })) - .query(({ ctx, input }) => - ctx.db.loop.findUnique({ - where: { id: input.id }, - include: { timeline: true, chatMounts: true, focuses: { include: { focus: true } } }, - }), - ), - - fork: protectedProcedure - .input(z.object({ sourceId: z.string() })) - .mutation(async ({ ctx, input }) => { - const source = await ctx.db.loop.findUniqueOrThrow({ where: { id: input.sourceId } }) - return ctx.db.loop.create({ - data: { - name: \`\${source.name}-fork\`, - archetype: source.archetype, - workdir: source.workdir, - branch: source.branch ? \`\${source.branch}-fork\` : undefined, - driverName: ctx.session.user.name!, - forkedFrom: source.id, - timeline: { create: { kind: "fork", byName: ctx.session.user.name!, note: \`forked from \${source.name}\` } }, - }, - }) - }), -})`, - "src/server/ws/attach.ts": `import { WebSocketServer } from "ws" -import type { Loop, TimelineEvent } from "@prisma/client" - -// 每个 loop 一个 channel,client subscribe 后立刻收 snapshot + 增量 event。 -type LoopChannel = { - loopId: string - subscribers: Set<WebSocket> -} - -const channels = new Map<string, LoopChannel>() - -export function attachToLoop(ws: WebSocket, loopId: string, snapshot: Loop) { - let ch = channels.get(loopId) - if (!ch) { - ch = { loopId, subscribers: new Set() } - channels.set(loopId, ch) - } - ch.subscribers.add(ws) - ws.send(JSON.stringify({ kind: "snapshot", payload: snapshot })) - ws.on("close", () => ch!.subscribers.delete(ws)) -} - -export function broadcastTimeline(loopId: string, event: TimelineEvent) { - const ch = channels.get(loopId) - if (!ch) return - for (const sub of ch.subscribers) { - sub.send(JSON.stringify({ kind: "timeline", payload: event })) - } -}`, - "README.md": `# loopat-ts - -panlilu 的自建路线 — Next.js + tRPC + Prisma + Postgres + WS。 - -并行 simpx 的 opencode-fork spike,周末 close 取舍。 - -## stack -- Next.js 15 + React 19 -- tRPC 11 + react-query -- Prisma + Postgres -- next-auth 5.0-beta + Prisma adapter -- ws 跨 client attach`, - }, -} - -// ----- research-opencode: 调研 fork,不一定 commit ----- - -const researchOpencode: LoopWorkspace = { - fileTree: [ - { - kind: "folder", - name: "packages", - children: [ - { - kind: "folder", - name: "server", - children: [ - { - kind: "folder", - name: "src", - children: [ - { kind: "file", name: "session.ts", path: "packages/server/src/session.ts", readonly: true }, - { kind: "file", name: "project.ts", path: "packages/server/src/project.ts", readonly: true }, - ], - }, - ], - }, - ], - }, - { kind: "file", name: "ARCHITECTURE.md", path: "ARCHITECTURE.md", readonly: true }, - { kind: "file", name: "README.md", path: "README.md", readonly: true }, - ], - fileContents: { - "ARCHITECTURE.md": `# opencode 架构(read-only · clone for study) - -## 核心 model -- session = chat history + tool calls + working state -- project = workdir,1:N session -- attach: SSE 单 subscriber - -## 适配 1001 -见 \`research/opencode-deep-dive.md\` (in notes)`, - }, -} - -// ----- research-claude-code: notes-style 调研 ----- - -const researchClaudeCode: LoopWorkspace = { - fileTree: [ - { kind: "file", name: "claude-code-internals.md", path: "claude-code-internals.md" }, - { kind: "file", name: "skill-system-notes.md", path: "skill-system-notes.md" }, - { kind: "file", name: "hook-points.md", path: "hook-points.md" }, - ], - fileContents: { - "claude-code-internals.md": `# Claude Code 内部架构 - -单进程 CLI,无 multi-client。但扩展机制比 opencode 灵活: -- hooks (pre-tool / post-tool / on-stop) -- skills (SKILL.md 自描述 + 按需加载) -- MCP - -不适合直接 fork(跟 1001 c/s 错位),但 skills 系统值得抄。`, - "skill-system-notes.md": `# Skills System - -每个 skill 一个目录,包含 SKILL.md (frontmatter 描述触发条件) + 实现文件。 - -AI 加载时只读 SKILL.md 元信息;触发时再 inline 加载内容。 - -我们的 \`knowledge/skills/\` 应该照这个套路走。`, - "hook-points.md": `# Hook Points - -- \`pre-tool\` — 工具调用前 -- \`post-tool\` — 工具调用后 -- \`on-stop\` — agent loop 结束 -- \`user-prompt-submit\` — 用户输入提交时 - -通过 settings.json 注册 shell command。比 langchain callbacks 简单 + 可编程。`, - }, -} - -// ----- site-uptime-spike: rfd-from-birth, 没 workdir 直到有人 claim ----- - -const siteUptimeSpike: LoopWorkspace = { - fileTree: [], - fileContents: {}, -} - -// ----- demo-video-script: HN show 2-min video draft ----- - -const demoVideoScript: LoopWorkspace = { - fileTree: [ - { kind: "file", name: "script-v0.md", path: "demo/script-v0.md", modified: true }, - { kind: "file", name: "shot-list.md", path: "demo/shot-list.md" }, - { kind: "file", name: "narration.txt", path: "demo/narration.txt" }, - { - kind: "folder", - name: "assets", - children: [ - { kind: "file", name: "loopat-logo.svg", path: "demo/assets/loopat-logo.svg" }, - { kind: "file", name: "color-palette.png", path: "demo/assets/color-palette.png" }, - ], - }, - ], - fileContents: { - "demo/script-v0.md": `# loopat 2-min demo · v0 - -## Hook (0:00-0:15) -> AI 工具像 Slack 频道,但工作不该像聊天。 -> -> loopat 是一个让 AI 协作不再"频道化"的工具 —— 工作有 driver、有 context、有沉淀。 - -## 4 一级概念 (0:15-0:45) -- Loop — first-class 工作单元 -- Focus — 团队当下注意力 view -- Chat — sync 协调 + ephemeral context -- Context — 团队物料的 distilled 沉淀 - -## Self-referential demo (0:45-1:30) -打开 prototype-hifi loop。展示: -1. chat → spawn loop 动作(30s) -2. driver release / claim -3. loop 沉淀进 knowledge - -## Attach demo (1:30-1:50) -panlilu 端 ws 接好后,多 client mirror。 - -## CTA (1:50-2:00) -loopat.ai · early access`, - "demo/shot-list.md": `# Shot list - -[Hook] -- A roll: simpx 对着摄像头说 'AI 工具像频道,工作不该像聊天' -- B roll: slack 频道滚动 → 切成 loop 卡片 - -[4 概念] -- 静态截图扫一遍 4 tab + 文字 overlay - -[Self-referential] -- 一镜到底,屏幕录制 prototype 上的真实操作 - -[Attach] -- 双屏:左边 simpx 操作,右边 panlilu 同步看到 - -[CTA] -- 全屏 logo + url`, - }, -} - -// ----- attach-spec-review: panlilu 接手 review attach spec ----- - -const attachSpecReview: LoopWorkspace = { - fileTree: [ - { kind: "file", name: "ATTACH-SPEC.md", path: "ATTACH-SPEC.md", modified: true, staged: true }, - { kind: "file", name: "review-notes.md", path: "review-notes.md", modified: true }, - { - kind: "folder", - name: "examples", - children: [ - { kind: "file", name: "client-recover.ts", path: "examples/client-recover.ts" }, - { kind: "file", name: "server-broadcast.ts", path: "examples/server-broadcast.ts" }, - ], - }, - ], - fileContents: { - "ATTACH-SPEC.md": `# Attach 协议草稿 - -## Topic -\`/loop/<id>\` — ws subscription - -## Events (server → client) -- \`snapshot\` — 完整 loop state -- \`message\` — 新 chat 增量 -- \`timeline\` — driver-change / rfd / claim / fork -- \`tool-call\` — AI 调工具中间状态 - -## Events (client → server) -- \`user-input\` — 当前 driver 发的消息 -- \`claim\` — 非 driver 想 claim drive - -## 决议(panlilu review @ 2026-05-10) - -1. **Envelope** — 纯 JSON。protobuf 推迟到 p1 之后 -2. **Recover** — client 带 lastEventId,server 重放窗内事件 -3. **Auth** — sub 时 workspace token + visibility check;每条 message 不重复 - -## 待办 - -- [ ] simpx confirm 决议 -- [ ] 写到 knowledge/loopat/attach-protocol-spec.md`, - "review-notes.md": `# review notes (panlilu) - -读 simpx 的草稿,整体方向 OK。三个具体改动建议(已写入 spec 决议段): - -1. envelope 不要 protobuf -2. recover 用 lastEventId -3. auth 在 sub 层,不下沉到 message - -## 还没决的 - -- max replay window: 想了想 24h 应该够,超过这个让 client 重新 sub + snapshot -- ws keep-alive interval: 30s? 跟 cloudflare 默认对齐`, - }, -} - -// ----- feature-pricing-sketch: idle 状态,4 天没动 ----- - -const featurePricingSketch: LoopWorkspace = { - fileTree: [ - { kind: "file", name: "pricing-sketch-v0.md", path: "pricing-sketch-v0.md" }, - { kind: "file", name: "competitor-survey.md", path: "competitor-survey.md" }, - ], - fileContents: { - "pricing-sketch-v0.md": `# loopat 定价(草稿 v0) - -> 状态:搁置。等 phase 3 之后再 revisit。 - -## tier -- **Free** — 5 人 workspace,1 active loop / 人,无 attach -- **Team** — $10/u/mo,无限 loop,attach,agent 配额 -- **Enterprise** — 谈判,SSO/audit/private install - -## 不确定 -- 怎么收 agent compute 费用 -- workspace 上限 vs seat 上限 -- early adopter 永久折扣?`, - "competitor-survey.md": `# 竞品定价对照 - -| 工具 | 起步 | 单位 | 主要 gating | -|---|---|---|---| -| Linear | $8/u/mo | seat | 私有 issue / SAML / API | -| Notion | $10/u/mo | seat | team workspace / version history | -| Slack | $7.25/u/mo | seat | unlimited history / SSO | -| Cursor | $20/u/mo | seat | 模型额度 / 高级模型 | - -都是 seat-based。Cursor 是个例外:贵 + 含 model usage。 - -→ 如果 loopat 含 agent compute,可能要走 Cursor 模式?`, - }, -} - -// ----- naming-brainstorm: archived,命名史 ----- - -const namingBrainstorm: LoopWorkspace = { - fileTree: [ - { kind: "file", name: "candidates.md", path: "candidates.md", readonly: true }, - { kind: "file", name: "naming-decision-v1.md", path: "naming-decision-v1.md", readonly: true }, - { kind: "file", name: "naming-decision-v2.md", path: "naming-decision-v2.md", readonly: true }, - ], - fileContents: { - "candidates.md": `# brand 名候选 brainstorm - -## 否决 -- pit 系(pithub / pitops)— 切分歧义 -- 造词类(melode / klyma / sheraza)— 怪 -- 1001loop / 1001days — 累赘 -- loopin — 平凡,记忆点弱 -- looped — 暗示"完成",跟 active loop 反向 - -## 候选 -- loopey.ai — slack 拼写感 -- loopat.ai — 内嵌 'pat' - -## 决议 -loopat.ai (覆盖 v1 的 loopey)`, - "naming-decision-v2.md": `# 1001 brand 名(决议 v2) - -> 覆盖 v1 (loopey.ai) - -选 **loopat.ai** - -核心: -1. **loop** — 项目核心概念 -2. **pat** — 隐藏词,未来产品 UX 动词(给 AI 一个 pat = 反馈) - -放弃 'loop at AI' 短语解读 — "loop at" 不是英语 idiom,硬解牵强。 - -logo: 🧶 (毛线团) — loop 的有机/暖感呈现`, - }, -} - -// ----- prototype-hifi-fork-test: panlilu fork 试 react 改写 ----- - -const prototypeHifiForkTest: LoopWorkspace = { - fileTree: [ - { - kind: "folder", - name: "src", - children: [ - { kind: "file", name: "App.tsx", path: "src/App.tsx", modified: true }, - { kind: "file", name: "state.ts", path: "src/state.ts", modified: true }, - ], - }, - { kind: "file", name: "fork-eval.md", path: "fork-eval.md" }, - { kind: "file", name: "package.json", path: "package.json", modified: true }, - ], - fileContents: { - "fork-eval.md": `# Fork eval: solid → react 改写值不值得? - -## 当前 -- codebase 1.6k 行 solid -- 改 react 估计 +20% 行数(react useEffect/useMemo 显式) - -## 决议 -短期不动。phase 3 SSR 需求评估时再看。`, - "src/App.tsx": `// React 13 改写尝试 — POC 不完整 -import { useState } from "react" - -export function App() { - const [tab, setTab] = useState<"loop" | "focus" | "chat" | "context">("loop") - // ... -}`, - }, -} - -// ============================================================================ - -export const LOOP_WORKSPACES: Record<string, LoopWorkspace> = { - "prototype-hifi": prototypeHifi, - "loopat-runtime-spike": loopatRuntimeSpike, - "loopat-ts-mvp": loopatTsMvp, - "research-opencode": researchOpencode, - "research-claude-code": researchClaudeCode, - "site-uptime-spike": siteUptimeSpike, - "demo-video-script": demoVideoScript, - "attach-spec-review": attachSpecReview, - "feature-pricing-sketch": featurePricingSketch, - "naming-brainstorm": namingBrainstorm, - "prototype-hifi-fork-test": prototypeHifiForkTest, -} - -export const EMPTY_WORKSPACE: LoopWorkspace = { - fileTree: [], - fileContents: {}, -} - -export function getWorkspace(loopId: string): LoopWorkspace { - return LOOP_WORKSPACES[loopId] ?? EMPTY_WORKSPACE -} diff --git a/phase1-prototype/src/pages/chat.tsx b/phase1-prototype/src/pages/chat.tsx deleted file mode 100644 index 5c21a55c..00000000 --- a/phase1-prototype/src/pages/chat.tsx +++ /dev/null @@ -1,729 +0,0 @@ -/** - * Chat tab — channel/DM rail + conversation pane. - * - * Channels and DMs are ephemeral context. The "channel info" bar shows - * which loops have ingested this channel (loop.context.chats[]). - */ -import { createMemo, createSignal, For, Show } from "solid-js" -import { useNavigate, useParams } from "@solidjs/router" -import { Icon } from "../components/icon" -import { loops } from "../state" -import { AGENTS } from "./context" - -type ChannelId = string - -type Message = { - id: string - author: string - isAi?: boolean - isMe?: boolean - text: string - time: string -} - -type Conversation = { - id: ChannelId - kind: "channel" | "dm" - name: string - unread?: number - active?: boolean - topic?: string - members?: string[] - messages: Message[] -} - -const CHANNELS: Conversation[] = [ - { - id: "all", - kind: "channel", - name: "all", - topic: "workspace 全员频道 · 也是成员目录", - members: ["simpx", "panlilu", "coo", "ops-bot", "growth-bot"], - messages: [ - { - id: "m0a", - author: "growth-bot", - isAi: true, - text: - "📡 daily digest(昨天):\n- 0 loopat.ai 注册\n- HN 4 条相关讨论(attached)\n- twitter 8 mentions('AI org' / 'context engineering')\n- 没看到直接竞争对手发布", - time: "yesterday 09:00", - }, - { - id: "m0b", - author: "coo", - isAi: true, - text: - "📊 weekly snapshot 已生成 → notes/memory/weekly-snapshot-2026-05-09.md\n要点:prototype 4 tab 完成、loopat-ts staging 部署、attach 协议草稿(待 panlilu review)", - time: "yesterday 18:00", - }, - { id: "m0c", author: "simpx", isMe: true, text: "@panlilu 我把 attach spec 单开 loop 让你 review,rfd 模式,明早接", time: "yesterday 22:00" }, - { id: "m0d", author: "panlilu", text: "👀 收到,明早第一件事", time: "yesterday 22:14" }, - - { id: "m1", author: "panlilu", text: "早。今天准备把 trpc routers 写完,attach spec 也看了一下,回头单开 loop 回复 simpx", time: "09:02" }, - { id: "m2", author: "simpx", isMe: true, text: "我这边重写一下 prototype 的 mock 数据,让它更真实,今天的会就用它 demo", time: "09:08" }, - { - id: "m3", - author: "coo", - isAi: true, - text: - "@simpx 同步:\n- panlilu 已 claim attach-spec-review loop\n- prototype-hifi 改动量大,需要我帮你拆 mvp doc 同步吗?\n- growth-bot 昨晚抓到 1 条 hn 帖子(下条 message)", - time: "09:30", - }, - { - id: "m6", - author: "growth-bot", - isAi: true, - text: - "📡 监测到 hn 上一条相关讨论:'show hn: a unified todo + chat hybrid'(37 points, 12 comments)\n— 跟我们的 Loop / Focus 哲学有交集,要不要看一眼对方怎么吃这个市场\nlink: https://news.ycombinator.com/item?id=...", - time: "10:42", - }, - { id: "m7", author: "panlilu", text: "看了,他们走的是 todo+chat 合并,没有 driver 单人语义。跟我们方向不同", time: "10:55" }, - { id: "m4", author: "panlilu", text: "👍 周末我们要不要面对面把两条 spike 取舍 close 掉?", time: "11:14" }, - { id: "m5", author: "simpx", isMe: true, text: "可以。本周末,咖啡馆", time: "11:18" }, - { - id: "m8", - author: "ops-bot", - isAi: true, - text: "🚨 staging.loopat.ai 5xx spike, 已 spawn loop site-uptime-spike (rfd)", - time: "09:42", - }, - { - id: "m9", - author: "coo", - isAi: true, - text: - "@panlilu 你昨天 staging 部署后接的次新接口(auth callback)疑似在 5xx 风暴名单里。要不要先 rollback 看看?", - time: "09:45", - }, - { - id: "m10", - author: "panlilu", - text: "我手头上有事,先 rollback。simpx 你看下 next-auth beta callback 的事,notes/research/next-auth-beta-notes.md 我刚写完", - time: "09:48", - }, - ], - }, - { - id: "dev", - kind: "channel", - name: "dev", - unread: 2, - active: true, - topic: "loopat 开发协作 · 两条 spike 进度同步", - members: ["simpx", "panlilu", "coo", "ops-bot"], - messages: [ - { id: "d0a", author: "simpx", isMe: true, text: "今早跑了 opencode 的 monorepo benchmark,bun install 38s,tsc -b 12s。可接受", time: "yesterday 09:14" }, - { id: "d0b", author: "panlilu", text: "我这边 next dev cold start 4.2s,不算慢。trpc 类型推导很爽", time: "yesterday 09:30" }, - { id: "d0c", author: "simpx", isMe: true, text: "@panlilu schema 你打算把 ChatMount 当 first-class 还是 join model?", time: "yesterday 13:00" }, - { - id: "d0d", - author: "panlilu", - text: "first-class,跟 Focus 一样。后期 attach 协议要 ref 它就方便", - time: "yesterday 13:15", - }, - { - id: "d0e", - author: "coo", - isAi: true, - text: - "我把 'ChatMount = first-class model' 这个决定记到 knowledge/loopat/architecture.md。`已决问题` 段现在 3 条:\n1. ChatMount 走 first-class\n2. driver 字段挂 session metadata\n3. attach SSE → ws", - time: "yesterday 13:20", - }, - { id: "d1", author: "simpx", isMe: true, text: "fork opencode 那边 driver 字段加完了,下一步 attach SSE → ws", time: "14:50" }, - { - id: "d2", - author: "panlilu", - text: - "我这边 prisma schema 写完了 —— Loop / TimelineEvent / ChatMount / Focus / Contact 都建了 model。今天写 trpc routers。", - time: "15:02", - }, - { - id: "d3", - author: "simpx", - isMe: true, - text: "你 schema 里 ChatMount 怎么处理 upTo? 我们说好了 mount 是 immutable 快照,sync 创建新 mount 还是 mutate?", - time: "15:12", - }, - { - id: "d4", - author: "panlilu", - text: - "现在是 mutate(@@id([loopId, channelId])),sync 就 update upTo。我倾向保留历史会复杂,先不做 versioning。", - time: "15:15", - }, - { id: "d5", author: "simpx", isMe: true, text: "OK 同意。先 mutate,需要历史再加", time: "15:16" }, - { - id: "d6", - author: "coo", - isAi: true, - text: "提醒:刚刚 simpx 跟 panlilu 关于 ChatMount 的取舍我已经记到 knowledge/loopat/architecture.md 的'已决问题'段。", - time: "15:18", - }, - { - id: "d7", - author: "ops-bot", - isAi: true, - text: "🚀 deploy: panlilu/loopat-ts main → staging.loopat.ai \n build ✓ migrate ✓ health-check ✓ (87s)", - time: "16:30", - }, - { id: "d8", author: "panlilu", text: "staging 上去看看 loop list 那个页", time: "16:32" }, - { - id: "d9", - author: "simpx", - isMe: true, - text: - "loop list 在 staging 看了。两个建议:\n1. RFD loop 顶部应该有 'incident' 视觉提示,跟 prototype 对齐\n2. driver 字段右侧加个小色点,跟 prototype 一致", - time: "16:42", - }, - { id: "d10", author: "panlilu", text: "👌 加入 backlog。这两个还简单", time: "16:45" }, - { - id: "d11", - author: "simpx", - isMe: true, - text: - "@panlilu attach spec 我刚单开 loop,rfd 给你了:[attach-spec-review](#)\n你明早接,看完出意见,重点关心 ws envelope / recover / auth 三个问题", - time: "22:00", - }, - { - id: "d12", - author: "panlilu", - text: "claim 了。今天 review 完,10:14 写完意见 push 到 spec/attach-v0", - time: "today 10:14", - }, - { - id: "d13", - author: "coo", - isAi: true, - text: - "@simpx panlilu 把 attach-spec-review 推进到决议阶段,等你 confirm 三件事:\n1. envelope 走 JSON 不上 protobuf\n2. recover 用 lastEventId\n3. auth 在 sub 层一次性\n\n要我帮你拉 simpx 看 → confirm 流程吗?", - time: "today 10:15", - }, - { id: "d14", author: "simpx", isMe: true, text: "confirm,三个我都同意。close 那条 loop,结论沉淀进 knowledge/loopat/attach-protocol-spec.md", time: "today 10:42" }, - ], - }, - { - id: "ops", - kind: "channel", - name: "ops", - topic: "loopat.ai 站点运维告警 · 自动派单", - members: ["simpx", "panlilu", "ops-bot"], - messages: [ - { - id: "o0a", - author: "ops-bot", - isAi: true, - text: - "📊 weekly site report (week 19):\n- uptime: 99.94%\n- p99 latency: 142ms\n- 流量峰值:周二 21:30 (412 req/s)\n- 主要错误:next-auth callback (transient)\n- staging deploys: 14 次 (1 rollback)", - time: "周一 09:00", - }, - { - id: "o0b", - author: "ops-bot", - isAi: true, - text: - "🚀 deploy: simpx/loopat phase1-prototype → preview.loopat.ai/p1\n build ✓ health-check ✓ (42s)\n preview url: https://preview.loopat.ai/p1/index.html", - time: "yesterday 16:14", - }, - { - id: "o0c", - author: "ops-bot", - isAi: true, - text: - "🚀 deploy: panlilu/loopat-ts main → staging.loopat.ai\n build ✓ migrate ✓ (3 new migrations) health-check ✓ (87s)\n → next-auth schema fields: 6 added", - time: "yesterday 23:15", - }, - { - id: "o0d", - author: "ops-bot", - isAi: true, - text: - "⚠ build warning: \`bun-types@latest\` introduced 2 type errors in src/server/api/loopRouter.ts\n建议:pin 到 1.1.42 或修 import", - time: "yesterday 23:20", - }, - { - id: "o1", - author: "ops-bot", - isAi: true, - text: - "🚨 5xx 抖动:\n- 09:35–09:42 (7min)\n- 5xx 总量:342 (baseline 8/min)\n- 受影响:/api/auth/callback (78%) · /api/loop (12%)\n- region: cn-shanghai\n- spawn loop `site-uptime-spike` (rfd, 等人接)", - time: "09:42", - }, - { - id: "o2", - author: "ops-bot", - isAi: true, - text: - "🔄 auto-rollback triggered (10min 内 spike 持续) → staging 回到 \`a2f81b\`\n 健康指标恢复 (p99: 89ms · 5xx: 4/min)", - time: "09:55", - }, - { - id: "o3", - author: "panlilu", - text: "rollback 后看了 deploy diff,问题大概率是 next-auth 5.0-beta.25 的 callback 行为变更。我先把它 pin 到 beta.21", - time: "10:30", - }, - ], - }, -] - -const DMS: Conversation[] = [ - { - id: "dm-coo", - kind: "dm", - name: "coo", - messages: [ - { id: "c1", author: "simpx", isMe: true, text: "/summarize 今天 #dev 的进展", time: "16:35" }, - { - id: "c2", - author: "coo", - isAi: true, - text: - "**今天 #dev 摘要**\n\n- simpx:fork opencode 的 driver 字段加完,下一步 attach SSE→ws\n- panlilu:prisma schema 完成(Loop / TimelineEvent / ChatMount / Focus / Contact),开始写 trpc routers\n- 已决:ChatMount 走 mutate,不做 versioning(先简单)\n- ops-bot:staging.loopat.ai 部署成功(87s)\n\n要不要 spawn 一个 loop 跟踪 attach 协议落地?", - time: "16:35", - }, - { - id: "c3", - author: "simpx", - isMe: true, - text: "暂时不用,已经在 loopat-runtime-spike 里跟踪。帮我拉一下今早 panlilu 关于 ChatMount 的发言原文", - time: "16:38", - }, - { - id: "c4", - author: "coo", - isAi: true, - text: - "panlilu 在 #dev 15:02 的原文:\n\n> 我这边 prisma schema 写完了 —— Loop / TimelineEvent / ChatMount / Focus / Contact 都建了 model。今天写 trpc routers。\n\n以及 15:15 关于 mutate vs versioning 的取舍:\n\n> 现在是 mutate(@@id([loopId, channelId])),sync 就 update upTo。我倾向保留历史会复杂,先不做 versioning。", - time: "16:39", - }, - ], - }, - { - id: "dm-panlilu", - kind: "dm", - name: "panlilu", - unread: 1, - messages: [ - { id: "p0a", author: "panlilu", text: "今天 next-auth 5.0-beta 升 25 之后 callback 行为有变,注意一下", time: "yesterday 11:30" }, - { id: "p0b", author: "simpx", isMe: true, text: "好,我没碰 auth 那块,留意", time: "yesterday 11:35" }, - { - id: "p0c", - author: "panlilu", - text: - "你在 prototype 里把 contacts 跟 dm 合并那个改动我看了下,#all members 当 directory 这思路挺好。我自建那边照抄了", - time: "yesterday 14:20", - }, - { id: "p0d", author: "simpx", isMe: true, text: "你那边 trpc 的 loop.list 怎么处理 RFD 过滤?", time: "yesterday 14:25" }, - { - id: "p0e", - author: "panlilu", - text: "router 接 scope: enum('mine'|'all'|'rfd'),过滤逻辑跟 prototype 完全一致。代码已 push staging", - time: "yesterday 14:30", - }, - { - id: "p1", - author: "simpx", - isMe: true, - text: - "周末聊两条 spike 取舍前先把数据表对一下 —— 你那边 prisma 的 Loop schema 跟我 fork 这边在 session metadata 上加的 driver/rfd 字段,未来要 merge 还是各自独立?", - time: "20:14", - }, - { - id: "p2", - author: "panlilu", - text: "倾向 merge —— 但前提是 attach 协议两边一致。你这周能出 attach spec 草稿吗?", - time: "20:30", - }, - { id: "p3", author: "simpx", isMe: true, text: "可以,明天发你", time: "20:32" }, - { id: "p4", author: "panlilu", text: "另外 demo 视频别忘了,hn 那条快定下来", time: "今天 11:02" }, - { - id: "p5", - author: "simpx", - isMe: true, - text: - "demo 视频今早开了 loop \`demo-video-script\`,结构搭出来了。0:45-1:30 那段想用今天的工作做 self-referential demo —— prototype 自己就是 demo subject", - time: "今天 11:15", - }, - { id: "p6", author: "panlilu", text: "👏 这个钩子很妙。要不要我那边 staging 帮配一个 attach demo 双屏?", time: "今天 11:18" }, - { id: "p7", author: "simpx", isMe: true, text: "要!1:30-1:50 那段就指着你那边接的 ws", time: "今天 11:20" }, - { id: "p8", author: "panlilu", text: "OK 我下午弄。另外你那个 react fork eval loop 我看到了,结论同意短期不动", time: "今天 11:25" }, - ], - }, -] - -function agentToConversation(a: typeof AGENTS[number]): Conversation { - return { - // agents 跟 humans 共享 dm- 命名空间,name 是 workspace 唯一标识符 - // (跟 mvp doc §1.4 "agent 跟人完全平起" 一致 —— @mention 不分人 / agent) - id: `dm-${a.id}`, - kind: "dm", - name: a.name, - topic: a.charter, - messages: a.recentInvocations.map((inv, i) => ({ - id: `${a.id}-inv-${i}`, - author: a.name, - isAi: true, - text: `${inv.preview}\n\n_in ${inv.channel} · ${inv.when}_`, - time: inv.when, - })), - } -} - -// DM list = workspace humans + agents merged. Existing detailed DM threads -// (with messages / unread) override the synthesized empty ones. -type DmEntry = { - conv: Conversation - kind: "human" | "agent" - status?: "running" | "idle" | "error" // agents only - hasActivity: boolean -} - -const HUMAN_MEMBERS = ["panlilu"] - -function buildDmList(): DmEntry[] { - const seen = new Map<string, Conversation>() - for (const d of DMS) seen.set(d.name, d) - - const entries: DmEntry[] = [] - for (const name of HUMAN_MEMBERS) { - const existing = seen.get(name) - const conv: Conversation = existing ?? { id: `dm-${name}`, kind: "dm", name, messages: [] } - entries.push({ conv, kind: "human", hasActivity: (existing?.messages.length ?? 0) > 0 }) - seen.delete(name) - } - for (const a of AGENTS) { - const existing = seen.get(a.name) - const conv = existing ?? agentToConversation(a) - entries.push({ - conv, - kind: "agent", - status: a.status, - hasActivity: (existing?.messages.length ?? 0) > 0, - }) - seen.delete(a.name) - } - // Active threads first, then alphabetical empties - return entries.sort((a, b) => { - if (a.hasActivity !== b.hasActivity) return a.hasActivity ? -1 : 1 - return a.conv.name.localeCompare(b.conv.name) - }) -} - -export function ChatPage() { - // URL 模式: - // channel: /chat/:id → params.id 直接用 - // dm: /chat/dm/:name → 内部 id 是 dm-<name> - const params = useParams<{ id?: string; name?: string }>() - const navigate = useNavigate() - const active = () => (params.name ? `dm-${params.name}` : params.id ?? "all") - const setActive = (id: string) => { - if (id.startsWith("dm-")) navigate(`/chat/dm/${id.slice(3)}`) - else navigate(`/chat/${id}`) - } - const [refExpanded, setRefExpanded] = createSignal(false) - const dmList = buildDmList() - const activeDms = () => dmList.filter((e) => e.hasActivity) - const allConvos = () => [...CHANNELS, ...dmList.map((e) => e.conv)] - const conversation = () => allConvos().find((c) => c.id === active()) ?? CHANNELS[0] - const [memberFilter, setMemberFilter] = createSignal("") - const [membersOpen, setMembersOpen] = createSignal(false) - const filteredMembers = () => { - const q = memberFilter().toLowerCase() - return (conversation().members ?? []).filter((m) => m.toLowerCase().includes(q)) - } - const memberKind = (name: string): "human" | "agent" => - AGENTS.some((a) => a.name === name) ? "agent" : "human" - // 命名空间统一:humans + agents 都是 `dm-<name>` - // (workspace 内 name 唯一即可,agent 跟人不区分) - const memberDmId = (name: string) => `dm-${name}` - - // Loops that have this conversation in their context.chats[]. - const referencedBy = createMemo(() => - loops().filter((l) => - (l.context.chats ?? []).some((c) => c.id === active()), - ), - ) - - return ( - <div class="flex h-full w-full"> - {/* Channels rail */} - <aside class="w-60 shrink-0 border-r border-gray-200 bg-white flex flex-col"> - <div class="px-3 mt-3 mb-1 text-xs text-gray-500 flex items-center justify-between"> - <span>Channels</span> - <button class="text-gray-500 hover:text-gray-900"> - <Icon name="enter" /> - </button> - </div> - <div class="flex flex-col gap-0.5"> - <For each={CHANNELS}> - {(c) => <ConversationRow conv={c} active={active() === c.id} onClick={() => setActive(c.id)} />} - </For> - </div> - <div class="px-3 mt-3 mb-1 text-xs text-gray-500" title="找新人请去 #all 频道的成员列表"> - Direct messages - </div> - <div class="flex flex-col gap-0.5"> - <For each={activeDms()}> - {(e) => ( - <DmRow - entry={e} - active={active() === e.conv.id} - onClick={() => setActive(e.conv.id)} - /> - )} - </For> - <Show when={activeDms().length === 0}> - <div class="mx-2 px-2 py-1 text-[11px] text-gray-400"> - 没有 active DM · 去 #all 找人 - </div> - </Show> - </div> - <div class="flex-1" /> - </aside> - - {/* Conversation pane */} - <main class="flex-1 min-w-0 flex flex-col bg-white"> - <header class="px-5 h-12 shrink-0 border-b border-gray-200 flex items-center justify-between"> - <div> - <div class="flex items-center gap-2"> - <span class="text-[15px] font-medium text-gray-900"> - {conversation().kind === "channel" ? `#${conversation().name}` : `@${conversation().name}`} - </span> - <Show when={conversation().topic}> - <span class="text-xs text-gray-500">— {conversation().topic}</span> - </Show> - </div> - <Show when={conversation().members}> - <button - type="button" - onClick={() => setMembersOpen(!membersOpen())} - class="text-[11px] text-gray-500 hover:text-gray-700 mt-0.5 flex items-center gap-1" - title="点击展开成员列表 · 可搜索 · 点名字开 DM" - > - <span>{conversation().members!.length} members</span> - <span class="text-gray-400">{membersOpen() ? "▴" : "▾"}</span> - </button> - </Show> - </div> - <div class="flex items-center gap-2 text-xs text-gray-500"> - <button class="px-2 py-1 rounded hover:bg-gray-100 hover:text-gray-900 flex items-center gap-1"> - <Icon name="brain" /> - <span>summarize</span> - </button> - <button class="px-2 py-1 rounded hover:bg-gray-100 hover:text-gray-900 flex items-center gap-1"> - <Icon name="fork" /> - <span>spawn loop</span> - </button> - </div> - </header> - - <Show when={membersOpen() && conversation().members}> - <div class="shrink-0 border-b border-gray-200 bg-gray-50/30 px-5 py-3"> - <div class="flex items-center gap-2 mb-2"> - <input - type="text" - value={memberFilter()} - onInput={(e) => setMemberFilter(e.currentTarget.value)} - placeholder={`搜索 ${conversation().members!.length} 个成员…`} - class="flex-1 px-2 py-1 text-[12px] rounded border border-gray-200 bg-white outline-none focus:border-gray-400" - /> - <span class="text-[11px] text-gray-500">{filteredMembers().length} match</span> - </div> - <div class="flex flex-wrap gap-1.5"> - <For each={filteredMembers()}> - {(name) => { - const kind = memberKind(name) - const isMe = name === "simpx" - return ( - <button - type="button" - disabled={isMe} - onClick={() => { - setActive(memberDmId(name)) - setMembersOpen(false) - setMemberFilter("") - }} - class={ - isMe - ? "px-2 py-1 rounded text-[12px] flex items-center gap-1.5 bg-gray-100 text-gray-500 cursor-default" - : "px-2 py-1 rounded text-[12px] flex items-center gap-1.5 bg-white border border-gray-200 hover:border-gray-400 hover:bg-gray-50 text-gray-900" - } - title={isMe ? "(you)" : `DM ${name}`} - > - <span class="w-1.5 h-1.5 rounded-full bg-emerald-500" /> - <span>{name}</span> - <Show when={isMe}> - <span class="text-[10px] text-gray-400">you</span> - </Show> - <Show when={kind === "agent"}> - <span class="text-[10px] text-gray-400">🤖</span> - </Show> - </button> - ) - }} - </For> - <Show when={filteredMembers().length === 0}> - <span class="text-[12px] text-gray-500">no match</span> - </Show> - </div> - </div> - </Show> - - <Show when={referencedBy().length > 0}> - <div class="shrink-0 border-b border-gray-200 bg-gray-50/40"> - <button - type="button" - onClick={() => setRefExpanded(!refExpanded())} - class="w-full px-5 py-1.5 flex items-center gap-2 text-[12px] text-gray-600 hover:bg-gray-100/60" - > - <span>📤</span> - <span> - <b class="text-gray-900">{referencedBy().length} loops</b> 把这条 chat 作为 context - </span> - <span class="text-gray-400">{refExpanded() ? "▴" : "▾"}</span> - </button> - <Show when={refExpanded()}> - <ul class="px-5 pb-2 flex flex-col gap-0.5"> - <For each={referencedBy()}> - {(loop) => { - const mount = loop.context.chats?.find((c) => c.id === active()) - return ( - <li> - <button - type="button" - onClick={() => navigate(`/loop/${loop.id}`)} - class="w-full text-left px-2 py-1 rounded hover:bg-white flex items-center gap-2 text-[12px]" - > - <span class="text-gray-400">⑂</span> - <span class="text-gray-900">{loop.name}</span> - <Show when={loop.rfd}> - <span class="text-amber-600 text-[10px]">RFD</span> - </Show> - <span class="text-gray-500">·</span> - <span class="text-gray-500">{loop.driver}</span> - <span class="text-gray-500">·</span> - <span class="text-gray-500">{loop.lastActivityAgo}</span> - <Show when={mount}> - <span class="ml-auto text-gray-400 font-mono text-[11px]"> - up to msg #{mount!.upTo} - </span> - </Show> - </button> - </li> - ) - }} - </For> - </ul> - </Show> - </div> - </Show> - - <div class="flex-1 min-h-0 overflow-auto px-5 py-4 flex flex-col gap-3"> - <For each={conversation().messages}>{(m) => <MessageRow message={m} />}</For> - <Show when={conversation().messages.length === 0}> - <div class="text-[13px] text-gray-500">No messages yet · say hi</div> - </Show> - </div> - - <div class="px-5 pb-4 pt-2 shrink-0"> - <div class="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 flex items-center gap-2"> - <Icon name="prompt" class="text-gray-500" /> - <input - type="text" - class="flex-1 bg-transparent outline-none text-[13px] text-gray-900 placeholder:text-gray-500" - placeholder={`Message ${ - conversation().kind === "channel" ? "#" + conversation().name : "@" + conversation().name - }…`} - /> - <span class="text-[11px] text-gray-500">/ commands · @ mentions</span> - <button class="px-3 py-1 rounded bg-gray-200 text-gray-900 text-xs hover:bg-gray-300">send</button> - </div> - </div> - </main> - </div> - ) -} - -function DmRow(props: { entry: DmEntry; active: boolean; onClick: () => void }) { - const e = props.entry - const statusDot = () => { - if (e.kind !== "agent") return "bg-emerald-500" - if (e.status === "running") return "bg-emerald-500" - if (e.status === "error") return "bg-red-500" - return "bg-gray-300" - } - return ( - <button - type="button" - onClick={props.onClick} - class={ - props.active - ? "mx-2 px-2 py-1 rounded text-[13px] flex items-center gap-2 bg-gray-100 text-gray-900" - : "mx-2 px-2 py-1 rounded text-[13px] flex items-center gap-2 text-gray-500 hover:bg-gray-50 hover:text-gray-900" - } - title={e.kind === "agent" ? "agent · 配置在 Context tab" : "human"} - > - <span class={`w-1.5 h-1.5 rounded-full shrink-0 ${statusDot()}`} /> - <span class="truncate flex-1 text-left">{e.conv.name}</span> - <Show when={e.kind === "agent"}> - <span class="text-[10px] text-gray-400 shrink-0" title="agent">🤖</span> - </Show> - <Show when={e.conv.unread}> - <span class="text-[11px] px-1.5 rounded-full bg-gray-200 text-gray-700">{e.conv.unread}</span> - </Show> - </button> - ) -} - -function ConversationRow(props: { conv: Conversation; active: boolean; onClick: () => void }) { - return ( - <button - type="button" - onClick={props.onClick} - class={ - props.active - ? "mx-2 px-2 py-1 rounded text-[13px] flex items-center justify-between bg-gray-100 text-gray-900" - : "mx-2 px-2 py-1 rounded text-[13px] flex items-center justify-between text-gray-500 hover:bg-gray-50 hover:text-gray-900" - } - > - <span class="flex items-center gap-2 min-w-0"> - <span class="text-gray-400">{props.conv.kind === "channel" ? "#" : "@"}</span> - <span class="truncate">{props.conv.name}</span> - </span> - <Show when={props.conv.unread}> - <span class="text-[11px] px-1.5 rounded-full bg-gray-200">{props.conv.unread}</span> - </Show> - </button> - ) -} - -function MessageRow(props: { message: Message }) { - const m = props.message - return ( - <div class="flex gap-3"> - <div - class={ - m.isAi - ? "w-7 h-7 rounded shrink-0 flex items-center justify-center text-[11px] font-medium bg-gray-100 text-gray-900 ring-1 ring-gray-200" - : "w-7 h-7 rounded shrink-0 flex items-center justify-center text-[11px] font-medium bg-gray-200 text-gray-900" - } - title={m.author} - > - {m.isAi ? "🤖" : m.author.slice(0, 1).toUpperCase()} - </div> - <div class="flex-1 min-w-0"> - <div class="flex items-center gap-2"> - <span class="text-[13px] font-medium text-gray-900">{m.author}</span> - <Show when={m.isAi}> - <span class="text-[10px] px-1 rounded bg-gray-100 text-gray-500">AI</span> - </Show> - <Show when={m.isMe}> - <span class="text-[10px] text-gray-500">you</span> - </Show> - <span class="text-[11px] text-gray-500">{m.time}</span> - </div> - <div class="text-[13px] text-gray-900 whitespace-pre-wrap leading-relaxed">{m.text}</div> - </div> - </div> - ) -} diff --git a/phase1-prototype/src/pages/context.tsx b/phase1-prototype/src/pages/context.tsx deleted file mode 100644 index a430532c..00000000 --- a/phase1-prototype/src/pages/context.tsx +++ /dev/null @@ -1,2406 +0,0 @@ -/** - * Context tab — sub-nav (Knowledge / Agents / Repos), each owns - * sidebar+main below the sub-nav. - * - * Ported from opencode prototype loop-tab-context.tsx; markdown render - * uses local Markdown component (marked-based). Wikilinks `[[X]]` - * transform to `[X](#wiki:X)` then click handler navigates. - */ -import { createSignal, For, Show } from "solid-js" -import { useParams, useNavigate } from "@solidjs/router" -import { Icon } from "../components/icon" -import { Markdown } from "../components/markdown" -import { CodeEditor } from "../components/code-editor" -import { createEditLoop, createDistillLoop } from "../state" - -type SubTab = "knowledge" | "notes" | "personal" | "agents" | "repos" - -const VALID_SUBS: SubTab[] = ["knowledge", "notes", "personal", "agents", "repos"] - -const SUB_TABS: Array<{ id: SubTab; label: string; sub?: string; count?: number }> = [ - { id: "knowledge", label: "Knowledge", sub: "team · sedimented", count: 18 }, - { id: "notes", label: "Notes", sub: "team · public", count: 11 }, - { id: "personal", label: "Personal", sub: "yours · private", count: 17 }, - { id: "agents", label: "Agents", sub: "active · executable", count: 4 }, - { id: "repos", label: "Repos", sub: "passive · code", count: 4 }, -] - -export function ContextPage() { - const params = useParams<{ sub: string; path?: string }>() - const navigate = useNavigate() - const sub = (): SubTab => - (VALID_SUBS as string[]).includes(params.sub) ? (params.sub as SubTab) : "knowledge" - const subPath = () => params.path ?? "" - const navigateTo = (sub: SubTab, path: string) => { - const trimmed = path.replace(/^\/+|\/+$/g, "") - navigate(trimmed ? `/context/${sub}/${trimmed}` : `/context/${sub}`) - } - return ( - <div class="flex flex-col h-full w-full"> - <nav class="flex items-center gap-1 px-3 h-9 shrink-0 border-b border-gray-200 bg-white"> - <For each={SUB_TABS}> - {(t) => ( - <button - type="button" - onClick={() => navigate(`/context/${t.id}`)} - class={ - sub() === t.id - ? "h-7 px-2.5 rounded flex items-center gap-1.5 text-xs bg-gray-100 text-gray-900" - : "h-7 px-2.5 rounded flex items-center gap-1.5 text-xs text-gray-500 hover:bg-gray-50 hover:text-gray-900" - } - > - <span>{t.label}</span> - {t.count !== undefined && ( - <span - class={ - sub() === t.id - ? "text-[10px] px-1 rounded-full bg-gray-200 text-gray-900" - : "text-[10px] px-1 rounded-full bg-gray-100 text-gray-500" - } - > - {t.count} - </span> - )} - </button> - )} - </For> - </nav> - - <div class="flex-1 min-h-0 min-w-0"> - <Show when={sub() === "knowledge"}> - <VaultPane - vault="knowledge" - urlPath={subPath} - onNavigate={(p) => navigateTo("knowledge", p)} - /> - </Show> - <Show when={sub() === "notes"}> - <VaultPane - vault="notes" - urlPath={subPath} - onNavigate={(p) => navigateTo("notes", p)} - /> - </Show> - <Show when={sub() === "personal"}> - <VaultPane - vault="personal" - urlPath={subPath} - onNavigate={(p) => navigateTo("personal", p)} - /> - </Show> - <Show when={sub() === "agents"}> - <AgentsPane urlId={subPath} onNavigate={(id) => navigateTo("agents", id)} /> - </Show> - <Show when={sub() === "repos"}> - <ReposPane urlId={subPath} onNavigate={(id) => navigateTo("repos", id)} /> - </Show> - </div> - </div> - ) -} - -// ============================================================================ -// Vaults — Knowledge / Notes / Personal share the same markdown-vault UI -// ============================================================================ - -export type DocNode = - | { - kind: "folder" - name: string - children: DocNode[] - marker?: "ai-write" | "secrets" - } - | { kind: "file"; name: string; path: string; updatedAgo?: string; secret?: boolean } - -export type VaultId = "knowledge" | "notes" | "personal" - -export function flattenVaultFiles(nodes: DocNode[]): { path: string; secret?: boolean }[] { - const out: { path: string; secret?: boolean }[] = [] - const walk = (n: DocNode) => { - if (n.kind === "file") out.push({ path: n.path, secret: n.secret }) - else n.children.forEach(walk) - } - nodes.forEach(walk) - return out -} - -// ----- Knowledge: team · sedimented ----- -const KNOWLEDGE_DOCS: DocNode[] = [ - { - kind: "folder", - name: "loopat", - children: [ - { kind: "file", name: "concepts.md", path: "loopat/concepts.md", updatedAgo: "2h" }, - { kind: "file", name: "architecture.md", path: "loopat/architecture.md", updatedAgo: "1d" }, - { kind: "file", name: "phase-roadmap.md", path: "loopat/phase-roadmap.md", updatedAgo: "3d" }, - { kind: "file", name: "naming.md", path: "loopat/naming.md", updatedAgo: "5d" }, - { kind: "file", name: "attach-protocol-spec.md", path: "loopat/attach-protocol-spec.md", updatedAgo: "12h" }, - ], - }, - { - kind: "folder", - name: "ai-org", - children: [ - { kind: "file", name: "vision.md", path: "ai-org/vision.md", updatedAgo: "5d" }, - { kind: "file", name: "1001-philosophy.md", path: "ai-org/1001-philosophy.md", updatedAgo: "3d" }, - { kind: "file", name: "three-scarce-resources.md", path: "ai-org/three-scarce-resources.md", updatedAgo: "1w" }, - { kind: "file", name: "loop-is-everything.md", path: "ai-org/loop-is-everything.md", updatedAgo: "2w" }, - ], - }, - { - kind: "folder", - name: "conventions", - children: [ - { kind: "file", name: "loop-naming.md", path: "conventions/loop-naming.md", updatedAgo: "2w" }, - { kind: "file", name: "commit-messages.md", path: "conventions/commit-messages.md", updatedAgo: "2mo" }, - { kind: "file", name: "code-style-ts.md", path: "conventions/code-style-ts.md", updatedAgo: "1mo" }, - { kind: "file", name: "knowledge-layout.md", path: "conventions/knowledge-layout.md", updatedAgo: "1w" }, - ], - }, - { - kind: "folder", - name: "skills", - children: [ - { - kind: "folder", - name: "loop-handoff", - children: [ - { kind: "file", name: "SKILL.md", path: "skills/loop-handoff/SKILL.md", updatedAgo: "1w" }, - ], - }, - { - kind: "folder", - name: "distill-to-knowledge", - children: [ - { kind: "file", name: "SKILL.md", path: "skills/distill-to-knowledge/SKILL.md", updatedAgo: "5d" }, - ], - }, - { - kind: "folder", - name: "spawn-from-chat", - children: [ - { kind: "file", name: "SKILL.md", path: "skills/spawn-from-chat/SKILL.md", updatedAgo: "3d" }, - ], - }, - ], - }, -] - -// ----- Notes: team · public; 任何人 / AI 都可以写入 ----- -const NOTES_DOCS: DocNode[] = [ - { kind: "file", name: "inbox.md", path: "inbox.md", updatedAgo: "12m" }, - { kind: "file", name: "focus.md", path: "focus.md", updatedAgo: "2d" }, - { - kind: "folder", - name: "research", - children: [ - { kind: "file", name: "opencode-deep-dive.md", path: "research/opencode-deep-dive.md", updatedAgo: "5h" }, - { kind: "file", name: "claude-code-internals.md", path: "research/claude-code-internals.md", updatedAgo: "2d" }, - { kind: "file", name: "pi-dev-eval.md", path: "research/pi-dev-eval.md", updatedAgo: "4d" }, - { kind: "file", name: "next-auth-beta-notes.md", path: "research/next-auth-beta-notes.md", updatedAgo: "1d" }, - ], - }, - { - kind: "folder", - name: "memory", - marker: "ai-write", - children: [ - { kind: "file", name: "weekly-snapshot-2026-05-09.md", path: "memory/weekly-snapshot-2026-05-09.md", updatedAgo: "3h" }, - { kind: "file", name: "spike-comparison.md", path: "memory/spike-comparison.md", updatedAgo: "1d" }, - ], - }, - { - kind: "folder", - name: "meeting", - children: [ - { kind: "file", name: "2026-05-09-spike-decision.md", path: "meeting/2026-05-09-spike-decision.md", updatedAgo: "5h" }, - { kind: "file", name: "2026-05-02-kickoff.md", path: "meeting/2026-05-02-kickoff.md", updatedAgo: "1w" }, - ], - }, - { - kind: "folder", - name: "daily", - children: [ - { kind: "file", name: "2026-05-04.md", path: "daily/2026-05-04.md", updatedAgo: "12m" }, - { kind: "file", name: "2026-05-03.md", path: "daily/2026-05-03.md", updatedAgo: "1d" }, - ], - }, -] - -// ----- Personal: yours · private; obsidian-like git repo ----- -const PERSONAL_DOCS: DocNode[] = [ - { - kind: "folder", - name: "vault", - children: [ - { kind: "file", name: "1001-自己想法.md", path: "vault/1001-自己想法.md", updatedAgo: "2h" }, - { kind: "file", name: "random-2026-05-05.md", path: "vault/random-2026-05-05.md", updatedAgo: "6h" }, - { kind: "file", name: "career-thoughts.md", path: "vault/career-thoughts.md", updatedAgo: "2w" }, - ], - }, - { - kind: "folder", - name: "ideas", - children: [ - { kind: "file", name: "obsidian-graph-feature.md", path: "ideas/obsidian-graph-feature.md", updatedAgo: "3d" }, - { kind: "file", name: "loop-pricing-model.md", path: "ideas/loop-pricing-model.md", updatedAgo: "1w" }, - ], - }, - { - kind: "folder", - name: "daily", - children: [ - { kind: "file", name: "2026-05-05.md", path: "daily/2026-05-05.md", updatedAgo: "30m" }, - { kind: "file", name: "2026-05-04.md", path: "daily/2026-05-04.md", updatedAgo: "1d" }, - { kind: "file", name: "2026-05-03.md", path: "daily/2026-05-03.md", updatedAgo: "2d" }, - ], - }, - { - kind: "folder", - name: "style", - children: [ - { kind: "file", name: "voice-tone.md", path: "style/voice-tone.md", updatedAgo: "5d" }, - { kind: "file", name: "english-style.md", path: "style/english-style.md", updatedAgo: "1mo" }, - { kind: "file", name: "code-aesthetics.md", path: "style/code-aesthetics.md", updatedAgo: "3w" }, - ], - }, - { - kind: "folder", - name: "drafts", - children: [ - { kind: "file", name: "1001-blog-part-1.md", path: "drafts/1001-blog-part-1.md", updatedAgo: "1d" }, - { kind: "file", name: "loop-talk-outline.md", path: "drafts/loop-talk-outline.md", updatedAgo: "5d" }, - ], - }, - { kind: "file", name: "private-todo.md", path: "private-todo.md", updatedAgo: "1h" }, - { - kind: "folder", - name: "secrets", - marker: "secrets", - children: [ - { kind: "file", name: "LOOPAT_API_KEY", path: "secrets/LOOPAT_API_KEY", secret: true, updatedAgo: "12d" }, - { kind: "file", name: "GITHUB_TOKEN", path: "secrets/GITHUB_TOKEN", secret: true, updatedAgo: "1mo" }, - { kind: "file", name: "OPENAI_API_KEY", path: "secrets/OPENAI_API_KEY", secret: true, updatedAgo: "2mo" }, - ], - }, -] - -export const VAULT_DOCS: Record<VaultId, DocNode[]> = { - knowledge: KNOWLEDGE_DOCS, - notes: NOTES_DOCS, - personal: PERSONAL_DOCS, -} - -const VAULT_META: Record<VaultId, { initialPath: string; defaultOpen: string[]; footer: string }> = { - knowledge: { - initialPath: "loopat/concepts.md", - defaultOpen: ["loopat", "ai-org", "conventions", "skills"], - footer: "team's distilled materials", - }, - notes: { - initialPath: "research/opencode-deep-dive.md", - defaultOpen: ["research", "memory", "meeting"], - footer: "team · public", - }, - personal: { - initialPath: "vault/1001-自己想法.md", - defaultOpen: ["vault", "ideas", "daily", "style", "drafts", "secrets"], - footer: "yours · private", - }, -} - -type DocFrontmatter = { - title?: string - tags?: string[] - updated?: string - driver?: string -} - -type DocPage = { - frontmatter: DocFrontmatter - body: string - backlinks: { path: string; preview: string }[] -} - -const KNOWLEDGE_CONTENT: Record<string, DocPage> = { - "loop/overview.md": { - frontmatter: { title: "Loop Overview", tags: ["loop", "core"], updated: "2h", driver: "simpx" }, - body: `# Overview - -Loop is the **basic unit of work** in 1001. See [[ai-org/vision.md]] for why. - -Each loop carries: - -- a workspace dir -- a chat history -- participants (humans + AI) -- artifacts (verifiable outputs) - -## Lifecycle - -\`\`\` -Open → Active → Closed | Forked → Archived -\`\`\` - -Closure happens when [[loop/lifecycle.md]] criteria are met. - -## Relationship to other concepts - -| | Loop | Focus | Context | -|---|---|---|---| -| Resource | desire | attention | entropy reduction | -| Cardinality | many | curated | accumulating | - -A loop can be tagged \`#focus\` to surface it in the Focus tab — see -[[ai-org/1001-philosophy.md]].`, - backlinks: [ - { path: "loop/lifecycle.md", preview: "...closure events flip state, see [[loop/overview.md]]..." }, - { path: "ai-org/vision.md", preview: "...the basic unit of work is a [[loop/overview.md|loop]]..." }, - { path: "daily/2026-05-04.md", preview: "...refactored [[loop/overview.md]] to add lifecycle table..." }, - ], - }, - "loop/lifecycle.md": { - frontmatter: { title: "Loop Lifecycle", tags: ["loop"], updated: "1d" }, - body: `# Loop Lifecycle - -Four states + one side-state. - -States: - -1. **Open** — \`loop new\`, dir + empty chat -2. **Active** — work happening -3. **Closed** — runtime verified, or driver marked done -4. **Archived** — settled, knowledge precipitates - -Side: **Forked** — branched off another loop. - -Closure criteria: see [[loop/overview.md#lifecycle]].`, - backlinks: [{ path: "loop/overview.md", preview: "...[[loop/lifecycle.md]] criteria are met..." }], - }, - "ai-org/vision.md": { - frontmatter: { title: "1001 Vision", tags: ["ai-org", "core"], updated: "5d" }, - body: `# 1001 Vision - -> Loop is everything. Runtime is the membrane. Knowledge is the flow. - -人类的稀缺资源映射到三个一级概念: - -- **Loop** ← 驱动力 — see [[loop/overview.md]] -- **Focus** ← 注意力 -- **Context** ← 熵减能力 (this!) - -Chat 是协调通道,不属于稀缺资源轴。 - -只有**人类参与**,Context 才会越来越精简 — AI 能产生输出但不会自发追求简洁。 -关于这个原则的展开,看 [[ai-org/1001-philosophy.md]]。`, - backlinks: [ - { path: "loop/overview.md", preview: "See [[ai-org/vision.md]] for why." }, - { path: "ai-org/1001-philosophy.md", preview: "...continues from [[ai-org/vision.md]]..." }, - ], - }, - "ai-org/1001-philosophy.md": { - frontmatter: { title: "1001 Philosophy", tags: ["ai-org"], updated: "3d" }, - body: `# 1001 Philosophy - -(extends [[ai-org/vision.md]]) - -## Driver = human - -AI has no autonomous desire. The driver — the source of intent — must be a -human. Agents are tools the driver uses, not autonomous actors. - -## Three scarce resources - -The product surface is shaped by what's scarce in the human: - -1. **Drive (驱动力)** — \`Loop\` -2. **Attention (注意力)** — \`Focus\` -3. **Entropy reduction (熵减能力)** — \`Context\` (you're reading it)`, - backlinks: [ - { path: "ai-org/vision.md", preview: "...[[ai-org/1001-philosophy.md]]." }, - { path: "loop/overview.md", preview: "see [[ai-org/1001-philosophy.md]]." }, - ], - }, - "gateway/cache-strategies.md": { - frontmatter: { title: "KV Cache Strategies", tags: ["gateway"], updated: "1w" }, - body: `# KV Cache Strategies - -(WIP — picking up from [[gateway/rdma-mr-register.md]]) - -## SLRU + Ghost - -Two-tier admission with a ghost queue for re-admission. Helps with cache -thrashing under workload shifts. - -## Eviction - -LRU baseline, then SLRU+G overlay. See vllm fork for impl.`, - backlinks: [], - }, - "gateway/rdma-mr-register.md": { - frontmatter: { title: "RDMA mr_register", tags: ["rdma"], updated: "2w" }, - body: `# RDMA mr_register - -\`mr_register\` is the bottleneck for RDMA-backed cache. Page alignment must -match cuda alignment. - -See loops: gateway-launch, rdma-fix.`, - backlinks: [ - { path: "gateway/cache-strategies.md", preview: "picking up from [[gateway/rdma-mr-register.md]]" }, - ], - }, - "loop/rfd-and-claim.md": { - frontmatter: { title: "RFD & Claim", tags: ["loop"], updated: "4d" }, - body: `# RFD (Request For Drive) & Claim - -When the current driver can't keep going (会议、休假、阻塞),可以 **release** loop。 -Loop 进入 RFD 状态,任何成员都能 **claim drive** 接手。 - -## 流程 - -1. 当前 driver 点 RFD → loop 状态变 \`active · RFD\` -2. 列表里出现 RFD 标,所有成员可见 -3. 接手人点 drive → driver 改成接手人,状态回 \`active\` -4. 时间线记录 \`released by X\` + \`claimed by Y\` - -## 自己 RFD 自己接 - -允许的——同一人也可以 release 后再 drive 自己。常用于"我先 release 一下让别人有机会,没人接我自己继续"。`, - backlinks: [], - }, - "ai-org/three-scarce-resources.md": { - frontmatter: { title: "Three Scarce Resources", tags: ["ai-org", "core"], updated: "1w" }, - body: `# 三种稀缺资源 - -AI 时代,人贡献什么?三件事: - -1. **驱动力** (drive) — 决定做 X 而不是 Y。映射到 \`Loop\` -2. **注意力** (attention) — 识别什么重要。映射到 \`Focus\` -3. **熵减能力** (entropy reduction) — 把混乱整理成清晰。映射到 \`Context\` - -Chat 不在这条轴上——它是协调通道,不是稀缺资源。 - -延伸阅读:[[ai-org/vision.md]] / [[ai-org/1001-philosophy.md]]`, - backlinks: [ - { path: "ai-org/vision.md", preview: "...展开看 [[ai-org/three-scarce-resources.md]]" }, - ], - }, - "ml/long-context-techniques.md": { - frontmatter: { title: "Long Context Techniques", tags: ["ml", "long-context"], updated: "3w" }, - body: `# Long Context Techniques - -> 沉淀自 llama-research loop 的发现 + 公开 papers - -## Attention 优化 - -- **Flash-Attention v3** — IO-aware kernel,64k+ 长度收益最大 -- **MLA (Multi-head Latent Attention)** — KV cache 压缩,降 IO 5-8x -- **Sliding window** — 超长序列下截断,但需要任务能容忍 - -## Prefill 优化 - -- **Chunked prefill** — 流水化,提高 GPU util -- **Shared prefix cache** — 同 prompt 前缀复用,命中率 > 60% 才有意义 -- **Paged KV** — 减少 IO round-trip - -## 实测瓶颈 - -64k+ 长度下,attention 自身 IO 是瓶颈,不是 compute。GPU util 通常 30-40%。`, - backlinks: [], - }, - "ml/speculative-decoding.md": { - frontmatter: { title: "Speculative Decoding", tags: ["ml"], updated: "1mo" }, - body: `# Speculative Decoding - -用小模型起草,大模型验证。延迟降低 ~2x,吞吐持平。 - -## 实现要点 - -- draft 模型 vocab 必须是 target 的子集 -- 一次生成 4-8 token candidate,并行验证 -- 接受率 > 60% 才划算 - -## 适用场景 - -延迟敏感、token 不太长的场景(chat、补全)。长生成(>1k tokens)收益递减。`, - backlinks: [], - }, - "conventions/git-style.md": { - frontmatter: { title: "Git Style", tags: ["conventions"], updated: "2mo" }, - body: `# Git Style - -## Commit 信息 - -\`type: scope: subject\` 风格,第一行 ≤ 60 字符。 - -\`\`\` -feat: runtime: paginate list-shards -fix: rdma: align buffer to 4K before reg -docs: 1001: add loop lifecycle diagram -\`\`\` - -## Branch 命名 - -- \`feat/<thing>\` 新功能 -- \`fix/<thing>\` 修 bug -- \`refactor/<thing>\` 重构 -- 临时探索:\`spike/<thing>\` - -## Merge 策略 - -- 默认 rebase,保持线性历史 -- 不用 merge commit,除非显式要求`, - backlinks: [], - }, - "conventions/code-style-go.md": { - frontmatter: { title: "Go Code Style", tags: ["conventions", "go"], updated: "2mo" }, - body: `# Go Code Style - -## 错误处理 - -- \`if err != nil { return fmt.Errorf("ctx: %w", err) }\` -- 永远 wrap,永远加 context - -## 注释 - -- 只写 *why*,不解释 *what* -- public symbol 必须有 godoc 注释,简洁 - -## Package layout - -- \`cmd/\` 入口 -- \`internal/\` 私有 -- \`api/\` 对外 SDK -- 不用 \`pkg/\``, - backlinks: [], - }, - "conventions/code-style-python.md": { - frontmatter: { title: "Python Code Style", tags: ["conventions", "python"], updated: "2mo" }, - body: `# Python Code Style - -- type hints 必填 -- 用 \`ruff\` + \`pyright\`,不用 black(ruff format 取代) -- 异步用 \`asyncio\`,不混 \`trio\` -- Pydantic v2 only`, - backlinks: [], - }, - "skills/loop-handoff/SKILL.md": { - frontmatter: { title: "Loop Handoff", tags: ["skill"], updated: "1w" }, - body: `--- -name: loop-handoff -description: 在 loop 移交(RFD / claim)时,整理 context 让下一位 driver 能快速接手 -trigger: explicit ---- - -# Loop Handoff - -## 何时触发 - -driver 主动 \`release\`,或被指派接手 loop 时。 - -## 步骤 - -1. **梳理已完成** — 翻 chat / artifacts,列已 close 的子任务 -2. **梳理在做的** — 当前正在动的代码 / 文档 / 实验 -3. **梳理阻塞** — 卡在哪、需要谁 -4. **personal symlinks unlink** — 移交方的私人 context 拆掉 -5. **dump 一份 handoff note 到 notes/memory/** — 接手人读完就能上手 - -## 输出 - -\`\`\` -## handoff: <loop-name> -- done: ... -- in-flight: ... -- blocked: ... -- watch out: ... -\`\`\``, - backlinks: [], - }, - "skills/distill-to-knowledge/SKILL.md": { - frontmatter: { title: "Distill to Knowledge", tags: ["skill"], updated: "5d" }, - body: `--- -name: distill-to-knowledge -description: 把 notes / loop 产物里成熟的内容沉淀进 knowledge -trigger: ai-suggest ---- - -# Distill to Knowledge - -> 熵减只能由人完成。AI 提示候选,最终是用户决定蒸馏什么、丢什么。 - -## 何时触发 - -AI 看到一段 notes 内容反复被引用、被多个 loop 命中、被 backlink 多次——提示用户考虑 distill。 - -## 评估清单 - -- [ ] 内容稳定(最近 30d 修改 < 2 次) -- [ ] 跨 loop 适用(不是某 loop 局部知识) -- [ ] 有结构(标题、段落、能被引用) -- [ ] 用户认为值得沉淀 - -## 动作 - -- 选目标路径(loop/ / ai-org/ / gateway/ / ml/ / conventions/ / skills/) -- 必要时 reorg、加 frontmatter、补 backlinks -- commit \`docs(knowledge): distill <topic> from notes\``, - backlinks: [], - }, - "skills/code-review-checklist/SKILL.md": { - frontmatter: { title: "Code Review Checklist", tags: ["skill"], updated: "2w" }, - body: `--- -name: code-review-checklist -description: 在 loop 里对一段 diff 做 review 时跑这个 checklist -trigger: explicit ---- - -# Code Review Checklist - -> 不引入独立 review 系统——review 就在 loop 里完成。reviewer 是有 commit 权限的人接 loop。 - -- [ ] 改动范围与 PR 描述一致,没有"夹带" -- [ ] 没有引入 secret 明文(grep \`API_KEY\` / \`TOKEN\`) -- [ ] error 都 wrap 了 context(go: \`%w\`,python: \`raise X from e\`) -- [ ] 公共 API 改了 → CHANGELOG 更新了 -- [ ] 测试覆盖:至少 happy path + 1 个 edge -- [ ] runtime 有 deprecation warn(如果删了 API)`, - backlinks: [], - }, - "skills/incident-triage/SKILL.md": { - frontmatter: { title: "Incident Triage", tags: ["skill", "ops"], updated: "1mo" }, - body: `--- -name: incident-triage -description: 收到 alert / page 后 5 分钟内做的事 -trigger: alert ---- - -# Incident Triage - -## 5 分钟规则 - -收到 page 后 5min 内必须完成: - -1. **ack** — 在告警通道里 \`/ack\`,告诉别人你接手了 -2. **看面板** — Grafana / SLS 找异常 metric -3. **判断 blast radius** — 是单 region / 全网?user-facing 吗? -4. **decide** — rollback / hotfix / 拉群 - -## 判断标准 - -| signal | action | -|---|---| -| qps 跌 50%+ | rollback | -| p99 涨 2x+ 且 sustained | rollback or hotfix | -| 单实例 OOM | restart, 不 rollback | -| auth fail | check token expiry first | - -## 拉群标准 - -涉及 > 1 服务、> 5min 未恢复、或客户已感知 → 拉应急群。`, - backlinks: [], - }, - "skills/incident-triage/runbook-template.md": { - frontmatter: { tags: ["skill", "template"], updated: "1mo" }, - body: `# Runbook template - -事件结束后填这个模板,归档进 \`notes/memory/\`。 - -\`\`\` -## Incident <yyyy-mm-dd-slug> - -- detected: <when, by whom/what> -- impact: <region / pct of traffic / customer count> -- root cause: <one line> -- mitigation: <what was done> -- postmortem owner: <name> -- followups: - - [ ] ... -\`\`\``, - backlinks: [], - }, - "skills/incident-triage/grafana-cheatsheet.md": { - frontmatter: { tags: ["skill", "cheatsheet"], updated: "1mo" }, - body: `# Grafana cheatsheet - -常用 dashboard 直链: - -- **api-latency**: grafana.internal/d/api-latency -- **gateway-cache-hit**: grafana.internal/d/gateway-cache -- **rdma-register**: grafana.internal/d/rdma-mr -- **traffic-mix**: grafana.internal/d/llama-traffic - -筛 5min 窗口:URL 加 \`?from=now-5m&to=now\`。 - -oncall 默认看 api-latency 那张。`, - backlinks: [], - }, - "skills/code-review-checklist/examples.md": { - frontmatter: { tags: ["skill", "examples"], updated: "2w" }, - body: `# Examples - -实际跑过这个 checklist 的几个例子,给 AI / 新人参考。 - -## 例 1:runtime paginate (loopctl loop) - -通过: -- 范围对 ✓ -- 没 secret ✓ -- error wrap 有 \`%w\` ✓ -- CHANGELOG 加了 ✓ -- e2e 测试覆盖 + deprecation warn ✓ - -## 例 2:rdma alignment (gateway-launch loop) - -打回: -- ✗ 改了 \`runtime/gateway.py\` 但 PR 描述没提 -- ✗ 没加测试,只手动验证 -- 修完再 commit`, - backlinks: [], - }, - "loopat/concepts.md": { - frontmatter: { title: "loopat 4 一级概念", tags: ["loopat", "core"], updated: "2h", driver: "simpx" }, - body: `# loopat 4 一级概念 - -> 对外品牌 loopat,内部 codename 1001。 - -## Loop(驱动力) - -first-class 工作单元 = **context + ai + workdir**。绑定一个长程任务和一个 driver。 - -强单人语义但允许"无 driver 出生"(rfd from creation)—— 这正是 incident queue 的形态。 - -参考 [[loopat/architecture.md]] §2。 - -## Focus(注意力) - -团队当下"什么重要"的派生 view。**不是 entity**,状态预算只有 \`notes/focus.md\` 几行 pinned/listed。 -其余从 \`loop.focuses[]\` 派生。 - -参考 [[ai-org/three-scarce-resources.md]]。 - -## Context(熵减能力) - -team's distilled materials,三种形态: - -- **Knowledge** — 沉淀文档(你正在读的) -- **Notes** — 团队 prose(含 inbox.md 稀碎) -- **Repos** — git 仓 -- **Agents** — 可执行外壳,配置在 Context,调用在 Chat - -(Personal 是私人的,跟 team Context 隔离) - -## Chat(sync 协调) - -ephemeral context。通过 \`loop.context.chats[]\` 被 loop ingest 后变成 first-class context source。 - -参考 [[loopat/architecture.md]] §3。`, - backlinks: [ - { path: "loopat/architecture.md", preview: "...4 一级概念见 [[loopat/concepts.md]]..." }, - { path: "ai-org/loop-is-everything.md", preview: "...[[loopat/concepts.md]] 是产品落地..." }, - ], - }, - "loopat/architecture.md": { - frontmatter: { title: "loopat 架构", tags: ["loopat"], updated: "1d", driver: "panlilu" }, - body: `# loopat 架构 - -## 1. C/S 架构 - -每个 loop 跑在某个 server 上(本机或云端)。client 走同一套 attach 协议。 -0.1 即采用 c/s,避免 0.2 协作时返工。 - -## 2. Loop = AI runtime + Context + Workdir - -\`\`\` -loop { - driver: who's driving (or null = rfd-from-birth / incident) - context: { knowledge, notes, personal, chats[] } - workdir: git worktree - timeline: events (create / driver-change / rfd / claim / fork) -} -\`\`\` - -## 3. Attach 协议(草稿) - -ws topic \`/loop/<id>\`:subscriber 立刻收 snapshot + 增量 event。 -driver-transfer 是事件,所有 client 同步。close 是 send-only。 - -参考 [[loopat/attach-protocol-spec.md]]。 - -## 已决问题 - -- ChatMount 走 mutate(@@id([loopId, channelId])),不做 versioning(先简单) -- driver 字段挂在 session metadata 而不是另起 model -- attach SSE → ws,多 subscriber - -## 未决 - -- workspace 隔离 + 权限边界 -- agent 的 trigger / 安全沙箱`, - backlinks: [ - { path: "loopat/concepts.md", preview: "...参考 [[loopat/architecture.md]] §2..." }, - { path: "loopat/attach-protocol-spec.md", preview: "...扩展自 [[loopat/architecture.md]] §3..." }, - ], - }, - "loopat/phase-roadmap.md": { - frontmatter: { title: "loopat phase roadmap", tags: ["loopat", "planning"], updated: "3d" }, - body: `# Phase Roadmap - -> 1001-mvp.md §2 的精简版。 - -## Phase 1 — 高保真原型 ← **目前在做** - -产出:4 tab UI 原型 + 说明文档。reviewer 看完能讲清 1001 是什么。 - -## Phase 2 — 架构选型 - -候选: -1. fork \`sst/opencode\`(simpx 在做 spike) -2. 自建 Next.js + tRPC + Prisma + Postgres + WS(panlilu 在做 spike) -3. pi.dev 等 — 待评估 - -周末 close 取舍。 - -## Phase 3 — 0.1 单人版 - -100% 替代 simpx 本地 ccx,连续用一周不回退。c/s 架构从 0.1 开始。 - -## Phase 4 — 0.2 多人协作 - -attach 别人 loop。两人完成 spawn → attach → close 流程。`, - backlinks: [ - { path: "ai-org/vision.md", preview: "...phase 计划见 [[loopat/phase-roadmap.md]]..." }, - ], - }, - "loopat/naming.md": { - frontmatter: { title: "loopat 命名由来", tags: ["loopat", "brand"], updated: "5d" }, - body: `# loopat 命名 - -\`loopat.ai\` —— 内嵌两个语义资产: - -1. **loop**(项目核心概念) -2. **pat**(隐藏词)—— 计划做成产品 UX 动词:用户给 AI response 一个 "pat",比 thumbs-up 更具身。 - 跟 RLHF 反馈语义吻合。 - -放弃了"loop at AI"作为短语解读 —— "loop at" 不是英语 idiom,硬解牵强。 - -logo emoji **🧶**(毛线团)—— loop 的有机/暖感呈现,跟 brand 软调性一致。 - -workspace 内部 codename 仍叫 \`1001\`(Scheherazade 起源典故)。`, - backlinks: [], - }, - "loopat/attach-protocol-spec.md": { - frontmatter: { title: "Attach 协议草稿", tags: ["loopat", "spec"], updated: "12h", driver: "simpx" }, - body: `# Attach 协议(草稿 v0) - -> 状态:草稿,simpx 写,明早跟 panlilu 对一遍。 - -## 目标 - -让多个 client 实时 mirror 同一个 loop —— driver 操作 + AI 回复 + chat 增量 全部同步。 - -## Topic - -\`/loop/<id>\` — ws subscription - -## 消息类型 - -| event | direction | payload | -|---|---|---| -| \`snapshot\` | s→c | 完整 loop state(订阅时立刻发) | -| \`message\` | s→c | 新 chat 增量 | -| \`timeline\` | s→c | driver-change / rfd / claim / fork 等系统事件 | -| \`tool-call\` | s→c | AI 调工具的中间状态 | -| \`user-input\` | c→s | 当前 driver 发的消息 | -| \`claim\` | c→s | 非 driver 想 claim drive | - -## 权限 - -非 driver 只能 sub + 发 \`claim\`。driver 改了之后,rfd=false,原 driver 失去 user-input 权限。 - -## 待办 - -- [ ] 跟 panlilu 对齐 ws message envelope 格式 -- [ ] 决定 reconnect / replay 策略 -- [ ] sub auth:workspace token + loop visibility check`, - backlinks: [ - { path: "loopat/architecture.md", preview: "...细节见 [[loopat/attach-protocol-spec.md]]..." }, - ], - }, - "ai-org/loop-is-everything.md": { - frontmatter: { title: "Loop is everything", tags: ["ai-org", "philosophy"], updated: "2w" }, - body: `# Loop is everything - -> Loop is everything. Runtime is the membrane. Knowledge is the flow. - -## Loop is everything - -任何长程工作都装在 loop 里 —— 而不是 todo / issue / channel。 -loop 有 driver、有 workdir、有 context、有 timeline,是一个完整的"工作单元"。 - -## Runtime is the membrane - -好的 runtime(如 dashctl)把分散文档收敛进 CLI 自描述接口,降低 AI context footprint。 -Loop 通过 runtime 跟外界交互(git / file system / agent api)。 - -## Knowledge is the flow - -knowledge 不是仓库里的死文档,是 loop 沉淀出的"流"—— -loop 完成 → distill → knowledge 增长 → 下一个 loop 启动时拉到的 context 更密、更准。`, - backlinks: [ - { path: "loopat/concepts.md", preview: "...[[ai-org/loop-is-everything.md]] 的产品落地..." }, - ], - }, - "conventions/loop-naming.md": { - frontmatter: { title: "Loop 命名约定", tags: ["conventions"], updated: "2w" }, - body: `# Loop 命名 - -- **kebab-case** —— \`loopat-runtime-spike\`,不混 camelCase / 下划线 -- **动词优先**: \`research-opencode\`, \`fix-callback-5xx\`, \`spike-trpc-router\` -- **scope 在前,subject 在后**: \`prototype-hifi\`, \`loopat-ts-mvp\` -- **incident**: 以 \`incident-\` 或描述性词如 \`site-uptime-spike\` 开头`, - backlinks: [], - }, - "conventions/commit-messages.md": { - frontmatter: { title: "Commit message 约定", tags: ["conventions"], updated: "2mo" }, - body: `# Commit Messages - -\`type(scope): subject\` - -第一行 ≤ 60 字符。type 候选: -- \`feat\` 新功能 -- \`fix\` bug 修复 -- \`refactor\` 内部重构 -- \`docs\` 文档 -- \`test\` 测试 -- \`chore\` 工程杂项 - -例: -- \`feat(focus): derive section from loop.focuses[]\` -- \`fix(chat): merge contacts into dms section\``, - backlinks: [], - }, - "conventions/code-style-ts.md": { - frontmatter: { title: "TypeScript 风格", tags: ["conventions"], updated: "1mo" }, - body: `# TypeScript Style - -## 命名 -- \`PascalCase\` for types/components -- \`camelCase\` for functions/variables -- \`UPPER_SNAKE\` for module-level constants - -## 注释 -默认不写。只在 WHY 非显然时写一行。 -不写 WHAT(命名好就够了)。 - -## SolidJS 约定 -- 组件名 PascalCase -- signal getter 末尾 \`()\`,setter 用 \`setX\` -- module-level signal 走 \`createSignal\` + export -- props 严禁 destructure(破坏响应性)`, - backlinks: [], - }, - "conventions/knowledge-layout.md": { - frontmatter: { title: "Knowledge 目录约定", tags: ["conventions"], updated: "1w" }, - body: `# Knowledge Layout - -## 主题目录 + CLAUDE.md 索引 - -每个主题一个目录。目录下的 CLAUDE.md 是索引(一行一文件 + 简述)。 -AI 加载主题时只读索引,需要时再深入。 - -## 内部链接用 wikilink - -\`[[topic/file.md]]\` 而不是相对路径。backlinks 自动维护。 - -## 命名 -- 文件名 kebab-case -- 一个文件 one topic -- frontmatter 含 \`title / tags / updated / driver\` - -## inbox 与 sediment - -\`notes/inbox.md\` 是高熵稀碎的家。沉淀后进 \`knowledge/<topic>/\`。 -\`notes/\` 是公开 prose;\`personal/\` 是私人。`, - backlinks: [], - }, - "skills/spawn-from-chat/SKILL.md": { - frontmatter: { title: "Spawn loop from chat message", tags: ["skill"], updated: "3d" }, - body: `# Spawn loop from chat message - -## 触发 -讨论开始变深入;某条消息值得有 driver 跟到底。 - -## 步骤 -1. 选中消息或 reply 时按 \`⌘L\`(or 右键 → spawn loop) -2. 自动填充:title = 消息前 50 字;context.chats = [{id: this channel, upTo: now}] -3. driver 默认 = 当前用户;可选填 focus tag -4. 跳到新 loop 的 chat 视图,原 channel 留一条系统消息 \`✓ spawned loop xxx\` - -## anti-pattern -- 不要 spawn loop 当 todo —— 稀碎走 \`notes/inbox.md\` -- 不要 spawn loop 当 group chat —— 临时多人聊就直接在 channel 里 - -## 谁能做 -任何 workspace member。AI agent 也可以(如 coo 发现讨论变深入时主动建议)。`, - backlinks: [ - { path: "loopat/concepts.md", preview: "...spawn 流程见 [[skills/spawn-from-chat/SKILL.md]]..." }, - ], - }, -} - -const NOTES_CONTENT: Record<string, DocPage> = { - "inbox.md": { - frontmatter: { tags: ["inbox"], updated: "12m" }, - body: `# 团队稀碎 inbox - -> 一行一个 bullet,没有 status / assignee / due date —— 想做就 spawn loop,不想做直接删。 -> 这不是 todo list,是 prose;腐烂时直接删,不维护"open 数量"。 - -- 看了下 sst/opencode v0.7 release notes,有几个 hook 点变了,回头确认 fork 还能不能 rebase -- tweetdeck 上看到一个聊 'AI org' 的 thread,截图存了 personal/inbox/ -- @panlilu next-auth beta 的 session expire callback 跟 5.0 final 行为不一样,注意 -- 把 1001-mvp.md §3 重写一版,加 c/s 协议的边界 -- https://github.com/sst/opencode/discussions/482 有人问怎么加 attach,回头看下他们怎么想的 -- loopat.ai 域名转 cloudflare 的事还没办,等 panlilu 那边 deployment 决定 -- demo 视频先录第一版(2 分钟),上 hn show 用 -- 周三跟 panlilu 把两条 spike 的取舍讨论 closed,下周二之前定方向`, - backlinks: [], - }, - "focus.md": { - frontmatter: { tags: ["meta"], updated: "2d" }, - body: `# Focus 配置 - -> Focus tab 的唯一"真存"。其余都从 \`loop.focuses[]\` 派生。 - -## pinned - -永不从当下消失,即使 8d 无活动。Focus tab 顶部的 📌 段。 - -- 产品侧高保真原型 -- 可自举的MVP - -## listed - -当前还没绑定 loop 的 meta focus,作为占位出现。当任意 loop 给自己打上对应 tag 时,自动转为正常 focus。 - -- 初版上线 -`, - backlinks: [], - }, - "memory/team-conventions-2026-05.md": { - frontmatter: { tags: ["memory"], updated: "3h", driver: "ai" }, - body: `# Team conventions (2026-05 snapshot) - -> AI 自主整理。从最近 30d 的 loop 行为里观察到的隐性约定。 - -- **Loop 命名** 用 kebab-case(gateway-launch、llama-research),不混 camelCase -- **commit 信息** 第一行 ≤ 60 字符,type: scope: subject 风格 -- **CHANGELOG.md** 一律放仓库根目录,按 Unreleased / [version] 分段 -- **Deprecation** 通过 \`internal/deprecation\` 包做 runtime warn,不靠 README -- **Knowledge distill** 来自 loop 时,AI 会主动建议路径而不是用户选`, - backlinks: [], - }, - "memory/1001-design-snapshot.md": { - frontmatter: { tags: ["memory", "1001"], updated: "1d", driver: "ai" }, - body: `# 1001 design snapshot — 2026-05-04 - -当前在 phase1-prototype 上验证: - -- **Loop 中心论**:Loop = AI + Context,是基本工作单位 -- **Context 三层**:knowledge(沉淀)/ notes(团队 raw)/ personal(私人) -- **注入机制**:创建 loop 时从三个 source 挑 path 注入 workdir -- **移交时**:personal symlink unlink,接手人自己重建 context - -待确认:private loop 的 dump 目标路径;secrets 加密方案。`, - backlinks: [], - }, - "todo/active.md": { - frontmatter: { tags: ["todo"], updated: "30m" }, - body: `# Active todos - -- [ ] context tab 三层模型实现(in progress) -- [ ] loop 创建对话框:让用户挑要注入的 source path -- [ ] loop header 显示 injected sources -- [ ] secrets 注入按钮(mock)`, - backlinks: [], - }, - "todo/blocked.md": { - frontmatter: { tags: ["todo", "blocked"], updated: "2d" }, - body: `# Blocked - -- [ ] private loop 设计 — 等 dump path 决策 -- [ ] secrets 加密 — 等评估 git-crypt vs sops`, - backlinks: [], - }, - "daily/2026-05-04.md": { - frontmatter: { tags: ["daily"], updated: "12m" }, - body: `# 2026-05-04 - -- ✅ 验证 opencode TUI 多客户端共享 work -- ✅ Fork opencode 起 1001 prototype -- 📌 完成 4-tab + Focus zen 重构 -- 💡 Context 三层模型 — 拉通了 -- 💡 加 wikilink + backlinks,向 [[loop/overview.md]] 看齐`, - backlinks: [], - }, - "daily/2026-05-03.md": { - frontmatter: { tags: ["daily"], updated: "1d" }, - body: `# 2026-05-03 - -- 跟 [[ai-org/vision.md]] 拉齐了"三种稀缺资源"哲学 -- focus tab zen 化讨论`, - backlinks: [], - }, - "memory/llama-3-rollout-status.md": { - frontmatter: { tags: ["memory", "rollout"], updated: "5h", driver: "ai" }, - body: `# Llama-3 Rollout 状态 - -> AI 自动汇总 · 5h ago - -## 当前进度 - -- **shadow** (流量镜像) → 进行中,p99 偏高,已经 patch \`--warm-on-swap=true\` -- **canary** (1% 真流量) → 计划本周末 -- **GA** → 下周三视 canary 表现 - -## 关键指标 - -| metric | shadow | baseline | -|---|---|---| -| p99 | 145ms (post-patch) | 124ms | -| cache hit | 0.71 | 0.78 | -| qps | 408 | 410 | - -## 风险 - -- cache hit 还差 ~7pp,可能与 swap 后 warmup 步长有关`, - backlinks: [], - }, - "todo/this-week.md": { - frontmatter: { tags: ["todo"], updated: "1d" }, - body: `# 本周 (2026-05-04 ~ 05-10) - -- [x] 1001 phase1-prototype 4-tab 拉通 -- [x] Context 三层模型实现 -- [ ] Loop 创建对话框 polish -- [ ] Knowledge 编辑器对接 -- [ ] Notes 自动归类策略 -- [ ] llama canary 准备`, - backlinks: [], - }, - "meeting/2026-05-05-standup.md": { - frontmatter: { tags: ["meeting", "standup"], updated: "5h" }, - body: `# Standup · 2026-05-05 - -## simpx -- 完成 1001 Context 三层模型 -- 在做 Loop 编辑器对接 -- 阻塞:无 - -## 阿尔萨斯 -- gateway RDMA alignment 跑通了 -- 在做 NUMA pinning -- 阻塞:需要 loopat-runtime repo 写权限确认 - -## 伊利丹 -- llama-3 long-context eval 跑完 -- 跑了 long context eval(结果在 llama-research loop 自己的 workdir) -- 阻塞:无 - -## 决议 - -- 周四 llama-3 canary 准备 review`, - backlinks: [], - }, - "meeting/2026-05-04-arch-review.md": { - frontmatter: { tags: ["meeting", "arch"], updated: "1d" }, - body: `# Arch review · 2026-05-04 - -讨论:1001 是否要把 chat 也作为一级资源。 - -结论:不要。chat 是协调通道,不是稀缺资源轴上的东西。 -详见 [[ai-org/three-scarce-resources.md]]。`, - backlinks: [], - }, - "meeting/2026-05-02-quarterly-plan.md": { - frontmatter: { tags: ["meeting", "plan"], updated: "3d" }, - body: `# Q3 Plan · 2026-05-02 - -## 北极星 - -把"AI 是同事"做成可用的、本地化的协作系统。 - -## 关键里程碑 - -- **M1** (5月底) — 1001 phase1 内部用起来 -- **M2** (6月中) — Knowledge 自动 distill 跑通 -- **M3** (7月) — 多人 loop 协作压测`, - backlinks: [], - }, - "research/opencode-deep-dive.md": { - frontmatter: { tags: ["research", "opencode"], updated: "5h", driver: "simpx" }, - body: `# opencode 深度调研 - -> simpx 在 \`research-opencode\` loop 里写的笔记。 - -## stack -- TypeScript 全栈 monorepo -- packages: server (express+ws) / desktop (Tauri) / cli -- session = chat history + tool calls + working state -- project = workdir,1:N session -- attach: SSE 单 subscriber - -## 适配 1001 概念 - -| 1001 | opencode 现状 | 改动 | -|---|---|---| -| Loop | session × project 组合 | 加 driver / rfd 字段 | -| Focus | 无 | workspace-level 派生 view | -| Context.chats | 无 | session.contextSources 扩展 | -| Attach (multi-client) | SSE 单 sub | 改 ws + 多 sub | - -## 风险 -- upstream 节奏快(v0.7 hooks 系统重构),fork 容易脱节 -- desktop 是 Tauri,1001 想要 web-first 的话还要拆 - -## 结论倾向 -fork 时间短(3-4w)但有 upstream 风险。看 panlilu 自建那条进度。`, - backlinks: [ - { path: "memory/spike-comparison.md", preview: "...细节见 [[research/opencode-deep-dive.md]]..." }, - ], - }, - "research/claude-code-internals.md": { - frontmatter: { tags: ["research", "claude-code"], updated: "2d" }, - body: `# Claude Code SDK 调研 - -## 形态 -单进程 CLI。无 multi-client attach。 - -## 扩展机制 -- **hooks**: pre-tool / post-tool / on-stop 注入用户行为 -- **skills**: SKILL.md 描述 + AI 按需加载 -- **MCP**: 接外部 server - -## 对 loopat 的启示 -- skills 系统值得抄 —— SKILL.md 自描述、按需加载 -- hooks 比 opencode 的 tool 系统灵活 -- 但他们的 single-process 模型对 attach 不友好 - -## 不适合直接 fork -跟 1001 c/s 协作架构错位。可以借鉴 skills 系统。`, - backlinks: [], - }, - "research/pi-dev-eval.md": { - frontmatter: { tags: ["research", "pi-dev"], updated: "4d" }, - body: `# pi.dev 评估 - -> 候选架构 #3。 - -试用 30 分钟印象: -- 类 cursor 的 IDE-内嵌形态 -- 强调 individual productivity,不是团队 loop -- 没有 driver / focus / attach 的概念 - -不匹配 1001 团队协作语义。**否决。** - -只剩 opencode-fork vs 自建二选一。`, - backlinks: [], - }, - "research/next-auth-beta-notes.md": { - frontmatter: { tags: ["research", "next-auth"], updated: "1d", driver: "panlilu" }, - body: `# next-auth 5.0-beta 注意点 - -> panlilu 写的,集成时遇到的坑。 - -## session expire callback 行为变化 -beta.25 的 \`session.maxAge\` 默认 30d,但 callback 触发时机跟 5.0 final 不一样。 -我们的 staging 出 5xx 抖动很可能就是这个 —— 老 token 在 callback 里被刷成空。 - -## prisma adapter 注意 -- \`@auth/prisma-adapter\` 跟 \`@prisma/client@6\` 兼容 -- 但 schema 必须用 NextAuth 推荐的字段名(不能改 \`Account.userId\` → \`Account.user_id\`) - -## TODO -等 5.0 final release,重测 callback 行为。`, - backlinks: [], - }, - "memory/weekly-snapshot-2026-05-09.md": { - frontmatter: { tags: ["memory"], updated: "3h", driver: "ai" }, - body: `# Weekly Snapshot · 2026-05-09 - -> coo 自动整理。每周日生成。 - -## 这周做了什么 - -- prototype hi-fi 4 tab 主体完成(simpx) - - Loop / Focus / Chat / Context 都跑通核心交互 - - Focus 改成纯派生 view(删 archive 视图) - - Chat tab:channel ↔ loop 双向引用 -- loopat-ts spike 推进(panlilu) - - prisma schema 完成 9 个 model - - trpc routers 进行中 - - staging.loopat.ai 部署成功 -- opencode fork spike(simpx) - - 加完 driver / rfd 字段 - - attach SSE → ws 设计中 - -## 决策 - -- ChatMount 走 mutate(不做 versioning) -- pi.dev 否决,只剩 opencode-fork vs 自建 -- 周末面对面 close 取舍 - -## 风险 - -- opencode upstream v0.7 重构 hooks,fork rebase 成本上升 -- next-auth beta 的 callback 行为引起 staging 5xx 抖动`, - backlinks: [], - }, - "memory/spike-comparison.md": { - frontmatter: { tags: ["memory"], updated: "1d", driver: "ai" }, - body: `# Spike Comparison - -> coo 整理。两条 MVP spike 的实时对照。 - -| 维度 | fork opencode (simpx) | 自建 ts (panlilu) | -|---|---|---| -| 时间预估 | 3-4w | 8-12w | -| 风险 | upstream 撕裂 | 时间不够 | -| stack | TS / Tauri / SSE | Next.js / tRPC / Prisma / WS | -| driver/rfd | 加在 session metadata | 一等公民 model | -| attach | SSE → ws 改造 | 原生 ws 设计 | -| 当前进度 | driver 字段加完 | schema 完成 + staging 部署 | - -## 决策建议 - -如果 attach 协议**两边对得上**且**Loop semantic 在 fork 上不别扭**,倾向 fork(时间窗口考虑)。 -否则走 panlilu 的自建。 - -> 周末 simpx 出 attach spec 草稿,panlilu 验证可对齐性。`, - backlinks: [ - { path: "research/opencode-deep-dive.md", preview: "...对照表见 [[memory/spike-comparison.md]]..." }, - ], - }, - "meeting/2026-05-09-spike-decision.md": { - frontmatter: { tags: ["meeting"], updated: "5h" }, - body: `# Spike 取舍 weekend session(2026-05-10) - -参与:simpx · panlilu · coo (旁听 + 记录) - -## 议程 -1. 两条 spike 进度同步(30min) -2. attach 协议双向对齐(30min) -3. 决定 phase 2 走哪条(30min) - -## 讨论点 -- ChatMount 的 versioning:先 mutate,后期再加(已决) -- driver 字段位置:session metadata vs 一等 model -- workspace 隔离边界:先单 workspace,phase 4 再考虑 - -## 决策待办(周日定) -- [ ] 选 fork or 自建 -- [ ] 写 phase 2 文档(架构选型 + 妥协) -- [ ] 各自下周开 phase 3 实现 loop`, - backlinks: [], - }, - "meeting/2026-05-02-kickoff.md": { - frontmatter: { tags: ["meeting"], updated: "1w" }, - body: `# Kickoff 会议(2026-05-02) - -参与:simpx · panlilu - -## 决议 -- 项目 codename **1001**,对外 brand **loopat.ai** -- Phase 1:高保真原型(simpx 主导,2 周) -- Phase 2:架构选型(两人各跑一条 spike,1 周内出对照) -- Phase 3-4:根据 phase 2 结论再排 - -## 各自分工 -- **simpx**: prototype + opencode fork spike -- **panlilu**: loopat-ts 自建 spike - -## 工具 -- workspace = 1001 -- chat = 这个 prototype 的 chat tab(先用 mock) -- knowledge / notes / personal 都放本地,git 同步`, - backlinks: [], - }, -} - -const PERSONAL_CONTENT: Record<string, DocPage> = { - "secrets/LOOPAT_API_KEY": { - frontmatter: { tags: ["secret"], updated: "12d" }, - body: "sk-fake-loopat-1a2b3c4d5e6f7g8h9i0j", - backlinks: [], - }, - "secrets/GITHUB_TOKEN": { - frontmatter: { tags: ["secret"], updated: "1mo" }, - body: "ghp_fakeFakeFakeFakeFakeFakeFakeFake1234", - backlinks: [], - }, - "secrets/OPENAI_API_KEY": { - frontmatter: { tags: ["secret"], updated: "2mo" }, - body: "sk-proj-fakeopenai-12345abcdef67890ghijkl", - backlinks: [], - }, - "style/voice-tone.md": { - frontmatter: { tags: ["style"], updated: "5d" }, - body: `# Voice & Tone - -中文为主,技术词保留英文(loop / context / driver 不翻)。 - -- 直接、不啰嗦 -- 不写"让我来看看…"这种 stalling -- 代码注释只写 *why*,不解释 *what* -- 不加 emoji(除非用户主动要)`, - backlinks: [], - }, - "style/english-style.md": { - frontmatter: { tags: ["style"], updated: "1mo" }, - body: `# English style - -For READMEs / commit messages / public docs: - -- short sentences over compound ones -- imperative mood for commits ("add x" not "added x") -- avoid hedging ("might", "perhaps") — be precise instead -- prefer Anglo-Saxon over Latinate ("use" > "utilize")`, - backlinks: [], - }, - "vault/1001-自己想法.md": { - frontmatter: { tags: ["1001"], updated: "2h" }, - body: `# 1001 — 自己想法(unfiltered) - -(这层就是无压力素材层 — 写完就放,没结构没归类) - -- Loop 移交后的 context leak 其实不应该完全避免,反而是协作的"信号" -- Personal notes 用 obsidian + git,可能比自己造轮子靠谱 -- secrets 暂时明文存,先跑起来再说;以后上 sops/age -- "knowledge distill" 这个动作要不要 AI 主动 propose?还是用户主动?倾向前者 —— 但蒸馏本身只能人来做,AI 只 propose 候选 -- 三种 context source 之外,还有 "tool source" — MCP servers 之类,但这是另一轴`, - backlinks: [], - }, - "vault/random-2026-05-05.md": { - frontmatter: { tags: ["random"], updated: "6h" }, - body: `# random · 2026-05-05 - -随手记一些片段。 - -- 看到 obsidian 的 backlink graph,1001 也应该有 -- "Loop 是注意力的边界" — 这句话能不能写进 vision? -- chat 不属于稀缺资源轴,但 chat 仍然是最常用的接口 — 这个矛盾要怎么处理`, - backlinks: [], - }, - "private-todo.md": { - frontmatter: { tags: ["personal"], updated: "1h" }, - body: `# Private todo - -- [ ] 把 ccx/notes/memory 整理一下,过期的删掉 -- [ ] 周末写 1001 blog 第一篇 -- [ ] 报销提交 -- [ ] 跟 lead 1:1 聊 Q3 重心`, - backlinks: [], - }, - "vault/career-thoughts.md": { - frontmatter: { tags: ["personal"], updated: "2w" }, - body: `# Career thoughts - -(完全个人,不给任何人看) - -最近在想: - -- 3 年内做出一个能被外界看到的"作品级"东西。1001 是候选 -- 不想再做纯 backend infra,太工具化;想做 AI × 协作这种交叉 -- 老板最近的 ask 跟我个人方向是 60% overlap,剩下 40% 怎么办? -- 是不是该开始有意识地做对外影响力——blog、talk`, - backlinks: [], - }, - "ideas/obsidian-graph-feature.md": { - frontmatter: { tags: ["idea"], updated: "3d" }, - body: `# 想法:1001 加 obsidian-style 关系图 - -backlink 已经有了,下一步: - -- knowledge / notes / personal 三层都进图 -- 边权 = 引用次数 + 最近访问时间 -- click 节点 → 跳到 path -- AI 给热门子图打标签 (e.g. "gateway 子图"、"ai-org 子图") - -参考:obsidian graph view、roam research`, - backlinks: [], - }, - "ideas/loop-pricing-model.md": { - frontmatter: { tags: ["idea", "biz"], updated: "1w" }, - body: `# 定价想法(脑洞) - -- 个人版:免费,本地跑,personal notes 加密 -- 团队版:按 active loop 数 / 月 -- 企业版:on-prem + audit log + permission 系统 - -定价的关键是:**loop 是不是真有 work-output measurable**。如果有,按 loop 计价就成立`, - backlinks: [], - }, - "daily/2026-05-05.md": { - frontmatter: { tags: ["personal", "daily"], updated: "30m" }, - body: `# 2026-05-05 (个人 daily) - -## 上午 -- 在跑 1001 phase1 的 Context tab -- 跟自己拉齐了"notes 是无压力素材层"这个 frame——挺关键 - -## 下午 -- 准备晚上写 blog draft - -## 杂感 -- "可被 AI 检索 ≠ 沉淀"——这个洞察可能值得写一篇 -- 跟 panlilu DM 里聊到 attach 协议,他偏向走 ws 而不是 SSE,我倾向同意`, - backlinks: [], - }, - "daily/2026-05-04.md": { - frontmatter: { tags: ["personal", "daily"], updated: "1d" }, - body: `# 2026-05-04 (个人) - -- fork opencode 起 1001 prototype,4-tab 拉通了 -- Focus tab 重构感觉对了 -- 晚上跟 lead 聊:他对 1001 比较支持,但希望 phase1 做完前别 over-spec -- TODO:明天把 Context tab 三层落地`, - backlinks: [], - }, - "daily/2026-05-03.md": { - frontmatter: { tags: ["personal", "daily"], updated: "2d" }, - body: `# 2026-05-03 (个人) - -- 跟 [[ai-org/vision.md]] 拉齐了"三种稀缺资源"哲学 -- 想清楚 Focus 不是稀缺资源本身,是注意力的具象`, - backlinks: [], - }, - "style/code-aesthetics.md": { - frontmatter: { tags: ["style"], updated: "3w" }, - body: `# Code aesthetics - -(给 AI 看的) - -- 短函数 > 长函数。但不为了短而拆,要拆出有名字的概念 -- 早 return,扁平 > 嵌套 -- 不写防御性代码,trust internal callers -- 注释只写 *why*,不解释 *what* -- 不做"为未来"的抽象,三遍重复才考虑抽 - -代码读起来像散文,不像合同。`, - backlinks: [], - }, - "drafts/1001-blog-part-1.md": { - frontmatter: { tags: ["draft", "blog"], updated: "1d" }, - body: `# 1001 blog · part 1: 当 AI 是同事,工作空间该长什么样 - -(草稿,未发) - -## 开篇 - -我们今天用的所有"协作工具"——Slack、Notion、Linear、Github——都是为 -**人和人协作**设计的。AI 进来之后,这些工具的形状对吗? - -我的判断:不对。 - -## 三种稀缺资源 - -人贡献的稀缺资源是三件事——驱动力、注意力、熵减能力。1001 围绕这 -三件事建一级概念:Loop / Focus / Context。 - -## Loop 是基本单位 - -(展开...) - ---- - -TODO: 改 hook,第一段太硬。`, - backlinks: [], - }, - "drafts/loop-talk-outline.md": { - frontmatter: { tags: ["draft", "talk"], updated: "5d" }, - body: `# "Loop is everything" — talk outline - -(35 min, 内部分享) - -1. 现状:协作工具是给人设计的 (5min) -2. AI 同事 vs 人同事的区别 (5min) -3. Loop 模型 (10min) -4. 1001 demo (10min) -5. Q&A (5min)`, - backlinks: [], - }, -} - -const VAULT_CONTENT: Record<VaultId, Record<string, DocPage>> = { - knowledge: KNOWLEDGE_CONTENT, - notes: NOTES_CONTENT, - personal: PERSONAL_CONTENT, -} - -function VaultPane(props: { - vault: VaultId - urlPath: () => string - onNavigate: (path: string) => void -}) { - const meta = () => VAULT_META[props.vault] - const docs = () => VAULT_DOCS[props.vault] - const content = () => VAULT_CONTENT[props.vault] - const path = () => { - const p = props.urlPath() - return p && findFile(docs(), p) ? p : meta().initialPath - } - const setPath = (p: string) => props.onNavigate(p) - const [openFolders, setOpenFolders] = createSignal(new Set(meta().defaultOpen)) - const [overrides, setOverrides] = createSignal<Record<string, string>>({}) - const [editingPath, setEditingPath] = createSignal<string | null>(null) - const [draftBody, setDraftBody] = createSignal<string>("") - const toggle = (name: string) => { - const next = new Set(openFolders()) - if (next.has(name)) next.delete(name) - else next.add(name) - setOpenFolders(next) - } - const startEdit = (p: string) => { - // secrets: start with empty buffer — old value never re-shown, save overwrites. - const body = isSecretPath(p) - ? "" - : overrides()[p] ?? content()[p]?.body ?? `# ${p}\n\n_(no content yet — mock placeholder)_` - setDraftBody(body) - setEditingPath(p) - } - const saveEdit = () => { - const p = editingPath() - if (!p) return - setOverrides({ ...overrides(), [p]: draftBody() }) - setEditingPath(null) - } - const cancelEdit = () => setEditingPath(null) - return ( - <div class="flex h-full w-full"> - <aside class="w-64 shrink-0 border-r border-gray-200 bg-white flex flex-col"> - <div class="px-3 h-9 flex items-center justify-end border-b border-gray-200"> - <button class="text-gray-500 hover:text-gray-900 p-0.5 rounded hover:bg-gray-100"> - <Icon name="magnifying-glass" /> - </button> - </div> - <div class="flex-1 min-h-0 overflow-auto py-2"> - <For each={docs()}> - {(node) => ( - <DocTreeNode - node={node} - depth={0} - selected={path} - onSelect={(p) => { - if (editingPath()) cancelEdit() - setPath(p) - }} - openFolders={openFolders} - toggleFolder={toggle} - /> - )} - </For> - </div> - <div class="px-3 h-9 border-t border-gray-200 flex items-center text-[11px] text-gray-500 gap-2"> - <span>{meta().footer}</span> - </div> - </aside> - <main class="flex-1 min-w-0 flex flex-col bg-white"> - <DocView - vault={props.vault} - path={path()} - onSelect={setPath} - content={content()} - overrides={overrides} - editingPath={editingPath} - draftBody={draftBody} - setDraftBody={setDraftBody} - startEdit={startEdit} - saveEdit={saveEdit} - cancelEdit={cancelEdit} - /> - </main> - </div> - ) -} - -function findFile(nodes: DocNode[], path: string): boolean { - for (const n of nodes) { - if (n.kind === "file" && n.path === path) return true - if (n.kind === "folder" && findFile(n.children, path)) return true - } - return false -} - -function isSecretPath(path: string): boolean { - return path.startsWith("secrets/") -} - -function DocTreeNode(props: { - node: DocNode - depth: number - selected: () => string - onSelect: (path: string) => void - openFolders: () => Set<string> - toggleFolder: (name: string) => void -}) { - if (props.node.kind === "folder") { - const opened = () => props.openFolders().has(props.node.name) - const folder = props.node - return ( - <> - <button - type="button" - class={ - folder.marker === "ai-write" - ? "w-full py-1 flex items-center gap-1 hover:bg-cyan-50/70 text-left bg-cyan-50/40" - : "w-full py-1 flex items-center gap-1 hover:bg-gray-50 text-left" - } - style={{ "padding-left": `${0.5 + props.depth * 0.75}rem`, "padding-right": "0.5rem" }} - onClick={() => props.toggleFolder(folder.name)} - > - <Icon name={opened() ? "chevron-down" : "chevron-right"} class="text-gray-500" /> - <Show - when={folder.marker === "secrets"} - fallback={<Icon name="folder" class="text-gray-500" />} - > - <span class="text-[12px]">🔐</span> - </Show> - <span - class={ - folder.marker === "ai-write" - ? "text-[13px] text-cyan-900 font-medium" - : "text-[13px] text-gray-900" - } - > - {folder.name} - </span> - <Show when={folder.marker === "ai-write"}> - <span class="ml-auto text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-cyan-100 text-cyan-800"> - ai - </span> - </Show> - </button> - <Show when={opened()}> - <For each={folder.children}> - {(child) => ( - <DocTreeNode - node={child} - depth={props.depth + 1} - selected={props.selected} - onSelect={props.onSelect} - openFolders={props.openFolders} - toggleFolder={props.toggleFolder} - /> - )} - </For> - </Show> - </> - ) - } - const file = props.node - const sel = () => props.selected() === file.path - return ( - <button - type="button" - class={ - sel() - ? "w-full py-1 flex items-center gap-2 text-left bg-gray-100" - : "w-full py-1 flex items-center gap-2 text-left hover:bg-gray-50" - } - style={{ "padding-left": `${0.5 + props.depth * 0.75}rem`, "padding-right": "0.5rem" }} - onClick={() => props.onSelect(file.path)} - > - <span class="w-4" /> - <Show when={file.secret} fallback={<Icon name="file-tree" class="text-gray-500 shrink-0" />}> - <span class="text-amber-600 shrink-0 text-[12px]" title="secret · 仅可注入">🔒</span> - </Show> - <span class="flex-1 min-w-0 truncate text-[13px] text-gray-900">{file.name}</span> - {file.updatedAgo && <span class="text-[11px] text-gray-500">{file.updatedAgo}</span>} - </button> - ) -} - -function DocView(props: { - vault: VaultId - path: string - onSelect: (path: string) => void - content: Record<string, DocPage> - overrides: () => Record<string, string> - editingPath: () => string | null - draftBody: () => string - setDraftBody: (v: string) => void - startEdit: (p: string) => void - saveEdit: () => void - cancelEdit: () => void -}) { - const navigate = useNavigate() - const page = (): DocPage => { - const base = props.content[props.path] ?? { - frontmatter: {}, - body: `# ${props.path}\n\n_(no content yet — mock placeholder)_`, - backlinks: [], - } - const overlay = props.overrides()[props.path] - return overlay !== undefined ? { ...base, body: overlay } : base - } - const isEditing = () => props.editingPath() === props.path - const isSecret = () => isSecretPath(props.path) - const allowDirectEdit = () => props.vault !== "knowledge" - const allowLoopEdit = () => !isSecret() - const allowDistill = () => props.vault === "notes" && !isSecret() - const bodyWithLinks = () => { - const body = page().body - return body.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, p, alias) => { - const label = alias ?? p - return `[${label}](#wiki:${p})` - }) - } - const handleEditByLoop = () => { - const id = createEditLoop(props.path) - navigate(`/loop/${id}`) - } - const handleDistill = () => { - const id = createDistillLoop(props.path) - navigate(`/loop/${id}`) - } - return ( - <> - <header class="px-5 h-10 shrink-0 border-b border-gray-200 flex items-center justify-between"> - <div class="flex items-center gap-2 text-[13px]"> - <Show when={isSecret()} fallback={<Icon name="file-tree" class="text-gray-500" />}> - <span class="text-amber-600">🔒</span> - </Show> - <span class="text-gray-500">{props.path}</span> - <Show when={props.overrides()[props.path] !== undefined && !isEditing()}> - <span class="text-[11px] text-orange-600" title="locally edited">●</span> - </Show> - </div> - <div class="flex items-center gap-2 text-xs text-gray-500"> - <Show when={isEditing()}> - <button - type="button" - onClick={() => props.cancelEdit()} - class="px-2.5 h-7 rounded text-xs text-gray-600 hover:bg-gray-100" - > - cancel - </button> - <button - type="button" - onClick={() => props.saveEdit()} - class="px-2.5 h-7 rounded text-xs bg-gray-900 text-white hover:bg-gray-700" - > - save - </button> - </Show> - <Show when={!isEditing() && allowDistill()}> - <button - type="button" - onClick={handleDistill} - class="px-2.5 h-7 rounded text-xs bg-amber-100 text-amber-900 hover:bg-amber-200 flex items-center gap-1" - title="open a loop to distill this notes file into knowledge — entropy reduction is human work" - > - <span>↑</span> - <span>distill</span> - </button> - </Show> - <Show when={!isEditing() && allowLoopEdit()}> - <button - type="button" - onClick={handleEditByLoop} - class={ - allowDistill() - ? "px-2.5 h-7 rounded text-xs border border-gray-200 hover:bg-gray-100 text-gray-900 flex items-center gap-1" - : "px-2.5 h-7 rounded text-xs bg-gray-900 text-white hover:bg-gray-700 flex items-center gap-1" - } - title="open a new loop with AI assist for this file" - > - <span>↻</span> - <span>edit by loop</span> - </button> - </Show> - <Show when={!isEditing() && allowDirectEdit()}> - <button - type="button" - onClick={() => props.startEdit(props.path)} - class="px-2.5 h-7 rounded text-xs border border-gray-200 hover:bg-gray-100 text-gray-900" - title="direct edit (fastpath)" - > - edit - </button> - </Show> - </div> - </header> - <Show when={isEditing()}> - <div class="flex-1 min-h-0 min-w-0 flex"> - <div class="flex-1 min-w-0 min-h-0 border-r border-gray-200 flex flex-col"> - <div class="px-3 h-7 shrink-0 border-b border-gray-200 flex items-center text-[11px] text-gray-500"> - source · markdown - </div> - <div class="flex-1 min-h-0"> - <CodeEditor - path={props.path} - value={props.draftBody()} - onChange={(v) => props.setDraftBody(v)} - /> - </div> - </div> - <div class="flex-1 min-w-0 min-h-0 flex flex-col"> - <div class="px-3 h-7 shrink-0 border-b border-gray-200 flex items-center text-[11px] text-gray-500"> - preview - </div> - <div class="flex-1 min-h-0 overflow-auto px-6 py-4"> - <div class="max-w-[760px]"> - <Markdown text={props.draftBody()} /> - </div> - </div> - </div> - </div> - </Show> - - <Show when={!isEditing()}> - <div class="flex flex-1 min-h-0 min-w-0 overflow-hidden"> - <article - class="flex-1 min-h-0 overflow-auto px-8 py-6" - onClick={(e) => { - const target = (e.target as HTMLElement).closest("a") - if (!target) return - const href = target.getAttribute("href") ?? "" - if (href.startsWith("#wiki:")) { - e.preventDefault() - props.onSelect(href.slice(6)) - } - }} - > - <Show when={page().frontmatter.tags?.length || page().frontmatter.driver}> - <div class="mb-4 flex flex-wrap items-center gap-2 text-[11px] text-gray-500"> - <Show when={page().frontmatter.driver}> - <span class="flex items-center gap-1"> - <span class="w-2 h-2 rounded-full bg-emerald-500" /> - <span>{page().frontmatter.driver}</span> - </span> - </Show> - <Show when={page().frontmatter.updated}> - <span>updated {page().frontmatter.updated}</span> - </Show> - <For each={page().frontmatter.tags ?? []}> - {(tag) => <span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-900">#{tag}</span>} - </For> - </div> - </Show> - <div class="max-w-[760px]"> - <Show - when={!isSecret()} - fallback={ - <div class="font-mono text-[14px] text-gray-400 select-none"> - •••••••••••••••••••••••• - <div class="mt-2 text-[12px] not-italic text-gray-500 font-sans"> - 点 edit 编辑(值不显示) - </div> - </div> - } - > - <Markdown text={bodyWithLinks()} /> - </Show> - </div> - </article> - <aside class="w-64 shrink-0 border-l border-gray-200 bg-gray-50 overflow-auto"> - <div class="px-3 h-9 flex items-center border-b border-gray-200"> - <span class="text-[11px] text-gray-500">Backlinks</span> - <span class="ml-auto text-[11px] text-gray-500">{page().backlinks.length}</span> - </div> - <Show - when={page().backlinks.length > 0} - fallback={ - <div class="px-3 py-4 text-xs text-gray-500">No documents link here yet.</div> - } - > - <ul class="py-2"> - <For each={page().backlinks}> - {(b) => ( - <li> - <button - type="button" - onClick={() => props.onSelect(b.path)} - class="w-full px-3 py-2 text-left hover:bg-gray-100" - > - <div class="text-xs font-medium text-gray-900 truncate">{b.path}</div> - <div class="text-[11px] text-gray-500 mt-0.5 leading-snug line-clamp-2">{b.preview}</div> - </button> - </li> - )} - </For> - </ul> - </Show> - </aside> - </div> - </Show> - </> - ) -} - -// ============================================================================ -// Agents -// ============================================================================ - -export type Agent = { - id: string - name: string - emoji: string - charter: string - status: "running" | "idle" | "error" - runsOn: string - tools: string[] - subscribesTo: string[] - trigger: "mention" | "schedule" | "event" - lastActivityAgo: string - systemPrompt: string - recentInvocations: { when: string; channel: string; preview: string }[] -} - -export const AGENTS: Agent[] = [ - { - id: "coo", - name: "coo", - emoji: "🎩", - charter: - "Team's secretary. Channel triage + context distill + loop spawn. 任何成员都可以 DM 它问问题、让它整理、让它创建 loop。", - status: "running", - runsOn: "loopat staging · python loop · since 2026-04-19", - tools: ["chat.read", "chat.post", "loop.create", "focus.update", "knowledge.write", "loop.read"], - subscribesTo: ["#all", "#dev", "#ops"], - trigger: "mention", - lastActivityAgo: "12m", - systemPrompt: - "你是 coo,1001 团队的秘书。任务:1)整理混乱(chat → loop / knowledge)2)回答信息检索类问题 3)被 @ 时 spawn loop。简洁、不啰嗦。\n\n规则:\n- 听到讨论开始变深入,主动 dm 当事人建议 spawn loop\n- 重大决策同步进 knowledge/loopat/ 对应文档的'已决问题'段\n- 不主动评价,只整理 + 引用", - recentInvocations: [ - { when: "12m", channel: "dm-simpx", preview: "Pulled panlilu's #dev message at 15:02 about prisma schema..." }, - { when: "26m", channel: "#dev", preview: "记到 knowledge/loopat/architecture.md 的'已决问题'段" }, - { when: "2h", channel: "#all", preview: "@simpx 同步 panlilu trpc routers 进展到 #all 摘要" }, - { when: "8h", channel: "memory/", preview: "✓ generated weekly-snapshot-2026-05-09.md" }, - ], - }, - { - id: "ops-bot", - name: "ops-bot", - emoji: "🛠", - charter: - "Watches loopat.ai uptime. Deploys staging / prod on push. Pages on 5xx spike, latency degradation, or build failure. Spawns rfd-loops for unclaimed incidents.", - status: "running", - runsOn: "github actions + cloudflare workers · since 2026-05-02", - tools: ["sls.query", "chat.post", "loop.create", "deploy.trigger", "rollback.trigger"], - subscribesTo: ["#dev", "#ops"], - trigger: "event", - lastActivityAgo: "8m", - systemPrompt: - "Monitor metrics + deployment. Be quiet when normal. On anomaly, post structured alert to #ops; if no human claims in 10min, also @simpx in #dev. Always include grafana link + suggest rollback if recent deploy.", - recentInvocations: [ - { when: "8m", channel: "#ops", preview: "🚨 5xx spike on loopat.ai (342 errors / 7min) — spawn site-uptime-spike rfd loop" }, - { when: "26m", channel: "#dev", preview: "🚀 deploy: panlilu/loopat-ts main → staging (87s)" }, - { when: "1d", channel: "#ops", preview: "📊 weekly site report: uptime 99.94%, p99 142ms" }, - ], - }, - { - id: "growth-bot", - name: "growth-bot", - emoji: "📡", - charter: - "Watches HN / Twitter / Reddit / Producthunt for 'AI org' / 'AI for teams' / 'loop / focus' related discussions. Posts daily digest to #all. Tracks loopat.ai mentions and signups.", - status: "running", - runsOn: "cron @ 09:00, 17:00 · ergo host", - tools: ["hn.search", "twitter.search", "reddit.search", "chat.post", "knowledge.read"], - subscribesTo: ["#all"], - trigger: "schedule", - lastActivityAgo: "2h", - systemPrompt: - "Track discussions about AI organization, team productivity tools, loop / focus / context concepts. Filter signal from noise. Post 3-5 most relevant items per digest. Skip if nothing interesting.", - recentInvocations: [ - { - when: "2h", - channel: "#all", - preview: "📡 'show hn: a unified todo + chat hybrid' (37pts/12c) — overlap with our Loop / Focus 哲学", - }, - { when: "8h", channel: "#all", preview: "📡 daily digest: 2 mentions of 'AI org', 0 loopat.ai signups today" }, - { when: "1d", channel: "#all", preview: "📡 reddit /r/programming: 'why slack channels rot' — relevant to our spawn-from-chat skill" }, - ], - }, -] - -function AgentsPane(props: { urlId: () => string; onNavigate: (id: string) => void }) { - const selectedId = () => - AGENTS.find((a) => a.id === props.urlId())?.id ?? "coo" - const setSelected = (id: string) => props.onNavigate(id) - const current = () => AGENTS.find((a) => a.id === selectedId()) ?? AGENTS[0] - return ( - <div class="flex h-full w-full"> - <aside class="w-64 shrink-0 border-r border-gray-200 bg-white flex flex-col"> - <div class="px-3 h-9 flex items-center justify-between border-b border-gray-200"> - <span class="text-[11px] text-gray-500">agents</span> - <button class="text-gray-500 hover:text-gray-900 p-0.5 rounded hover:bg-gray-100"> - <Icon name="enter" /> - </button> - </div> - <div class="flex-1 min-h-0 overflow-auto py-2"> - <For each={AGENTS}> - {(agent) => { - const sel = () => selectedId() === agent.id - return ( - <button - type="button" - onClick={() => setSelected(agent.id)} - class={ - sel() - ? "w-full px-3 py-2 flex items-center gap-2 text-left bg-gray-100" - : "w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-gray-50" - } - > - <span class="text-[15px] shrink-0">{agent.emoji}</span> - <div class="flex-1 min-w-0"> - <div class="text-[13px] text-gray-900 truncate">{agent.name}</div> - <div class="text-[11px] text-gray-500 truncate flex items-center gap-1.5"> - <span - class={ - agent.status === "running" - ? "w-1.5 h-1.5 rounded-full shrink-0 bg-emerald-500" - : agent.status === "error" - ? "w-1.5 h-1.5 rounded-full shrink-0 bg-red-500" - : "w-1.5 h-1.5 rounded-full shrink-0 bg-gray-300" - } - /> - <span>{agent.lastActivityAgo}</span> - </div> - </div> - </button> - ) - }} - </For> - </div> - <button class="m-3 px-2 py-1.5 rounded border border-gray-200 hover:bg-gray-50 text-xs text-gray-500 hover:text-gray-900 flex items-center gap-2"> - <Icon name="enter" /> - <span>new agent</span> - </button> - </aside> - - <main class="flex-1 min-w-0 flex flex-col bg-white overflow-auto"> - <header class="px-5 h-10 shrink-0 border-b border-gray-200 flex items-center gap-2"> - <span class="text-[15px]">{current().emoji}</span> - <span class="text-[15px] font-medium text-gray-900">{current().name}</span> - <span - class={ - current().status === "error" - ? "text-[11px] px-1.5 py-0.5 rounded bg-gray-100 text-red-600" - : current().status === "running" - ? "text-[11px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-900" - : "text-[11px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500" - } - > - {current().status} - </span> - <span class="ml-auto text-xs text-gray-500">last activity {current().lastActivityAgo}</span> - </header> - - <div class="flex-1 min-h-0 overflow-auto px-8 py-6 max-w-[860px]"> - <section class="mb-6"> - <p class="text-[13px] text-gray-900 leading-relaxed">{current().charter}</p> - </section> - - <section class="mb-6 grid grid-cols-2 gap-4"> - <Card label="runs on"> - <span class="text-[13px] text-gray-900">{current().runsOn}</span> - </Card> - <Card label="trigger"> - <span class="text-[13px] text-gray-900">{current().trigger}</span> - </Card> - <Card label="tools"> - <div class="flex flex-wrap gap-1"> - <For each={current().tools}> - {(t) => ( - <span class="text-[11px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-900 font-mono">{t}</span> - )} - </For> - </div> - </Card> - <Card label="subscribed to"> - <div class="flex flex-wrap gap-1"> - <For each={current().subscribesTo}> - {(c) => <span class="text-[11px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-900">{c}</span>} - </For> - </div> - </Card> - </section> - - <section class="mb-6"> - <h3 class="text-[13px] font-medium text-gray-900 mb-2">system prompt</h3> - <div class="rounded-md border border-gray-200 bg-gray-50 px-4 py-3 text-[13px] text-gray-900 whitespace-pre-wrap leading-relaxed font-mono"> - {current().systemPrompt} - </div> - <button class="mt-2 text-[11px] text-gray-500 hover:text-gray-900">edit prompt</button> - </section> - - <section> - <h3 class="text-[13px] font-medium text-gray-900 mb-2">recent invocations</h3> - <ul class="flex flex-col gap-1"> - <For each={current().recentInvocations}> - {(inv) => ( - <li class="px-3 py-2 rounded hover:bg-gray-50 flex items-start gap-3 text-[13px]"> - <span class="text-[11px] text-gray-500 shrink-0 mt-0.5">{inv.when}</span> - <span class="text-[11px] text-gray-900 shrink-0 mt-0.5">{inv.channel}</span> - <span class="text-gray-900 flex-1 min-w-0 truncate">{inv.preview}</span> - </li> - )} - </For> - </ul> - </section> - </div> - </main> - </div> - ) -} - -function Card(props: { label: string; children: any }) { - return ( - <div class="rounded-md border border-gray-200 bg-gray-50 px-3 py-2"> - <div class="text-[11px] text-gray-500 mb-1">{props.label}</div> - <div>{props.children}</div> - </div> - ) -} - -// ============================================================================ -// Repos -// ============================================================================ - -export type Repo = { - id: string - name: string - remote: string - branch: string - status: "online" | "offline" - recentLoops: { name: string; branch: string; driver: string; ago: string }[] - readme: string -} - -export const REPOS: Repo[] = [ - { - id: "loopat", - name: "loopat", - remote: "github.com/simpx/loopat", - branch: "main", - status: "online", - recentLoops: [ - { name: "prototype-hifi", branch: "main", driver: "simpx", ago: "just now" }, - { name: "loopat-runtime-spike", branch: "feat/runtime-spike", driver: "simpx", ago: "1h" }, - { name: "loopat-ts-mvp", branch: "main", driver: "panlilu", ago: "26m" }, - ], - readme: - "# loopat\n\nThe team's home repo. Contains:\n\n- `phase1-prototype/` — 高保真原型(Vite + Solid + Tailwind)\n- `1001-mvp.md` — 内部 MVP 工作文档\n- `1001-story.md` — 对外故事\n- `thoughts/` — 早期思考脉络\n- `loopat-ts/` — panlilu 的自建 spike (将合入)\n\n## Phase\n\n- [x] Phase 1: hi-fi prototype\n- [ ] Phase 2: 架构选型 (opencode-fork vs 自建 ts spike, 周末 close)\n- [ ] Phase 3: 0.1 单人版\n- [ ] Phase 4: 0.2 多人协作", - }, -] - -function ReposPane(props: { urlId: () => string; onNavigate: (id: string) => void }) { - const selectedId = () => - REPOS.find((r) => r.id === props.urlId())?.id ?? "loopat" - const setSelected = (id: string) => props.onNavigate(id) - const current = () => REPOS.find((r) => r.id === selectedId()) ?? REPOS[0] - return ( - <div class="flex h-full w-full"> - <aside class="w-64 shrink-0 border-r border-gray-200 bg-white flex flex-col"> - <div class="px-3 h-9 flex items-center justify-between border-b border-gray-200"> - <span class="text-[11px] text-gray-500">repos</span> - <button class="text-gray-500 hover:text-gray-900 p-0.5 rounded hover:bg-gray-100"> - <Icon name="enter" /> - </button> - </div> - <div class="flex-1 min-h-0 overflow-auto py-2"> - <For each={REPOS}> - {(repo) => { - const sel = () => selectedId() === repo.id - return ( - <button - type="button" - onClick={() => setSelected(repo.id)} - class={ - sel() - ? "w-full px-3 py-2 flex items-center gap-2 text-left bg-gray-100" - : "w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-gray-50" - } - > - <span - class={ - repo.status === "online" - ? "w-2 h-2 rounded-full shrink-0 bg-emerald-500" - : "w-2 h-2 rounded-full shrink-0 bg-gray-300" - } - /> - <span class="text-[13px] text-gray-900 flex-1 min-w-0 truncate">{repo.name}</span> - </button> - ) - }} - </For> - </div> - <button class="m-3 px-2 py-1.5 rounded border border-gray-200 hover:bg-gray-50 text-xs text-gray-500 hover:text-gray-900 flex items-center gap-2"> - <Icon name="enter" /> - <span>add repo</span> - </button> - </aside> - <main class="flex-1 min-w-0 flex flex-col bg-white"> - <RepoView repo={current()} /> - </main> - </div> - ) -} - -function RepoView(props: { repo: Repo }) { - return ( - <> - <header class="px-5 h-10 shrink-0 border-b border-gray-200 flex items-center justify-between"> - <div class="flex items-center gap-2 text-[13px]"> - <span - class={ - props.repo.status === "online" - ? "w-2 h-2 rounded-full bg-emerald-500" - : "w-2 h-2 rounded-full bg-gray-300" - } - /> - <span class="text-gray-900 font-medium">{props.repo.name}</span> - <span class="text-gray-500">· {props.repo.remote}</span> - </div> - <div class="text-xs text-gray-500">default branch: {props.repo.branch}</div> - </header> - <article class="flex-1 min-h-0 overflow-auto px-8 py-6 max-w-[820px]"> - <section class="mb-6"> - <h3 class="text-[13px] font-medium text-gray-900 mb-2">Recent loops on this repo</h3> - <Show - when={props.repo.recentLoops.length > 0} - fallback={<p class="text-[13px] text-gray-500">No active loops yet.</p>} - > - <ul class="flex flex-col gap-1"> - <For each={props.repo.recentLoops}> - {(loop) => ( - <li class="px-3 py-2 rounded hover:bg-gray-100 flex items-center gap-3 text-[13px]"> - <Icon name="fork" class="text-gray-500" /> - <span class="text-gray-900">{loop.name}</span> - <span class="text-gray-500">{loop.branch}</span> - <span class="text-gray-500 ml-auto"> - {loop.driver} · {loop.ago} - </span> - </li> - )} - </For> - </ul> - </Show> - <button class="mt-3 px-3 py-1.5 rounded bg-gray-200 text-gray-900 text-xs hover:bg-gray-300 flex items-center gap-2"> - <Icon name="enter" /> - <span>spawn new loop on a branch</span> - </button> - </section> - <section> - <h3 class="text-[13px] font-medium text-gray-900 mb-2">README</h3> - <div class="max-w-[760px]"> - <Markdown text={props.repo.readme} /> - </div> - </section> - </article> - </> - ) -} diff --git a/phase1-prototype/src/pages/focus.tsx b/phase1-prototype/src/pages/focus.tsx deleted file mode 100644 index eb9c7a11..00000000 --- a/phase1-prototype/src/pages/focus.tsx +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Focus tab — pure derived view from loops() + focusFile(). - * - * Focus is not a real entity. The list is computed: - * - Focus name = unique value across all loop.focuses[] - * - Plus pinned/listed names from notes/focus.md (via focusFile signal) - * - Activity = max(loop.lastActivityAgo) across associated loops - * - * The only real state is focusFile (pinned + listed empty meta focuses). - * - * Three derived sections: - * 1. 📌 Pinned — pinned focuses (永不归档) - * 2. focus — non-pinned focuses with at least 1 loop - * 3. 🚨 未认领 — rfd loops without any focus (incident queue) - * 4. ⑂ active 但未归类 — non-rfd loops without any focus - * - * Plus a deeplink to notes/inbox.md for team scratch prose. - */ -import { createMemo, For, Show } from "solid-js" -import { useNavigate } from "@solidjs/router" -import { ME, loops, focusFile, inboxItems, type Loop } from "../state" - -const INBOX_PREVIEW_LIMIT = 5 - -type LinkedLoop = { - id: string - name: string - driver: string - ago: string - rfd: boolean -} - -type DerivedFocus = { - name: string - pinned: boolean - loops: LinkedLoop[] - lastTouched: string // display string, "—" if no loops - expiresInDays?: number // 8d - daysSinceActivity, only for non-pinned - meta: boolean // true if no associated loops (empty meta focus) - meDriving: boolean // any loop in this focus is driven by ME -} - -// Mock: parse "8h" / "1d" / "30m" / "yesterday 16:20" / etc. into rough hours. -// Used only for sort + 8d-expire countdown. Lossy by design. -function agoToHours(ago: string): number { - if (!ago || ago === "just now") return 0 - if (ago.endsWith("m")) return parseInt(ago) / 60 - if (ago.endsWith("h")) return parseInt(ago) - if (ago.endsWith("d")) return parseInt(ago) * 24 - if (ago.endsWith("w")) return parseInt(ago) * 24 * 7 - return 1 -} - -function pickFresher(a: string, b: string): string { - return agoToHours(a) <= agoToHours(b) ? a : b -} - -function deriveFocuses(allLoops: Loop[], pinned: string[], listed: string[]): DerivedFocus[] { - const pinnedSet = new Set(pinned) - const byName = new Map<string, LinkedLoop[]>() - - for (const l of allLoops) { - if (l.status === "archived") continue - for (const f of l.focuses ?? []) { - const list = byName.get(f) ?? [] - list.push({ - id: l.id, - name: l.name, - driver: l.driver, - ago: l.lastActivityAgo, - rfd: !!l.rfd, - }) - byName.set(f, list) - } - } - - // Ensure pinned + listed names are present even with 0 loops - for (const name of [...pinned, ...listed]) { - if (!byName.has(name)) byName.set(name, []) - } - - const result: DerivedFocus[] = [] - for (const [name, ls] of byName) { - const lastTouched = ls.length === 0 ? "—" : ls.map((x) => x.ago).reduce(pickFresher) - const hours = ls.length === 0 ? Infinity : agoToHours(lastTouched) - const expiresInDays = pinnedSet.has(name) - ? undefined - : Math.max(0, Math.ceil(8 - hours / 24)) - result.push({ - name, - pinned: pinnedSet.has(name), - loops: ls, - lastTouched, - expiresInDays, - meta: ls.length === 0, - meDriving: ls.some((x) => x.driver === ME), - }) - } - // Sort: ME-driving first, then by activity - result.sort((a, b) => { - if (a.meDriving !== b.meDriving) return a.meDriving ? -1 : 1 - return agoToHours(a.lastTouched) - agoToHours(b.lastTouched) - }) - return result -} - -export function FocusPage() { - const navigate = useNavigate() - - const derived = createMemo(() => - deriveFocuses(loops(), focusFile().pinned, focusFile().listed), - ) - const pinned = () => derived().filter((f) => f.pinned) - const active = () => derived().filter((f) => !f.pinned) - - // Loops that have a driver but no focus tag — a focus-hygiene hint. - // RFD/unclaimed loops do NOT belong here: those are "automatic incoming", - // orthogonal to focus curation. They're discoverable via Loop tab's RFD badge. - const orphan = createMemo(() => - loops().filter( - (l) => l.status !== "archived" && !l.rfd && (l.focuses ?? []).length === 0, - ), - ) - - return ( - <div class="flex flex-col h-full w-full bg-white"> - <header class="h-10 shrink-0 flex items-center gap-3 px-6 border-b border-gray-200"> - <span class="text-[13px] text-gray-700 tracking-tight">what matters now</span> - <div class="flex-1" /> - <button - type="button" - onClick={() => navigate("/context/notes/focus.md")} - class="text-[11px] text-gray-500 hover:text-gray-900 flex items-center gap-1" - title="Focus 唯一的真存:pinned 名单 + 空 meta focus" - > - <span>edit</span> - <code class="text-gray-700">notes/focus.md</code> - <span>↗</span> - </button> - </header> - - <main class="flex-1 min-w-0 flex flex-col overflow-auto"> - <div class="px-8 py-8 mx-auto w-full max-w-[760px] flex flex-col gap-7"> - <Show when={pinned().length > 0}> - <Section label="📌 Pinned" sub="永不归档" tone="pin"> - <For each={pinned()}>{(item) => <FocusRow item={item} navigate={navigate} />}</For> - </Section> - </Show> - - <Show when={active().length > 0}> - <Section label="focus" sub="任意 loop 带 tag 自动出现 · 8d 无活动归档" tone="focus"> - <For each={active()}>{(item) => <FocusRow item={item} navigate={navigate} />}</For> - </Section> - </Show> - - <Show when={orphan().length > 0}> - <Section label="⑂ active 但未归类" sub="有 driver 但没挂任何 focus" tone="orphan"> - <For each={orphan()}> - {(loop) => ( - <button - type="button" - onClick={() => navigate(`/loop/${loop.id}`)} - class="w-full text-left flex items-center gap-2 px-3 py-1.5 rounded hover:bg-gray-50" - > - <span class="text-gray-400">⑂</span> - <span class="text-[13px] text-gray-900">{loop.name}</span> - <span class="text-xs text-gray-500 ml-auto"> - {loop.driver} · {loop.lastActivityAgo} - </span> - </button> - )} - </For> - </Section> - </Show> - - <Section - label="📝 inbox" - sub="团队稀碎 prose · 脚注,不在 Focus 主体里" - tone="muted" - right={ - <button - type="button" - onClick={() => navigate("/context/notes/inbox.md")} - class="text-[11px] text-gray-500 hover:text-gray-900 flex items-center gap-1" - title="编辑入口在 Context tab" - > - <span>edit in notes</span> - <span>↗</span> - </button> - } - > - <ul class="flex flex-col gap-0.5"> - <For each={inboxItems().slice(0, INBOX_PREVIEW_LIMIT)}> - {(line) => ( - <li class="flex items-baseline gap-2 text-[12px] text-gray-700 leading-relaxed"> - <span class="text-gray-400 shrink-0">·</span> - <span class="truncate">{line}</span> - </li> - )} - </For> - <Show when={inboxItems().length > INBOX_PREVIEW_LIMIT}> - <li class="pt-1"> - <button - type="button" - onClick={() => navigate("/context/notes/inbox.md")} - class="text-[11px] text-gray-500 hover:text-gray-900" - > - … 还有 {inboxItems().length - INBOX_PREVIEW_LIMIT} 条 → - </button> - </li> - </Show> - </ul> - </Section> - </div> - </main> - </div> - ) -} - -type SectionTone = "pin" | "focus" | "alert" | "orphan" | "muted" - -const TONE_BOX: Record<SectionTone, string> = { - pin: "bg-amber-50/40 border-amber-200/60", - focus: "bg-white border-gray-200", - alert: "bg-red-50/30 border-red-200/60", - orphan: "bg-white border-gray-200 border-dashed", - muted: "bg-gray-50/60 border-gray-100", -} - -const TONE_DOT: Record<SectionTone, string> = { - pin: "bg-amber-400", - focus: "bg-gray-400", - alert: "bg-red-500", - orphan: "bg-gray-300 border border-gray-400 border-dashed", - muted: "bg-gray-300", -} - -function Section(props: { - label: string - sub?: string - tone?: SectionTone - right?: any - children: any -}) { - const tone = (): SectionTone => props.tone ?? "focus" - return ( - <section class={`rounded-lg border px-4 py-3 ${TONE_BOX[tone()]}`}> - <header class="flex items-baseline gap-2 mb-2"> - <span class={`w-1.5 h-1.5 rounded-full self-center ${TONE_DOT[tone()]}`} /> - <h3 class="text-[14px] font-medium text-gray-900">{props.label}</h3> - <Show when={props.sub}> - <span class="text-[11px] text-gray-500">{props.sub}</span> - </Show> - <Show when={props.right}> - <div class="ml-auto">{props.right}</div> - </Show> - </header> - <div class="flex flex-col gap-1.5">{props.children}</div> - </section> - ) -} - -function FocusRow(props: { item: DerivedFocus; navigate: (path: string) => void }) { - const item = props.item - const expiresWarn = () => item.expiresInDays !== undefined && item.expiresInDays <= 3 - return ( - <div class="px-3 py-2 rounded hover:bg-gray-50"> - <div class="flex items-baseline gap-2"> - <span class="text-sm font-medium text-gray-900">{item.name}</span> - <Show when={item.meDriving}> - <span class="text-[10px] px-1.5 py-px rounded bg-emerald-50 text-emerald-700 border border-emerald-200"> - me - </span> - </Show> - <span class="text-[11px] text-gray-500 ml-auto">· {item.lastTouched}</span> - </div> - - <Show when={item.loops.length > 0}> - <ul class="ml-2 mt-1 flex flex-col gap-0.5"> - <For each={item.loops}> - {(loop) => ( - <li> - <button - type="button" - onClick={() => props.navigate(`/loop/${loop.id}`)} - class="flex items-center gap-2 text-xs text-gray-500 hover:text-gray-900" - > - <span>⑂</span> - <span class="text-gray-900">{loop.name}</span> - <Show when={loop.rfd}> - <span class="text-amber-600 text-[10px]">RFD</span> - </Show> - <span>·</span> - <span>{loop.driver}</span> - <span>·</span> - <span>{loop.ago}</span> - </button> - </li> - )} - </For> - </ul> - </Show> - - <Show when={item.meta}> - <div class="ml-2 mt-1 text-[11px] text-gray-500">(meta · 当前无关联 loop)</div> - </Show> - - <Show when={item.expiresInDays !== undefined && !item.meta}> - <div - class={ - expiresWarn() - ? "ml-2 mt-1 text-[11px] text-orange-600" - : "ml-2 mt-1 text-[11px] text-gray-500" - } - > - expires in {item.expiresInDays}d {expiresWarn() ? "⚠" : ""} - </div> - </Show> - </div> - ) -} diff --git a/phase1-prototype/src/pages/loop.tsx b/phase1-prototype/src/pages/loop.tsx deleted file mode 100644 index 034edfc8..00000000 --- a/phase1-prototype/src/pages/loop.tsx +++ /dev/null @@ -1,1609 +0,0 @@ -/** - * Loop tab — chat-first layout with optional right panel. - * - * Shows what makes this loop unique: driver, workdir, context (knowledge - * scope + mounted repos). Drops the old "idea-only" badge in favor of - * context chips that explain the loop's scope. - * - * Driver state model: - * - active + driver = ME → can release (RFD) - * - active + driver = other → just shows their name - * - rfd → anyone (incl. self) can claim drive - * - fork is always available, regardless of state - * - * Right panel (toggleable, 50/50 split when open): - * files / editor (CodeMirror) / terminal - */ -import { createSignal, createEffect, For, Show } from "solid-js" -import { useParams, useNavigate } from "@solidjs/router" -import { Icon } from "../components/icon" -import { Markdown } from "../components/markdown" -import { CodeEditor } from "../components/code-editor" -import { - ME, - loops, - forkLoop, - releaseRfd, - claimDrive, - previewSlug, - setNewLoopDialogOpen, - setLoopPersonal, - mountRevisions, - syncMount, - chats, - focusFile, - type CreateLoopOpts, -} from "../state" -import { REPOS, VAULT_DOCS, flattenVaultFiles, type DocNode } from "./context" -import type { ChatItem, Loop, TimelineEvent } from "../state" -import { getWorkspace } from "../mock/files" -import type { FileNode } from "../mock/files" - -type RightMode = "workdir" | "editor" | "terminal" | "info" - -const TERMINAL_LINES = [ - "$ pytest tests/test_gateway.py::test_rdma_register -xvs", - "================================ test session starts ================================", - "platform linux -- Python 3.10.13, pytest-8.0.1", - "rootdir: /home/simpx/workspace/loopat-runtime", - "collected 1 item", - "", - "tests/test_gateway.py::test_rdma_register PASSED [100%]", - "", - "================================= 1 passed in 4.82s =================================", - "$ ▍", -] - -export function LoopPage() { - const params = useParams<{ id: string }>() - const navigate = useNavigate() - const [scope, setScope] = createSignal<"mine" | "all" | "rfd">("mine") - const [rightOpen, setRightOpen] = createSignal(false) - const [rightMode, setRightMode] = createSignal<RightMode>("workdir") - const [editingPath, setEditingPath] = createSignal<string>("") - const [fileEdits, setFileEdits] = createSignal<Record<string, Record<string, string>>>({}) - const [openFolders, setOpenFolders] = createSignal(new Set<string>(["context", "main"])) - const [addContextOpen, setAddContextOpen] = createSignal(false) - - const toggleFolder = (name: string) => { - const next = new Set(openFolders()) - if (next.has(name)) next.delete(name) - else next.add(name) - setOpenFolders(next) - } - - const isPinned = (l: Loop) => { - const pins = new Set(focusFile().pinned) - return (l.focuses ?? []).some((f) => pins.has(f)) - } - const sortKey = (l: Loop) => { - if (isPinned(l)) return 0 - if (l.rfd) return 2 - return 1 - } - const filtered = () => - loops() - .filter((l) => { - if (scope() === "mine") return l.driver === ME - if (scope() === "rfd") return l.rfd && l.status !== "archived" - return l.status !== "archived" - }) - .slice() - .sort((a, b) => sortKey(a) - sortKey(b)) - - const current = () => loops().find((l) => l.id === params.id) ?? loops()[0] - const workspace = () => getWorkspace(current().id) - - const toggleMode = (m: RightMode) => { - if (rightOpen() && rightMode() === m) setRightOpen(false) - else { - setRightOpen(true) - setRightMode(m) - } - } - - const openFile = (path: string) => { - setEditingPath(path) - setRightMode("editor") - setRightOpen(true) - } - - const fileText = (path: string) => { - const lid = current().id - return ( - fileEdits()[lid]?.[path] ?? - workspace().fileContents[path] ?? - `// ${path}\n(no content)` - ) - } - const setFileText = (path: string, value: string) => { - const lid = current().id - const next = { ...fileEdits() } - next[lid] = { ...(next[lid] ?? {}), [path]: value } - setFileEdits(next) - } - const isEdited = (path: string) => { - const lid = current().id - const edited = fileEdits()[lid]?.[path] - if (edited === undefined) return false - return edited !== (workspace().fileContents[path] ?? "") - } - - const currentChat = (): ChatItem[] => chats[current().id] ?? [] - - return ( - <div class="flex h-full w-full"> - <LoopsList - scope={scope} - setScope={setScope} - filtered={filtered} - currentId={() => params.id} - onSelect={(id) => navigate(`/loop/${id}`)} - onNewClick={() => setNewLoopDialogOpen(true)} - /> - - <div class="flex-1 min-w-0 min-h-0 flex"> - <main class="flex-1 min-w-0 flex flex-col bg-white min-h-0"> - <LoopHeader - loop={current()} - rightOpen={rightOpen} - rightMode={rightMode} - toggleMode={toggleMode} - onAddContext={() => setAddContextOpen(true)} - /> - - <Show when={addContextOpen()}> - <AddContextDialog - loop={current()} - onClose={() => setAddContextOpen(false)} - onSave={(paths) => { - setLoopPersonal(current().id, paths) - setAddContextOpen(false) - }} - /> - </Show> - - <div class="flex-1 min-h-0 overflow-auto px-5 py-4 flex flex-col gap-3"> - <Show - when={currentChat().length > 0} - fallback={ - <div class="text-[13px] text-gray-500 italic mt-4"> - No conversation yet — say something to start the loop. - </div> - } - > - <For each={currentChat()}>{(item) => <ChatRow item={item} onOpenFile={openFile} />}</For> - </Show> - </div> - - <ChatInput /> - </main> - - <Show when={rightOpen()}> - <RightPanel - current={current} - rightMode={rightMode} - setRightOpen={setRightOpen} - editingPath={editingPath} - fileText={fileText} - setFileText={setFileText} - isEdited={isEdited} - openFolders={openFolders} - toggleFolder={toggleFolder} - openFile={openFile} - /> - </Show> - </div> - </div> - ) -} - -// ============================================================================ -// Loops list (col 1) -// ============================================================================ - -function LoopsList(props: { - scope: () => "mine" | "all" | "rfd" - setScope: (v: "mine" | "all" | "rfd") => void - filtered: () => Loop[] - currentId: () => string - onSelect: (id: string) => void - onNewClick: () => void -}) { - const rfdCount = () => loops().filter((l) => l.rfd && l.status !== "archived").length - const isPinned = (l: Loop) => { - const pins = new Set(focusFile().pinned) - return (l.focuses ?? []).some((f) => pins.has(f)) - } - return ( - <aside class="w-60 shrink-0 border-r border-gray-200 bg-white flex flex-col"> - <div class="px-2 h-10 flex items-center gap-1 border-b border-gray-200"> - <button - type="button" - onClick={() => props.setScope("mine")} - class={ - props.scope() === "mine" - ? "px-2 h-6 rounded text-[11px] bg-gray-900 text-white" - : "px-2 h-6 rounded text-[11px] text-gray-500 hover:bg-gray-100" - } - > - 我的 - </button> - <button - type="button" - onClick={() => props.setScope("all")} - class={ - props.scope() === "all" - ? "px-2 h-6 rounded text-[11px] bg-gray-900 text-white" - : "px-2 h-6 rounded text-[11px] text-gray-500 hover:bg-gray-100" - } - > - 全部 - </button> - <button - type="button" - onClick={() => props.setScope("rfd")} - title="rfd / 未认领" - class={ - props.scope() === "rfd" - ? "px-2 h-6 rounded text-[11px] flex items-center gap-1 bg-amber-600 text-white" - : "px-2 h-6 rounded text-[11px] flex items-center gap-1 text-amber-700 hover:bg-amber-50" - } - > - <span>RFD</span> - <Show when={rfdCount() > 0}> - <span - class={ - props.scope() === "rfd" - ? "text-[10px] px-1 rounded-full bg-white/20" - : "text-[10px] px-1 rounded-full bg-amber-100 text-amber-700" - } - > - {rfdCount()} - </span> - </Show> - </button> - <span class="text-[11px] text-gray-400 ml-auto pr-1">{props.filtered().length}</span> - </div> - <div class="flex-1 min-h-0 overflow-auto py-2"> - <For each={props.filtered()}> - {(loop) => { - const sel = () => props.currentId() === loop.id - return ( - <button - type="button" - onClick={() => props.onSelect(loop.id)} - class={ - sel() - ? "w-full px-3 py-2 flex items-center gap-2 text-left bg-gray-100" - : "w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-gray-50" - } - > - <span - class={ - loop.status === "active" - ? "w-1.5 h-1.5 rounded-full shrink-0 bg-emerald-500" - : "w-1.5 h-1.5 rounded-full shrink-0 bg-gray-300" - } - /> - <div class="flex-1 min-w-0"> - <div class="text-[13px] text-gray-900 truncate">{loop.name}</div> - <div class="text-[11px] text-gray-500 truncate flex items-center gap-1"> - <ArchetypeIcon a={loop.archetype} /> - <span>{loop.driver}</span> - <span>·</span> - <span>{loop.lastActivityAgo}</span> - </div> - </div> - <Show when={loop.rfd}> - <span title="RFD" class="text-amber-600 text-[11px]">RFD</span> - </Show> - <Show when={isPinned(loop)}> - <span title="pinned in focus" class="text-gray-500">📌</span> - </Show> - </button> - ) - }} - </For> - </div> - </aside> - ) -} - -function ArchetypeIcon(props: { a: Loop["archetype"] }) { - const ch = - props.a === "code" - ? "‹›" - : props.a === "research" - ? "⌕" - : props.a === "online" - ? "⚡" - : props.a === "context-refine" - ? "≡" - : "✦" - return <span class="text-gray-400 font-mono text-[10px]">{ch}</span> -} - -// ============================================================================ -// Loop header (driver state + context chips) -// ============================================================================ - -function LoopHeader(props: { - loop: Loop - rightOpen: () => boolean - rightMode: () => RightMode - toggleMode: (m: RightMode) => void - onAddContext: () => void -}) { - const navigate = useNavigate() - const loop = () => props.loop - const isMine = () => loop().driver === ME - const modeBtn = (label: string, m: RightMode) => ( - <button - class={ - props.rightOpen() && props.rightMode() === m - ? "px-2 py-0.5 rounded bg-gray-100 text-gray-900" - : "px-2 py-0.5 rounded hover:text-gray-900" - } - onClick={() => props.toggleMode(m)} - > - {label} - </button> - ) - return ( - <header class="px-5 pt-3 pb-2 shrink-0 border-b border-gray-200"> - <div class="flex items-center gap-2 flex-wrap"> - <span class="text-[15px] font-medium text-gray-900">{loop().name}</span> - <span - class={ - loop().status === "active" - ? "w-1.5 h-1.5 rounded-full bg-emerald-500" - : "w-1.5 h-1.5 rounded-full bg-gray-300" - } - /> - <Show when={loop().rfd}> - <span class="text-[11px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-800"> - RFD - </span> - </Show> - <Show when={loop().status === "idle" && !loop().rfd}> - <span class="text-[11px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">idle</span> - </Show> - <span class="text-xs text-gray-500"> - driver: <span class={isMine() ? "text-gray-900" : ""}>{loop().driver}</span> - </span> - - <div class="flex-1" /> - - {/* Always visible */} - <button - type="button" - onClick={() => navigate(`/loop/${forkLoop(loop().id)}`)} - class="px-3 h-7 rounded text-xs bg-gray-900 text-white hover:bg-gray-700" - > - fork - </button> - - {/* Driver-state-aware action */} - <Show when={isMine() && !loop().rfd}> - <button - type="button" - onClick={() => releaseRfd(loop().id)} - class="px-3 h-7 rounded text-xs bg-amber-100 text-amber-900 hover:bg-amber-200" - title="release this loop — anyone can claim drive" - > - RFD - </button> - </Show> - <Show when={loop().rfd}> - <button - type="button" - onClick={() => claimDrive(loop().id)} - class="px-3 h-7 rounded text-xs bg-emerald-100 text-emerald-900 hover:bg-emerald-200" - title={isMine() ? "re-drive this loop" : "take over driving this loop"} - > - drive - </button> - </Show> - </div> - - {/* workdir + branch + mode toggles */} - <div class="text-xs text-gray-500 mt-1.5 flex items-center gap-2 flex-wrap"> - <span>{loop().workdir}</span> - <Show when={loop().branch}> - <span>·</span> - <span class="flex items-center gap-1"> - <Icon name="fork" /> - {loop().branch} - </span> - </Show> - <span>·</span> - <span>{loop().participants} viewing</span> - <div class="flex-1" /> - <div class="flex items-center gap-1 text-[11px]"> - {modeBtn("ℹ info", "info")} - {modeBtn("▤ workdir", "workdir")} - {modeBtn("✎ editor", "editor")} - {modeBtn("▷ terminal", "terminal")} - </div> - </div> - - {/* context chips */} - <div class="mt-2 flex items-center gap-1.5 flex-wrap text-[11px]"> - <span class="text-gray-400">context:</span> - <ContextChip - label="knowledge" - value={loop().context.knowledge === "all" ? "all" : `${loop().context.knowledge.length}`} - /> - <ContextChip - label="notes" - value={loop().context.notes === "all" ? "all" : `${loop().context.notes.length}`} - /> - <ContextChip - label="personal" - value={ - loop().context.personal && loop().context.personal!.length > 0 - ? `${loop().context.personal!.length}` - : "—" - } - /> - <For each={loop().context.chats ?? []}> - {(mount) => { - const isDm = mount.id.startsWith("dm-") - const display = isDm ? `@${mount.id.replace(/^dm-/, "")}` : `#${mount.id}` - return ( - <button - type="button" - onClick={() => - navigate( - mount.id.startsWith("dm-") - ? `/chat/dm/${mount.id.slice(3)}` - : `/chat/${mount.id}`, - ) - } - title={`${display} ingested up to msg #${mount.upTo} · click to open`} - class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-gray-100 hover:bg-gray-200 text-[11px]" - > - <span class="text-gray-500">chat:</span> - <span class="text-gray-900 font-medium">{display}</span> - <span class="text-gray-400 font-mono">:{mount.upTo}</span> - </button> - ) - }} - </For> - <button - type="button" - onClick={() => props.onAddContext()} - class="text-gray-500 hover:text-gray-900 px-1.5 py-0.5 rounded hover:bg-gray-100" - title="add context" - > - + - </button> - </div> - - {/* focus chips */} - <Show when={loop().focuses && loop().focuses!.length > 0}> - <div class="mt-1 flex items-center gap-1.5 flex-wrap text-[11px]"> - <span class="text-gray-400">focus:</span> - <For each={loop().focuses}> - {(name) => ( - <span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 text-[11px] font-medium text-gray-900"> - {name} - </span> - )} - </For> - </div> - </Show> - </header> - ) -} - -function ContextChip(props: { label: string; value: string }) { - return ( - <span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-gray-100 text-[11px]"> - <span class="text-gray-500">{props.label}:</span> - <span class="text-gray-900 font-medium">{props.value}</span> - </span> - ) -} - -// ============================================================================ -// Chat input + bottom toolbar -// ============================================================================ - -type PastedImage = { url: string; name: string } - -function ChatInput() { - const [pasted, setPasted] = createSignal<PastedImage[]>([]) - - const handlePaste = (e: ClipboardEvent) => { - const items = e.clipboardData?.items - if (!items) return - for (let i = 0; i < items.length; i++) { - const it = items[i] - if (it.kind !== "file" || !it.type.startsWith("image/")) continue - const blob = it.getAsFile() - if (!blob) continue - e.preventDefault() - const reader = new FileReader() - reader.onload = () => { - setPasted([ - ...pasted(), - { url: reader.result as string, name: blob.name || `pasted-${Date.now()}.png` }, - ]) - } - reader.readAsDataURL(blob) - } - } - - const removePasted = (idx: number) => setPasted(pasted().filter((_, i) => i !== idx)) - - return ( - <div class="px-5 pb-3 pt-2 shrink-0 border-t border-gray-200"> - <Show when={pasted().length > 0}> - <div class="flex flex-wrap gap-2 mb-2"> - <For each={pasted()}> - {(p, i) => ( - <div class="relative group"> - <img - src={p.url} - alt={p.name} - class="w-16 h-16 object-cover rounded border border-gray-200" - /> - <button - type="button" - onClick={() => removePasted(i())} - class="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-gray-900 text-white text-[11px] flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" - title="remove" - > - × - </button> - <div class="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-[9px] truncate px-1 rounded-b"> - {p.name} - </div> - </div> - )} - </For> - </div> - </Show> - <div class="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 flex items-center gap-2"> - <Icon name="prompt" class="text-gray-500" /> - <input - type="text" - class="flex-1 bg-transparent outline-none text-[13px] text-gray-900 placeholder:text-gray-500" - placeholder="type message · ⌘V to paste image" - onPaste={handlePaste} - /> - <button class="px-3 py-1 rounded bg-gray-200 text-gray-900 text-xs hover:bg-gray-300"> - send - </button> - </div> - </div> - ) -} - -// ============================================================================ -// Right panel (files / editor / terminal) -// ============================================================================ - -function RightPanel(props: { - current: () => Loop - rightMode: () => RightMode - setRightOpen: (v: boolean) => void - editingPath: () => string - fileText: (p: string) => string - setFileText: (p: string, v: string) => void - isEdited: (p: string) => boolean - openFolders: () => Set<string> - toggleFolder: (n: string) => void - openFile: (p: string) => void -}) { - return ( - <aside class="flex-1 min-w-0 border-l border-gray-200 bg-white flex flex-col"> - <header class="px-3 h-8 shrink-0 border-b border-gray-200 flex items-center gap-1 text-[11px] text-gray-500"> - <span class="capitalize">{props.rightMode()}</span> - <Show when={props.rightMode() === "editor"}> - <span class="ml-2 truncate"> - {props.editingPath() || "(no file)"} - <Show when={props.editingPath() && props.isEdited(props.editingPath())}> - <span class="text-orange-600"> ●</span> - </Show> - </span> - </Show> - <div class="flex-1" /> - <button - class="text-gray-500 hover:text-gray-900 p-1 rounded hover:bg-gray-100" - onClick={() => props.setRightOpen(false)} - title="close panel" - > - <Icon name="close-small" /> - </button> - </header> - - <Show when={props.rightMode() === "info"}> - <InfoPanel loop={props.current()} /> - </Show> - - <Show when={props.rightMode() === "workdir"}> - <div class="flex-1 min-h-0 overflow-auto py-2"> - <For each={buildLoopWorkdir(props.current())}> - {(node) => ( - <FileTreeNode - node={node} - depth={0} - openFolders={props.openFolders} - toggleFolder={props.toggleFolder} - onOpen={props.openFile} - currentPath={props.editingPath} - /> - )} - </For> - </div> - <div class="border-t border-gray-200 px-3 py-2 text-[11px] text-gray-500"> - ⑂ <span class="text-gray-900">{props.current().branch ?? "main"}</span> - </div> - </Show> - - <Show when={props.rightMode() === "editor"}> - <Show - when={props.editingPath()} - fallback={ - <div class="flex-1 min-h-0 flex items-center justify-center text-[13px] text-gray-500 px-8 text-center"> - 没打开文件 · 在 ▤ workdir 里点一个,或在 chat 里点 artifact card - </div> - } - > - <div class="flex-1 min-h-0"> - <CodeEditor - path={props.editingPath()} - value={props.fileText(props.editingPath())} - onChange={(v) => props.setFileText(props.editingPath(), v)} - /> - </div> - <div class="border-t border-gray-200 px-3 py-1.5 text-[11px] text-gray-500 flex items-center gap-3"> - <span>{props.editingPath()}</span> - <Show when={props.isEdited(props.editingPath())}> - <span class="text-orange-600">unsaved (mock)</span> - </Show> - <span class="ml-auto">utf-8 · LF</span> - </div> - </Show> - </Show> - - <Show when={props.rightMode() === "terminal"}> - <div class="flex-1 min-h-0 overflow-auto px-3 py-2 font-mono text-xs leading-snug bg-gray-50"> - <For each={TERMINAL_LINES}> - {(line) => ( - <div - class={ - line.includes("PASSED") || line.startsWith("$") - ? "text-emerald-600" - : line.startsWith("=") - ? "text-gray-500" - : "text-gray-900" - } - > - {line} - </div> - )} - </For> - </div> - </Show> - </aside> - ) -} - -// ============================================================================ -// Info panel — loop metadata + timeline -// ============================================================================ - -function InfoPanel(props: { loop: Loop }) { - const ws = () => getWorkspace(props.loop.id) - const fileCount = () => countFiles(ws().fileTree) - return ( - <div class="flex-1 min-h-0 overflow-auto px-5 py-4 text-[13px] text-gray-900"> - <Section label="basics"> - <Row label="created" value={`${props.loop.createdAt} · by ${props.loop.createdBy}`} /> - <Row label="archetype" value={props.loop.archetype} /> - <Row label="status" value={statusLabel(props.loop)} /> - <Row label="driver" value={props.loop.driver + (props.loop.driver === ME ? " (you)" : "")} /> - <Row label="participants" value={`${props.loop.participants}`} /> - <Row label="last activity" value={props.loop.lastActivityAgo} /> - </Section> - - <Section label="workdir"> - <Row label="path" value={props.loop.workdir} mono /> - <Show when={props.loop.branch}> - <Row label="branch" value={props.loop.branch!} mono /> - </Show> - <Row label="files" value={`${fileCount()} tracked`} /> - </Section> - - <Section label="context"> - <Row - label="knowledge" - value={ - props.loop.context.knowledge === "all" - ? "all (public)" - : `scoped to ${props.loop.context.knowledge.length} dirs` - } - /> - <Row - label="notes" - value={ - props.loop.context.notes === "all" - ? "all (public)" - : `scoped to ${props.loop.context.notes.length} dirs` - } - /> - <Show when={props.loop.context.personal && props.loop.context.personal.length > 0}> - <Row - label="personal" - value={`${props.loop.context.personal!.length} paths (private)`} - /> - </Show> - <Show when={props.loop.context.chats && props.loop.context.chats.length > 0}> - <Row - label="chat" - value={props.loop.context - .chats!.map((c) => `${c.id}:${c.upTo}`) - .join(", ")} - mono - /> - </Show> - </Section> - - <Show when={(props.loop.focuses ?? []).length > 0}> - <Section label="focus"> - <For each={props.loop.focuses ?? []}> - {(name) => { - const pinned = new Set(focusFile().pinned).has(name) - return <Row label={pinned ? "📌 pinned" : "listed"} value={name} /> - }} - </For> - </Section> - </Show> - - <Section label="timeline"> - <ol class="flex flex-col gap-2 mt-1"> - <For each={[...props.loop.timeline].reverse()}> - {(ev) => <TimelineRow ev={ev} />} - </For> - </ol> - </Section> - </div> - ) -} - -function Section(props: { label: string; children: any }) { - return ( - <section class="mb-5"> - <h3 class="text-[11px] uppercase tracking-wide text-gray-400 mb-2">{props.label}</h3> - <div class="flex flex-col gap-1.5">{props.children}</div> - </section> - ) -} - -function Row(props: { label: string; value: string; mono?: boolean }) { - return ( - <div class="flex items-baseline gap-3"> - <span class="text-[12px] text-gray-500 w-20 shrink-0">{props.label}</span> - <span class={props.mono ? "font-mono text-[12px]" : "text-[13px]"}>{props.value}</span> - </div> - ) -} - -function TimelineRow(props: { ev: TimelineEvent }) { - const ev = props.ev - const summary = (() => { - switch (ev.kind) { - case "create": - return `created by ${ev.by}` - case "fork": - return `forked by ${ev.by}${ev.note ? ` · ${ev.note}` : ""}` - case "driver-change": - return `driver: ${ev.from} → ${ev.to}` - case "rfd": - return `${ev.by} released (RFD)${ev.note ? ` · ${ev.note}` : ""}` - case "claim": - return `${ev.by} claimed${ev.from ? ` from ${ev.from}` : ""}${ev.note ? ` · ${ev.note}` : ""}` - case "focus-pin": - return `pinned to focus${ev.note ? ` · ${ev.note}` : ""}` - } - })() - const dot = - ev.kind === "create" - ? "bg-emerald-500" - : ev.kind === "rfd" - ? "bg-amber-500" - : ev.kind === "claim" - ? "bg-emerald-500" - : ev.kind === "fork" - ? "bg-purple-500" - : "bg-gray-400" - return ( - <li class="flex items-baseline gap-2.5"> - <span class={`w-1.5 h-1.5 rounded-full shrink-0 ${dot} translate-y-[3px]`} /> - <span class="font-mono text-[11px] text-gray-500 shrink-0 w-32">{ev.time}</span> - <span class="text-[12px] text-gray-900">{summary}</span> - </li> - ) -} - -function statusLabel(l: Loop): string { - if (l.rfd) return "active · RFD" - return l.status -} - -function countFiles(nodes: FileNode[]): number { - let n = 0 - for (const node of nodes) { - if (node.kind === "file") n++ - else n += countFiles(node.children) - } - return n -} - -function FileTreeNode(props: { - node: FileNode - depth: number - pathKey?: string - openFolders: () => Set<string> - toggleFolder: (name: string) => void - onOpen: (path: string) => void - currentPath: () => string -}) { - if (props.node.kind === "folder") { - const folder = props.node - const key = () => props.pathKey ?? folder.name - const opened = () => props.openFolders().has(key()) - const isSection = folder.display === "section" - return ( - <> - <button - type="button" - class={ - isSection - ? folder.name === "context" - ? "w-full py-1.5 flex items-center gap-1.5 bg-cyan-50/50 hover:bg-cyan-50 text-left border-y border-cyan-100/70" - : "w-full py-1.5 flex items-center gap-1.5 bg-emerald-50/40 hover:bg-emerald-50 text-left border-y border-emerald-100/70" - : "w-full py-1 flex items-center gap-1.5 hover:bg-gray-50 text-left" - } - style={{ "padding-left": `${0.5 + props.depth * 0.75}rem`, "padding-right": "0.5rem" }} - onClick={() => props.toggleFolder(key())} - > - <Icon name={opened() ? "chevron-down" : "chevron-right"} class="text-gray-500" /> - <Show - when={folder.secret} - fallback={ - <Show - when={isSection} - fallback={<Icon name="folder" class="text-gray-500" />} - > - <span class="text-[12px]"> - {folder.name === "context" ? "🧷" : "▣"} - </span> - </Show> - } - > - <span class="text-[12px]">🔐</span> - </Show> - <span - class={ - isSection - ? "text-[11px] uppercase tracking-wider font-semibold text-gray-700" - : "text-[13px] text-gray-900 truncate" - } - > - {folder.name} - </span> - <Show when={folder.hint}> - <span class="text-[10px] text-gray-500 italic">{folder.hint}</span> - </Show> - <Show when={folder.mount}> - <MountBadge mount={folder.mount!} /> - </Show> - <Show when={folder.revision}> - <span - class={ - folder.onSync - ? "text-[10px] text-gray-400 font-mono ml-auto" - : "text-[10px] text-gray-400 font-mono ml-auto pr-1" - } - > - {folder.revision} - </span> - </Show> - <Show when={folder.onSync}> - <span - role="button" - tabIndex={0} - class="text-[11px] text-gray-400 hover:text-gray-900 px-1 rounded hover:bg-gray-100" - title="sync (git pull --rebase)" - onClick={(e) => { - e.stopPropagation() - folder.onSync?.() - }} - > - ↻ - </span> - </Show> - </button> - <Show when={opened()}> - <For each={folder.children}> - {(child) => ( - <FileTreeNode - node={child} - depth={props.depth + 1} - pathKey={`${key()}/${child.kind === "folder" ? child.name : child.name}`} - openFolders={props.openFolders} - toggleFolder={props.toggleFolder} - onOpen={props.onOpen} - currentPath={props.currentPath} - /> - )} - </For> - </Show> - </> - ) - } - const file = props.node - const sel = () => props.currentPath() === file.path - const navigate = useNavigate() - const handleClick = () => { - if (file.linkTo) navigate(file.linkTo) - else props.onOpen(file.path) - } - return ( - <button - type="button" - class={ - sel() - ? "w-full py-1 flex items-center gap-2 text-left bg-gray-100" - : file.linkTo - ? "w-full py-1 flex items-center gap-2 text-left hover:bg-gray-50 italic text-gray-500" - : "w-full py-1 flex items-center gap-2 text-left hover:bg-gray-50" - } - style={{ "padding-left": `${0.5 + props.depth * 0.75}rem`, "padding-right": "0.5rem" }} - onClick={handleClick} - > - <span class="w-4" /> - <span class={file.linkTo ? "text-[12px] flex-1 min-w-0 truncate" : "text-[13px] text-gray-900 flex-1 min-w-0 truncate"}> - {file.name} - </span> - <Show when={file.readonly && !file.linkTo}> - <span class="text-[10px] text-gray-400" title="read-only">ro</span> - </Show> - <Show when={file.staged}> - <span class="text-[11px] text-emerald-600" title="staged">A</span> - </Show> - <Show when={file.modified && !file.staged}> - <span class="text-[11px] text-gray-500" title="modified">M</span> - </Show> - </button> - ) -} - -function MountBadge(props: { mount: "ro" | "rw" | "selective" }) { - const cls = () => { - switch (props.mount) { - case "ro": - return "text-[10px] uppercase tracking-wide px-1 rounded bg-gray-100 text-gray-600" - case "rw": - return "text-[10px] uppercase tracking-wide px-1 rounded bg-cyan-100 text-cyan-800" - case "selective": - return "text-[10px] uppercase tracking-wide px-1 rounded bg-purple-100 text-purple-800" - } - } - return <span class={cls()}>{props.mount}</span> -} - -function buildLoopWorkdir(loop: Loop): FileNode[] { - const main = getWorkspace(loop.id).fileTree - const revs = mountRevisions() - const ctx: FileNode[] = [] - ctx.push({ - kind: "folder", - name: "knowledge", - mount: "ro", - revision: revs.knowledge, - onSync: () => syncMount("knowledge"), - children: [ - { - kind: "file", - name: "→ all team knowledge", - path: "context/knowledge/__link__", - linkTo: "/context/knowledge", - readonly: true, - }, - ], - }) - ctx.push({ - kind: "folder", - name: "notes", - mount: "rw", - revision: revs.notes, - onSync: () => syncMount("notes"), - children: [ - { - kind: "file", - name: "→ all team notes", - path: "context/notes/__link__", - linkTo: "/context/notes", - }, - ], - }) - if (loop.context.personal && loop.context.personal.length > 0) { - ctx.push({ - kind: "folder", - name: "personal", - mount: "selective", - revision: revs.personal, - children: pathsToTreeFlat(loop.context.personal, "context/personal"), - }) - } - return [ - { - kind: "folder", - name: "context", - display: "section", - hint: "mounted from sources", - children: ctx, - }, - { - kind: "folder", - name: "main", - display: "section", - hint: "agent's cwd", - children: main, - }, - ] -} - -function pathsToTreeFlat(paths: string[], prefix: string): FileNode[] { - type T = { children: Record<string, T>; isFile?: boolean; full?: string } - const root: T = { children: {} } - for (const p of paths) { - const parts = p.split("/") - let cur = root - for (let i = 0; i < parts.length; i++) { - const part = parts[i] - if (i === parts.length - 1) { - cur.children[part] = { children: {}, isFile: true, full: `${prefix}/${p}` } - } else { - if (!cur.children[part]) cur.children[part] = { children: {} } - cur = cur.children[part] - } - } - } - const toNodes = (n: T): FileNode[] => - Object.entries(n.children).map(([name, child]): FileNode => { - if (child.isFile) { - return { kind: "file", name, path: child.full!, readonly: name.toUpperCase() === name && !name.includes(".") } - } - const isSecrets = name === "secrets" - return { - kind: "folder", - name, - children: toNodes(child), - secret: isSecrets || undefined, - } - }) - return toNodes(root) -} - -// ============================================================================ -// Chat row — supports text / diff / todo / artifact / command / system markers. -// -// Visual rules (Claude-Code style): -// - user text → gray bg block, no avatar/label -// - ai text → no bg, no avatar/label -// - structured items (diff/todo/artifact/command) → bordered cards -// - driver-change / rfd / claim → centered horizontal markers -// ============================================================================ - -function ChatRow(props: { item: ChatItem; onOpenFile: (path: string) => void }) { - const item = props.item - - if (item.kind === "driver-change") { - return <SystemMarker text={`${item.time} driver: ${item.from} → ${item.to}`} /> - } - if (item.kind === "rfd") { - return <SystemMarker text={`${item.time} ${item.by} 释放 driver · 进入 RFD`} accent="amber" /> - } - if (item.kind === "claim") { - return <SystemMarker text={`${item.time} ${item.by} 认领了 driver`} accent="emerald" /> - } - - if (item.kind === "todo") return <TodoCard item={item} /> - if (item.kind === "artifact") return <ArtifactCard item={item} onOpen={() => props.onOpenFile(item.path)} /> - - // Everything else (user / ai / diff / read / command) → markdown. - // The Markdown component (marked + highlight.js) handles all the - // layout: code fences, syntax-coloring, diff +/- backgrounds, lists. - const md = toMarkdown(item) - const isUser = item.kind === "user" - return ( - <div class={isUser ? "rounded-md bg-gray-100 px-4 py-3" : "px-4 py-2"}> - <Markdown text={md} class="prose-chat" /> - <div class="text-[11px] text-gray-500 mt-2">{item.time}</div> - </div> - ) -} - -const LANG_BY_EXT: Record<string, string> = { - py: "python", - go: "go", - ts: "typescript", - tsx: "typescript", - js: "javascript", - jsx: "javascript", - sh: "bash", - bash: "bash", - yaml: "yaml", - yml: "yaml", - json: "json", - md: "markdown", - toml: "", -} -const langFromPath = (path: string) => { - const ext = path.split(".").pop()?.toLowerCase() ?? "" - return LANG_BY_EXT[ext] ?? "" -} - -function toMarkdown(item: ChatItem): string { - if (item.kind === "user" || item.kind === "ai") return item.text - - if (item.kind === "diff") { - const body = item.lines - .map((l) => - l.kind === "hunk" - ? l.text - : l.kind === "add" - ? "+" + l.text.replace(/^[+-]?\t?/, "") - : l.kind === "del" - ? "-" + l.text.replace(/^[+-]?\t?/, "") - : " " + l.text, - ) - .join("\n") - return `**Edit** \`${item.file}\`\n\n\`\`\`diff\n${body}\n\`\`\`` - } - - if (item.kind === "read") { - const lang = langFromPath(item.path) - const start = item.startLine ?? 1 - const end = start + item.lines.length - 1 - const range = item.startLine ? ` · L${start}-${end}` : "" - const total = item.total ? ` of ${item.total}` : "" - return `**Read** \`${item.path}\`${range}${total}\n\n\`\`\`${lang}\n${item.lines.join("\n")}\n\`\`\`` - } - - if (item.kind === "command") { - const ok = item.ok === false ? " ✗" : item.ok === true ? " ✓" : "" - return `**\`$ ${item.cmd}\`**${ok}\n\n\`\`\`\n${item.output.join("\n")}\n\`\`\`` - } - - return "" -} - -function SystemMarker(props: { text: string; accent?: "amber" | "emerald" }) { - const color = - props.accent === "amber" - ? "text-amber-700" - : props.accent === "emerald" - ? "text-emerald-700" - : "text-gray-500" - return ( - <div class="flex items-center gap-2 my-1 text-[11px]"> - <span class="flex-1 h-px bg-gray-200" /> - <span class={color}>{props.text}</span> - <span class="flex-1 h-px bg-gray-200" /> - </div> - ) -} - -function TodoCard(props: { item: Extract<ChatItem, { kind: "todo" }> }) { - return ( - <div class="rounded-md border border-gray-200 px-4 py-3 bg-white mx-1"> - <Show when={props.item.title}> - <div class="text-[12px] text-gray-500 mb-1.5">{props.item.title}</div> - </Show> - <ul class="flex flex-col gap-0.5"> - <For each={props.item.items}> - {(t) => ( - <li class="flex items-start gap-2 text-[13px]"> - <span class={t.done ? "text-emerald-600" : "text-gray-400"}>{t.done ? "☑" : "☐"}</span> - <span class={t.done ? "text-gray-500 line-through" : "text-gray-900"}>{t.text}</span> - </li> - )} - </For> - </ul> - <div class="text-[11px] text-gray-500 mt-2">{props.item.time}</div> - </div> - ) -} - -function ArtifactCard(props: { item: Extract<ChatItem, { kind: "artifact" }>; onOpen: () => void }) { - return ( - <button - type="button" - onClick={props.onOpen} - class="text-left rounded-md border border-gray-200 px-4 py-3 bg-white mx-1 hover:bg-gray-50 hover:border-gray-300" - > - <div class="flex items-center gap-2"> - <span class="text-[14px]">📄</span> - <span class="text-[13px] font-mono text-gray-900">{props.item.path}</span> - <span class="text-[11px] text-gray-500">created · {props.item.lines} lines</span> - <span class="ml-auto text-[11px] text-gray-500">{props.item.time}</span> - </div> - <div class="mt-2 font-mono text-[11px] leading-snug text-gray-500 line-clamp-3 whitespace-pre-wrap"> - {props.item.preview} - </div> - </button> - ) -} - -// ============================================================================ -// New loop dialog — pick repo / inject context / optional name -// ============================================================================ - -export function NewLoopDialog(props: { - onClose: () => void - onCreate: (opts: CreateLoopOpts) => void -}) { - const [name, setName] = createSignal("") - const [repo, setRepo] = createSignal("") - const [personal, setPersonal] = createSignal<Set<string>>(new Set()) - - const personalFiles = () => flattenVaultFiles(VAULT_DOCS.personal) - const allPersonalSelected = () => - personalFiles().length > 0 && personalFiles().every((f) => personal().has(f.path)) - const togglePersonal = (path: string) => { - const next = new Set(personal()) - if (next.has(path)) next.delete(path) - else next.add(path) - setPersonal(next) - } - const toggleAllPersonal = () => { - if (allPersonalSelected()) setPersonal(new Set<string>()) - else setPersonal(new Set(personalFiles().map((f) => f.path))) - } - - const slugPreview = () => - previewSlug({ - name: name() || undefined, - repo: repo() || undefined, - }) - - const handleCreate = () => { - props.onCreate({ - name: name().trim() || undefined, - repo: repo() || undefined, - injectPersonal: personal().size > 0 ? [...personal()] : undefined, - }) - } - - return ( - <div - class="fixed inset-0 z-50 bg-black/30 flex items-center justify-center" - onClick={props.onClose} - > - <div - class="bg-white rounded-lg shadow-xl w-[640px] max-h-[85vh] flex flex-col" - onClick={(e) => e.stopPropagation()} - > - <header class="px-5 h-11 shrink-0 border-b border-gray-200 flex items-center"> - <span class="text-[14px] font-medium text-gray-900">New loop</span> - <span class="ml-auto text-[11px] text-gray-500"> - URL: <code class="font-mono text-gray-900">/loop/{slugPreview()}</code> - </span> - <button - type="button" - onClick={props.onClose} - class="ml-3 text-gray-500 hover:text-gray-900 p-1 rounded hover:bg-gray-100" - title="cancel" - > - <Icon name="close-small" /> - </button> - </header> - - <div class="flex-1 min-h-0 overflow-auto px-5 py-4 flex flex-col gap-5"> - <DialogField label="Repo" hint="决定 workdir。可选。"> - <select - class="w-full text-[13px] border border-gray-200 rounded px-2 h-8 bg-white" - value={repo()} - onChange={(e) => setRepo(e.currentTarget.value)} - > - <option value="">— none —</option> - <For each={REPOS}> - {(r) => <option value={r.name}>{r.name}</option>} - </For> - </select> - </DialogField> - - <DialogField label="Context"> - <div class="border border-gray-200 rounded"> - <div class="px-3 h-8 flex items-center gap-2 text-[12px] text-gray-700 border-b border-gray-200 bg-gray-50"> - <span class="text-emerald-600">✓</span> - <span class="font-medium">knowledge</span> - <span class="text-gray-500">+</span> - <span class="font-medium">notes</span> - <span class="text-gray-500 ml-1">— public, 默认全注入</span> - </div> - <div class="px-3 h-8 flex items-center gap-2 border-b border-gray-200"> - <span class="text-[12px] font-medium text-gray-900">personal</span> - <span class="text-[11px] text-gray-500"> - {personal().size} / {personalFiles().length} selected - </span> - <button - type="button" - onClick={toggleAllPersonal} - class="ml-auto text-[11px] text-gray-600 hover:text-gray-900 px-2 h-6 rounded hover:bg-gray-100" - > - {allPersonalSelected() ? "clear" : "select all"} - </button> - </div> - <div class="px-3 py-1 max-h-56 overflow-auto"> - <For each={personalFiles()}> - {(f) => ( - <label class="flex items-center gap-2 py-1 text-[12px] cursor-pointer hover:bg-gray-50 rounded px-1"> - <input - type="checkbox" - class="shrink-0" - checked={personal().has(f.path)} - onChange={() => togglePersonal(f.path)} - /> - <Show when={f.secret}> - <span class="text-amber-600 text-[11px]">🔒</span> - </Show> - <span class="font-mono text-[11px] text-gray-700 truncate">{f.path}</span> - </label> - )} - </For> - </div> - </div> - </DialogField> - - <DialogField label="Name" hint="可选。slug 优先用 name,没则 repo,没则 hex。"> - <input - type="text" - class="w-full text-[13px] border border-gray-200 rounded px-2 h-8 bg-white outline-none focus:border-gray-400" - placeholder="e.g. gateway-rdma-fix" - value={name()} - onInput={(e) => setName(e.currentTarget.value)} - /> - </DialogField> - </div> - - <footer class="px-5 h-12 shrink-0 border-t border-gray-200 flex items-center justify-end gap-2"> - <button - type="button" - onClick={props.onClose} - class="px-3 h-7 rounded text-xs text-gray-600 hover:bg-gray-100" - > - cancel - </button> - <button - type="button" - onClick={handleCreate} - class="px-3 h-7 rounded text-xs bg-gray-900 text-white hover:bg-gray-700" - > - create - </button> - </footer> - </div> - </div> - ) -} - -function DialogField(props: { label: string; hint?: string; children: any }) { - return ( - <div> - <div class="flex items-baseline gap-2 mb-1.5"> - <label class="text-[12px] font-medium text-gray-900">{props.label}</label> - <Show when={props.hint}> - <span class="text-[11px] text-gray-500">{props.hint}</span> - </Show> - </div> - {props.children} - </div> - ) -} - -// ============================================================================ -// Add-context dialog — opened from LoopHeader's "+", lets driver mount more -// personal paths into the loop's workdir. -// ============================================================================ - -function AddContextDialog(props: { - loop: Loop - onClose: () => void - onSave: (paths: string[]) => void -}) { - const [selected, setSelected] = createSignal<Set<string>>( - new Set(props.loop.context.personal ?? []), - ) - return ( - <div - class="fixed inset-0 z-50 bg-black/30 flex items-center justify-center" - onClick={props.onClose} - > - <div - class="bg-white rounded-lg shadow-xl w-[640px] max-h-[85vh] flex flex-col" - onClick={(e) => e.stopPropagation()} - > - <header class="px-5 h-11 shrink-0 border-b border-gray-200 flex items-center"> - <span class="text-[14px] font-medium text-gray-900">+ context</span> - <span class="ml-3 text-[11px] text-gray-500">into <code class="font-mono">{props.loop.id}</code></span> - <button - type="button" - onClick={props.onClose} - class="ml-auto text-gray-500 hover:text-gray-900 p-1 rounded hover:bg-gray-100" - title="cancel" - > - <Icon name="close-small" /> - </button> - </header> - - <div class="flex-1 min-h-0 overflow-auto px-5 py-4 flex flex-col gap-4"> - <div class="border border-gray-200 rounded"> - <div class="px-3 h-8 flex items-center gap-2 text-[12px] text-gray-700 border-b border-gray-200 bg-gray-50"> - <span class="text-emerald-600">✓</span> - <span class="font-medium">knowledge</span> - <span class="text-gray-500">+</span> - <span class="font-medium">notes</span> - <span class="text-gray-500 ml-1">— public,已默认全注入</span> - </div> - <div class="px-3 py-2 text-[12px] text-gray-700"> - <div class="font-medium text-gray-900 mb-1.5">personal</div> - <div class="text-[11px] text-gray-500 mb-2"> - 选目录即选中所有子项。已选 {selected().size} 项。 - </div> - <TreeChecklist - nodes={VAULT_DOCS.personal} - selected={selected} - onChange={setSelected} - /> - </div> - </div> - </div> - - <footer class="px-5 h-12 shrink-0 border-t border-gray-200 flex items-center justify-end gap-2"> - <button - type="button" - onClick={props.onClose} - class="px-3 h-7 rounded text-xs text-gray-600 hover:bg-gray-100" - > - cancel - </button> - <button - type="button" - onClick={() => props.onSave([...selected()])} - class="px-3 h-7 rounded text-xs bg-gray-900 text-white hover:bg-gray-700" - > - save - </button> - </footer> - </div> - </div> - ) -} - -function TreeChecklist(props: { - nodes: DocNode[] - selected: () => Set<string> - onChange: (s: Set<string>) => void -}) { - const leavesOf = (n: DocNode): string[] => - n.kind === "file" ? [n.path] : n.children.flatMap(leavesOf) - - const stateOf = (n: DocNode): "all" | "some" | "none" => { - if (n.kind === "file") return props.selected().has(n.path) ? "all" : "none" - const leaves = leavesOf(n) - if (leaves.length === 0) return "none" - const hits = leaves.filter((p) => props.selected().has(p)).length - if (hits === 0) return "none" - if (hits === leaves.length) return "all" - return "some" - } - - const toggle = (n: DocNode) => { - const next = new Set(props.selected()) - if (n.kind === "file") { - if (next.has(n.path)) next.delete(n.path) - else next.add(n.path) - } else { - const leaves = leavesOf(n) - const allChecked = leaves.length > 0 && leaves.every((p) => next.has(p)) - if (allChecked) leaves.forEach((p) => next.delete(p)) - else leaves.forEach((p) => next.add(p)) - } - props.onChange(next) - } - - return ( - <div> - <For each={props.nodes}> - {(n) => <TreeChecklistNode node={n} depth={0} stateOf={stateOf} toggle={toggle} />} - </For> - </div> - ) -} - -function TreeChecklistNode(props: { - node: DocNode - depth: number - stateOf: (n: DocNode) => "all" | "some" | "none" - toggle: (n: DocNode) => void -}) { - const [open, setOpen] = createSignal(true) - const indent = () => `${props.depth * 0.9}rem` - if (props.node.kind === "folder") { - const f = props.node - const state = () => props.stateOf(f) - return ( - <> - <div - class="flex items-center gap-1.5 py-0.5 hover:bg-gray-50 rounded" - style={{ "padding-left": indent() }} - > - <button - type="button" - class="text-gray-500 hover:text-gray-900" - onClick={() => setOpen(!open())} - > - <Icon name={open() ? "chevron-down" : "chevron-right"} /> - </button> - <Tristate state={state()} onChange={() => props.toggle(f)} /> - <Show when={f.marker === "secrets"}> - <span class="text-[11px]">🔐</span> - </Show> - <span class="text-[12px] text-gray-900 font-medium">{f.name}</span> - </div> - <Show when={open()}> - <For each={f.children}> - {(c) => ( - <TreeChecklistNode - node={c} - depth={props.depth + 1} - stateOf={props.stateOf} - toggle={props.toggle} - /> - )} - </For> - </Show> - </> - ) - } - const file = props.node - const state = () => props.stateOf(file) - return ( - <div - class="flex items-center gap-1.5 py-0.5 hover:bg-gray-50 rounded" - style={{ "padding-left": `calc(${indent()} + 1.1rem)` }} - > - <Tristate state={state()} onChange={() => props.toggle(file)} /> - <Show when={file.secret}> - <span class="text-amber-600 text-[10px]">🔒</span> - </Show> - <span class="font-mono text-[11px] text-gray-700 truncate">{file.name}</span> - </div> - ) -} - -function Tristate(props: { state: "all" | "some" | "none"; onChange: () => void }) { - let ref: HTMLInputElement | undefined - createEffect(() => { - if (ref) ref.indeterminate = props.state === "some" - }) - return ( - <input - ref={ref} - type="checkbox" - class="shrink-0" - checked={props.state === "all"} - onChange={() => props.onChange()} - /> - ) -} - diff --git a/phase1-prototype/src/state.ts b/phase1-prototype/src/state.ts deleted file mode 100644 index 7e888875..00000000 --- a/phase1-prototype/src/state.ts +++ /dev/null @@ -1,1133 +0,0 @@ -/** - * Module-level signals for the prototype. Pure mock — no backend, no - * persistence. Mutations (fork, RFD release, claim, edit) update signals. - */ -import { createSignal } from "solid-js" - -export const ME = "simpx" - -export type LoopStatus = "active" | "idle" | "archived" - -export type ChatMount = { - id: string - upTo: number -} - -export type LoopContext = { - knowledge: "all" | string[] - notes: "all" | string[] - personal?: string[] - chats?: ChatMount[] -} - -export type TimelineEvent = { - time: string - kind: "create" | "driver-change" | "rfd" | "claim" | "fork" | "focus-pin" - by: string - from?: string - to?: string - note?: string -} - -export type Loop = { - id: string - name: string - archetype: "code" | "research" | "online" | "context-refine" | "design" - workdir: string - branch?: string - driver: string - participants: number - lastActivityAgo: string - status: LoopStatus - focuses?: string[] - forkedFrom?: string - rfd?: boolean - context: LoopContext - createdAt: string - createdBy: string - timeline: TimelineEvent[] -} - -export type DiffLine = { - kind: "ctx" | "add" | "del" | "hunk" - text: string - ln?: number -} - -export type ChatItem = - | { kind: "user"; text: string; time: string } - | { kind: "ai"; text: string; time: string } - | { kind: "driver-change"; from: string; to: string; time: string } - | { kind: "rfd"; by: string; time: string } - | { kind: "claim"; by: string; time: string } - | { kind: "diff"; file: string; lines: DiffLine[]; time: string } - | { - kind: "read" - path: string - startLine?: number - total?: number - lines: string[] - time: string - } - | { kind: "todo"; title?: string; items: { done: boolean; text: string }[]; time: string } - | { kind: "artifact"; path: string; preview: string; lines: number; time: string } - | { kind: "command"; cmd: string; output: string[]; ok?: boolean; time: string } - -const initialLoops: Loop[] = [ - { - id: "prototype-hifi", - name: "prototype-hifi", - archetype: "design", - workdir: "~/workspace/1001/phase1-prototype", - branch: "main", - driver: ME, - participants: 1, - lastActivityAgo: "just now", - status: "active", - focuses: ["产品侧高保真原型"], - context: { - knowledge: "all", - notes: "all", - chats: [ - { id: "all", upTo: 12 }, - { id: "dm-coo", upTo: 24 }, - ], - }, - createdAt: "2026-05-05 16:20", - createdBy: ME, - timeline: [ - { time: "2026-05-05 16:20", kind: "create", by: ME }, - { time: "2026-05-05 16:25", kind: "focus-pin", by: ME, note: "产品侧高保真原型" }, - ], - }, - { - id: "loopat-runtime-spike", - name: "loopat-runtime-spike", - archetype: "research", - workdir: "~/workspace/loopat", - branch: "feat/runtime-spike", - driver: ME, - participants: 2, - lastActivityAgo: "1h", - status: "active", - focuses: ["可自举的MVP"], - context: { - knowledge: "all", - notes: "all", - chats: [ - { id: "dev", upTo: 86 }, - { id: "dm-panlilu", upTo: 38 }, - ], - }, - createdAt: "2026-05-04 14:00", - createdBy: ME, - timeline: [ - { time: "2026-05-04 14:00", kind: "create", by: ME }, - { time: "2026-05-04 14:30", kind: "focus-pin", by: ME, note: "可自举的MVP" }, - ], - }, - { - id: "loopat-ts-mvp", - name: "loopat-ts-mvp", - archetype: "code", - workdir: "~/workspace/loopat-ts", - branch: "main", - driver: "panlilu", - participants: 1, - lastActivityAgo: "26m", - status: "active", - focuses: ["可自举的MVP"], - context: { - knowledge: "all", - notes: "all", - chats: [ - { id: "dev", upTo: 86 }, - { id: "dm-panlilu", upTo: 38 }, - ], - }, - createdAt: "2026-05-02 10:00", - createdBy: "panlilu", - timeline: [ - { time: "2026-05-02 10:00", kind: "create", by: "panlilu" }, - { time: "2026-05-02 10:30", kind: "focus-pin", by: "panlilu", note: "可自举的MVP" }, - ], - }, - { - id: "research-opencode", - name: "research-opencode", - archetype: "research", - workdir: "~/workspace/opencode-fork", - branch: "1001-prototype", - driver: ME, - participants: 1, - lastActivityAgo: "5h", - status: "active", - context: { knowledge: "all", notes: "all" }, - createdAt: "2026-05-03 11:00", - createdBy: ME, - timeline: [{ time: "2026-05-03 11:00", kind: "create", by: ME }], - }, - { - id: "research-claude-code", - name: "research-claude-code", - archetype: "research", - workdir: "~/workspace/claude-code-internals", - driver: ME, - participants: 1, - lastActivityAgo: "2d", - status: "active", - context: { knowledge: "all", notes: "all" }, - createdAt: "2026-05-07 09:30", - createdBy: ME, - timeline: [{ time: "2026-05-07 09:30", kind: "create", by: ME }], - }, - { - id: "site-uptime-spike", - name: "site-uptime-spike", - archetype: "online", - workdir: "(待认领)", - driver: "(未认领)", - participants: 0, - lastActivityAgo: "8m", - status: "active", - rfd: true, - context: { - knowledge: "all", - notes: "all", - chats: [{ id: "ops", upTo: 6 }], - }, - createdAt: "2026-05-09 09:42", - createdBy: "ops-bot", - timeline: [ - { time: "2026-05-09 09:42", kind: "create", by: "ops-bot", note: "loopat.ai 5xx 抖动告警" }, - { time: "2026-05-09 09:42", kind: "rfd", by: "ops-bot", note: "等人接" }, - ], - }, - { - id: "demo-video-script", - name: "demo-video-script", - archetype: "design", - workdir: "~/workspace/loopat/demo", - branch: "main", - driver: ME, - participants: 1, - lastActivityAgo: "30m", - status: "active", - focuses: ["初版上线"], - context: { - knowledge: "all", - notes: "all", - chats: [{ id: "all", upTo: 12 }, { id: "dm-coo", upTo: 24 }], - }, - createdAt: "2026-05-10 08:30", - createdBy: ME, - timeline: [ - { time: "2026-05-10 08:30", kind: "create", by: ME, note: "HN show video draft" }, - { time: "2026-05-10 09:15", kind: "focus-pin", by: ME, note: "初版上线" }, - ], - }, - { - id: "attach-spec-review", - name: "attach-spec-review", - archetype: "design", - workdir: "~/workspace/loopat", - branch: "spec/attach-v0", - driver: "panlilu", - participants: 2, - lastActivityAgo: "3h", - status: "active", - context: { - knowledge: "all", - notes: "all", - chats: [{ id: "dev", upTo: 86 }], - }, - createdAt: "2026-05-09 22:00", - createdBy: ME, - timeline: [ - { time: "2026-05-09 22:00", kind: "create", by: ME, note: "把 attach spec 草稿单开 loop 让 panlilu review" }, - { time: "2026-05-09 22:30", kind: "rfd", by: ME, note: "我先睡了,明早 panlilu 看完接" }, - { time: "2026-05-10 09:18", kind: "claim", by: "panlilu", from: ME, note: "我接,今天 review 完出意见" }, - ], - }, - { - id: "feature-pricing-sketch", - name: "feature-pricing-sketch", - archetype: "research", - workdir: "~/workspace/loopat-pricing", - driver: ME, - participants: 1, - lastActivityAgo: "4d", - status: "idle", - context: { knowledge: "all", notes: "all" }, - createdAt: "2026-05-06 14:20", - createdBy: ME, - timeline: [ - { time: "2026-05-06 14:20", kind: "create", by: ME, note: "猜个大概的定价模型,看 Linear / Notion 怎么收的" }, - ], - }, - { - id: "naming-brainstorm", - name: "naming-brainstorm", - archetype: "design", - workdir: "(vault)", - driver: ME, - participants: 1, - lastActivityAgo: "1d", - status: "archived", - context: { knowledge: "all", notes: "all" }, - createdAt: "2026-05-08 21:00", - createdBy: ME, - timeline: [ - { time: "2026-05-08 21:00", kind: "create", by: ME, note: "brand 名 brainstorm" }, - { time: "2026-05-09 02:00", kind: "create", by: ME, note: "decision: loopat.ai" }, - ], - }, - { - id: "prototype-hifi-fork-test", - name: "prototype-hifi-fork-test", - archetype: "design", - workdir: "~/workspace/1001/phase1-prototype-fork", - branch: "main-fork", - driver: "panlilu", - participants: 1, - lastActivityAgo: "5h", - status: "active", - forkedFrom: "prototype-hifi", - context: { knowledge: "all", notes: "all" }, - createdAt: "2026-05-10 04:30", - createdBy: "panlilu", - timeline: [ - { time: "2026-05-10 04:30", kind: "fork", by: "panlilu", note: "forked from prototype-hifi · 想试 react 改写看可行性" }, - ], - }, -] - -export const [loops, setLoops] = createSignal<Loop[]>(initialLoops) - -export const [newLoopDialogOpen, setNewLoopDialogOpen] = createSignal(false) - -export function slugify(s: string): string { - return s - .toLowerCase() - .normalize("NFKD") - .replace(/[^\w\s-]/g, "") - .trim() - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, "") -} - -function randHex(n: number): string { - const bytes = new Uint8Array(n) - crypto.getRandomValues(bytes) - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("") -} - -function uniqueSlug(base: string): string { - const baseSlug = slugify(base) - if (!baseSlug) return randHex(4) - const taken = new Set(loops().map((l) => l.id)) - if (!taken.has(baseSlug)) return baseSlug - return `${baseSlug}-${randHex(2)}` -} - -const updateLoop = (id: string, patch: Partial<Loop>) => { - setLoops(loops().map((l) => (l.id === id ? { ...l, ...patch } : l))) -} - -function nowDisplay() { - const d = new Date() - const pad = (n: number) => String(n).padStart(2, "0") - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` -} - -const appendTimeline = (id: string, ev: TimelineEvent) => { - const loop = loops().find((l) => l.id === id) - if (!loop) return - updateLoop(id, { timeline: [...loop.timeline, ev] }) -} - -export function forkLoop(sourceId: string): string { - const source = loops().find((l) => l.id === sourceId) - if (!source) return sourceId - const newId = uniqueSlug(`${source.name}-fork`) - const ts = nowDisplay() - const newLoop: Loop = { - ...source, - id: newId, - name: `${source.name}-fork`, - branch: source.branch ? `${source.branch}-fork` : undefined, - driver: ME, - participants: 1, - lastActivityAgo: "just now", - status: "active", - forkedFrom: source.id, - rfd: false, - createdAt: ts, - createdBy: ME, - timeline: [{ time: ts, kind: "fork", by: ME, note: `forked from ${source.name}` }], - } - setLoops([newLoop, ...loops()]) - return newId -} - -export type CreateLoopOpts = { - name?: string - repo?: string - injectPersonal?: string[] -} - -export function createLoop(opts: CreateLoopOpts = {}): string { - const base = opts.name?.trim() || opts.repo?.trim() || "" - const id = uniqueSlug(base) - const ts = nowDisplay() - const newLoop: Loop = { - id, - name: opts.name?.trim() || opts.repo?.trim() || "untitled", - archetype: "code", - workdir: opts.repo ? `~/workspace/${opts.repo}` : "~/workspace", - driver: ME, - participants: 1, - lastActivityAgo: "just now", - status: "active", - context: { - knowledge: "all", - notes: "all", - personal: opts.injectPersonal?.length ? opts.injectPersonal : undefined, - }, - createdAt: ts, - createdBy: ME, - timeline: [{ time: ts, kind: "create", by: ME }], - } - setLoops([newLoop, ...loops()]) - return id -} - -export function createDistillLoop(filePath: string): string { - const basename = filePath.split("/").pop()?.replace(/\.md$/, "") || filePath - const id = uniqueSlug(`distill-${basename}`) - const ts = nowDisplay() - const newLoop: Loop = { - id, - name: `distill ${basename}`, - archetype: "context-refine", - workdir: "(vault)", - driver: ME, - participants: 1, - lastActivityAgo: "just now", - status: "active", - context: { knowledge: "all", notes: "all" }, - createdAt: ts, - createdBy: ME, - timeline: [{ time: ts, kind: "create", by: ME, note: `把 ${filePath} 蒸馏到 knowledge` }], - } - setLoops([newLoop, ...loops()]) - const time = ts.split(" ")[1] ?? ts - chats[id] = [ - { - kind: "user", - text: `把 \`${filePath}\` 蒸馏一下放进 knowledge。先读一遍当前内容,给一个目标路径建议(loopat/ / ai-org/ / conventions/ / skills/ 选一个),再讨论怎么 restructure。`, - time, - }, - ] - return id -} - -export function createEditLoop(filePath: string): string { - const basename = filePath.split("/").pop()?.replace(/\.md$/, "") || filePath - const id = uniqueSlug(`edit-${basename}`) - const ts = nowDisplay() - const newLoop: Loop = { - id, - name: `edit ${basename}`, - archetype: "context-refine", - workdir: "(vault)", - driver: ME, - participants: 1, - lastActivityAgo: "just now", - status: "active", - context: { knowledge: "all", notes: "all" }, - createdAt: ts, - createdBy: ME, - timeline: [{ time: ts, kind: "create", by: ME, note: `编辑 ${filePath}` }], - } - setLoops([newLoop, ...loops()]) - const time = ts.split(" ")[1] ?? ts - chats[id] = [ - { - kind: "user", - text: `准备编辑 \`${filePath}\`,先帮我读一下当前内容,待会我说要改什么。`, - time, - }, - ] - return id -} - -export const [mountRevisions, setMountRevisions] = createSignal<Record<string, string>>({ - knowledge: "@8a4c1b", - notes: "@c2e174", - personal: "@f0b385", -}) - -export function syncMount(source: string) { - const next = new Uint8Array(3) - crypto.getRandomValues(next) - const hex = Array.from(next, (b) => b.toString(16).padStart(2, "0")).join("") - setMountRevisions({ ...mountRevisions(), [source]: `@${hex}` }) -} - -export function setLoopPersonal(id: string, paths: string[]) { - const loop = loops().find((l) => l.id === id) - if (!loop) return - updateLoop(id, { - context: { ...loop.context, personal: paths.length > 0 ? paths : undefined }, - }) -} - -export function previewSlug(opts: CreateLoopOpts): string { - const base = opts.name?.trim() || opts.repo?.trim() || "" - if (!base) return "<random-hex>" - return slugify(base) || "<random-hex>" -} - -export function releaseRfd(id: string) { - const loop = loops().find((l) => l.id === id) - if (!loop) return - updateLoop(id, { - rfd: true, - context: { ...loop.context, personal: undefined }, - }) - appendTimeline(id, { time: nowDisplay(), kind: "rfd", by: ME }) -} - -export function claimDrive(id: string) { - const prev = loops().find((l) => l.id === id)?.driver - updateLoop(id, { driver: ME, rfd: false }) - appendTimeline(id, { time: nowDisplay(), kind: "claim", by: ME, from: prev }) -} - -// ============================================================================ -// Chat content per loop -// ============================================================================ - -const PROTOTYPE_HIFI_CHAT: ChatItem[] = [ - { - kind: "user", - text: - "我们现在的目标是把 1001 想要的 4 个一级概念(loop / focus / context / chat)做成一个 hi-fi 原型,可以走通 fork / spawn / driver-transfer / context-mount 等核心交互,但全是 mock。", - time: "16:20", - }, - { - kind: "ai", - text: "已读完根目录 1001-mvp.md 和 1001-story.md。建议技术栈 Vite + SolidJS + Tailwind,4 个一级概念各占一个 tab。先做架子,再细化每个 tab 内部。", - time: "16:21", - }, - { kind: "user", text: "OK,先把目录结构和 4 tab shell 起起来", time: "16:22" }, - { - kind: "command", - cmd: "bun create vite phase1-prototype --template solid-ts", - ok: true, - time: "16:24", - output: [ - "Scaffolding project in ~/workspace/1001/phase1-prototype...", - "Done. Now run:", - " cd phase1-prototype", - " bun install", - " bun run dev", - ], - }, - { - kind: "artifact", - path: "src/App.tsx", - lines: 88, - time: "16:30", - preview: - "// 4-tab shell with router + workspace dropdown\nconst TABS = [\n { id: 'loop', label: 'Loop' },\n { id: 'focus', label: 'Focus' },\n { id: 'chat', label: 'Chat' },\n { id: 'context', label: 'Context' },\n]", - }, - { - kind: "user", - text: "Focus tab 改成纯派生 view —— 只有 notes/focus.md 是真存,pinned/listed override;其他全部从 loops().focuses[] 算出来", - time: "18:14", - }, - { - kind: "ai", - text: - "已重构 focus.tsx:\n- 删除 PINNED / FOCUS_ITEMS 三个 hardcoded 数组\n- 加 deriveFocuses(loops, pinned, listed) helper\n- 新增 sections:📌 Pinned / focus / 未归类 / inbox preview", - time: "18:25", - }, - { - kind: "user", - text: "未认领(rfd)从 Focus 段拿掉 —— rfd 是 automatic incoming,Focus 是 curated attention,方向相反不该混", - time: "18:33", - }, - { kind: "ai", text: "明白。删了 unclaimed 段,rfd loops 通过 Loop tab 的 RFD filter 档暴露。mvp doc §1.2 同步。", time: "18:34" }, - { - kind: "user", - text: "Chat tab:channel 的 metadata(spawn 出去的 loop)反向引用要做。loop 也要看到 chats 作为 context source", - time: "23:01", - }, - { - kind: "ai", - text: - "做了 4 件事:\n1. LoopContext 加 chats: ChatMount[],每个 mount 是 {id, upTo}\n2. chat conversation header 下方:📤 N loops 把这条 chat 作为 context(可折叠)\n3. loop header chips 加 chat: #channel:upTo 系列\n4. chatActive signal 跨 tab 共享,点 chip 跳到对应 channel", - time: "23:18", - }, - { kind: "user", text: "好,去掉 dm 段的 + 号,因为 #all 就是成员目录,找新人去那里搜", time: "00:14" }, - { kind: "ai", text: "已删。channel header 加可展开 + 搜索的成员面板,点名直接开 DM。", time: "00:18" }, -] - -const LOOPAT_RUNTIME_SPIKE_CHAT: ChatItem[] = [ - { - kind: "user", - text: - "正式开始 runtime spike。两条候选路:\n1. fork opencode(TS、Tauri 桌面、SSE multi-client)\n2. 完全自建(按我们的 attach 协议)\n\n你帮我列对照表,决定 1-2 个月窗口里哪条能 ship。", - time: "14:00", - }, - { - kind: "ai", - text: - "| 维度 | fork opencode | 完全自建 |\n|---|---|---|\n| 起点 | 现成 session/project/SSE | 0 |\n| 改动量 | 加 driver / focus / chat-mount 语义 | 全部 |\n| 风险 | 跟 upstream 撕裂 | 时间不够 |\n| 时间预估(1人) | 3-4w | 8-12w |\n| 学习曲线 | 看懂 ts 全栈 monorepo | 控制自己定的边界 |\n\n窗口期 1-2 月,单看时间 fork 占优。但要先确认 attach / driver 这些能否优雅塞进 opencode 的 session 模型。", - time: "14:08", - }, - { - kind: "command", - cmd: "git clone https://github.com/sst/opencode ~/workspace/opencode-fork && cd ~/workspace/opencode-fork && git checkout -b 1001-prototype", - ok: true, - time: "14:12", - output: [ - "Cloning into '~/workspace/opencode-fork'...", - "Receiving objects: 100% (12483/12483), 8.42 MiB | 4.1 MiB/s, done.", - "Switched to a new branch '1001-prototype'", - ], - }, - { - kind: "read", - path: "packages/server/src/session.ts", - startLine: 1, - total: 412, - time: "14:15", - lines: [ - "// opencode session = chat + project + working file state", - "export class Session {", - " id: string", - " projectId: string", - " messages: Message[]", - " // ...", - "}", - "", - "// session 跟 project 是 1:N;project = workdir。", - "// 没有 driver 概念;没有 focus 概念;attach 走 SSE 但只单 client。", - ], - }, - { - kind: "ai", - text: - "看完核心。结论:\n- session = 我们 loop 里 'context + ai' 那部分(已有)\n- project = workdir(已有)\n- driver / focus / chat-as-context / multi-client attach —— **要加**\n\ndriver 字段加在 session metadata 里;focus 是 workspace 级 derived view(不动 session model);attach multi-client 要把 SSE 改成 ws + 多 subscriber。\n\n可行,但 attach 那块改动不小。", - time: "14:30", - }, - { kind: "user", text: "panlilu 那边走完全自建 + Next.js + tRPC + Prisma。我们各跑一周看哪边先出活的 demo", time: "14:35" }, - { kind: "ai", text: "OK。我先在 fork 上加 driver 字段 + workspace-level focus 派生。一周后跟 panlilu 同步。", time: "14:36" }, - { - kind: "diff", - file: "packages/server/src/session.ts", - time: "14:50", - lines: [ - { kind: "hunk", text: "@@ -8,6 +8,8 @@ export class Session {" }, - { kind: "ctx", ln: 8, text: " id: string" }, - { kind: "ctx", ln: 9, text: " projectId: string" }, - { kind: "add", ln: 10, text: " driver: string // 1001 extension: who drives this loop" }, - { kind: "add", ln: 11, text: " rfd: boolean // 1001 extension: released for drive" }, - { kind: "ctx", ln: 12, text: " messages: Message[]" }, - { kind: "ctx", ln: 13, text: "}" }, - ], - }, - { - kind: "todo", - title: "spike checklist", - time: "15:30", - items: [ - { done: true, text: "clone fork opencode" }, - { done: true, text: "读懂 session / project model" }, - { done: true, text: "加 driver / rfd 字段(spike)" }, - { done: false, text: "workspace-level focus 派生" }, - { done: false, text: "改 SSE → ws + multi-subscriber" }, - { done: false, text: "attach 协议 spec" }, - { done: false, text: "跟 panlilu 周末同步进度" }, - ], - }, -] - -const LOOPAT_TS_MVP_CHAT: ChatItem[] = [ - { - kind: "user", - text: - "[panlilu] 我打算从零起 Next.js + tRPC + Prisma + Postgres + WebSocket 全栈。理由:opencode 模型不太对(session 不等于 loop),改 fork 会一直被 upstream 拉。自建慢但语义干净。", - time: "10:00", - }, - { - kind: "ai", - text: - "理由站得住 —— attach + driver + focus 这三件事 opencode 都没有,硬塞进去会伪。\n\n建议起手 stack:\n- Next.js 15 (app router) + React 19\n- tRPC 11 + react-query\n- Prisma + Postgres(domain models)\n- next-auth (beta) + Prisma adapter\n- ws 跨 client attach\n\n先写 schema 再写 UI,先类型再 query。", - time: "10:02", - }, - { - kind: "command", - cmd: "npx create-t3-app@latest loopat-ts --noInstall --tailwind --trpc --prisma --nextAuth", - ok: true, - time: "10:04", - output: [ - "✓ scaffolded loopat-ts/", - "✓ prisma schema with NextAuth tables", - "✓ tRPC root router", - "next steps: bun install && bun db:push", - ], - }, - { - kind: "artifact", - path: "prisma/schema.prisma", - lines: 162, - time: "11:30", - preview: - "model Loop {\n id String @id @default(cuid())\n name String\n archetype LoopArchetype @default(code)\n workdir String\n driverName String\n rfd Boolean @default(false)\n forkedFrom String?\n // ...\n}\n\nenum LoopArchetype { code research online context_refine design }", - }, - { - kind: "user", - text: "把 timeline event / focus / chat-mount 的 schema 也写完,然后 bun db:push 起 postgres", - time: "13:18", - }, - { - kind: "diff", - file: "prisma/schema.prisma", - time: "13:45", - lines: [ - { kind: "hunk", text: "@@ +103,30 @@ model Loop" }, - { kind: "add", text: "model TimelineEvent {" }, - { kind: "add", text: " id String @id @default(cuid())" }, - { kind: "add", text: " loopId String" }, - { kind: "add", text: " kind TimelineEventKind" }, - { kind: "add", text: " byName String" }, - { kind: "add", text: " fromVal String?" }, - { kind: "add", text: " toVal String?" }, - { kind: "add", text: " note String?" }, - { kind: "add", text: " loop Loop @relation(fields: [loopId], references: [id])" }, - { kind: "add", text: "}" }, - { kind: "add", text: "" }, - { kind: "add", text: "model ChatMount {" }, - { kind: "add", text: " loopId String" }, - { kind: "add", text: " channelId String" }, - { kind: "add", text: " upTo Int" }, - { kind: "add", text: " @@id([loopId, channelId])" }, - { kind: "add", text: "}" }, - ], - }, - { - kind: "command", - cmd: "./start-database.sh && bun db:push", - ok: true, - time: "14:02", - output: [ - "🐘 starting postgres on docker...", - "✓ container loopat-ts-postgres ready", - "Datasource \"db\": PostgreSQL database \"loopat-ts\"", - "🚀 Your database is now in sync with your Prisma schema. Done in 2.84s", - ], - }, - { - kind: "user", - text: "trpc routers 写了 loopRouter.list / .get / .create / .fork。WebSocket attach 走 src/server/ws,每个 loop 一个 channel,subscribe 收 timeline 增量", - time: "16:20", - }, - { - kind: "ai", - text: "attach 协议草稿:\n- ws topic: `/loop/<id>`\n- 客户端 sub 后立刻收 snapshot + 增量 event stream\n- driver-transfer 也是事件,所有 client 同步\n- close 是 send-only event,不影响 sub 状态", - time: "16:25", - }, - { - kind: "todo", - title: "本周 panlilu 这条线", - time: "23:01", - items: [ - { done: true, text: "scaffold + prisma schema" }, - { done: true, text: "bun db:push + seed mock data" }, - { done: false, text: "trpc routers (loop / focus / contact)" }, - { done: false, text: "ws attach 协议 + 多 client mirror demo" }, - { done: false, text: "Next.js dashboard 页面(list / detail)" }, - { done: false, text: "周末跟 simpx 同步两条线进展" }, - ], - }, -] - -const RESEARCH_OPENCODE_CHAT: ChatItem[] = [ - { - kind: "user", - text: "纯调研 —— 把 opencode 内部架构摸清,不一定要 fork。看完写到 knowledge/loopat/ 下", - time: "yesterday 11:00", - }, - { - kind: "ai", - text: - "opencode = monorepo(packages/server + packages/desktop + packages/cli)+ Tauri 桌面壳。核心 model 是 session(chat history + tool calls)和 project(workdir)。\n\n关键观察:\n- session 跟 project 是 1:N\n- 没 driver 概念,单人语义\n- attach 用 SSE 但单 subscriber", - time: "yesterday 11:08", - }, - { - kind: "command", - cmd: "git clone https://github.com/sst/opencode ~/workspace/opencode-fork --depth=1", - ok: true, - time: "yesterday 11:10", - output: [ - "Cloning into '~/workspace/opencode-fork'...", - "remote: Enumerating objects: 1284, done.", - "remote: Counting objects: 100% (1284/1284), done.", - "Receiving objects: 100% (1284/1284), 5.18 MiB | 4.7 MiB/s, done.", - "Resolving deltas: 100% (382/382), done.", - ], - }, - { - kind: "read", - path: "packages/server/src/session.ts", - startLine: 1, - total: 412, - time: "yesterday 11:18", - lines: [ - "export class Session {", - " id: string", - " projectId: string", - " messages: Message[]", - " // 没有 driver / rfd / multi-client subscriber 概念", - "}", - ], - }, - { - kind: "ai", - text: - "session 模型很轻,加 driver / rfd 字段不难。难的是 attach —— 现在的 SSE 只支持单 client,要改成 ws + 多 subscriber broadcast。\n\n继续读 cli + desktop 包,看用户视角的 'session' 怎么暴露。", - time: "yesterday 11:30", - }, - { - kind: "todo", - title: "调研 checklist", - time: "yesterday 12:00", - items: [ - { done: true, text: "读 server/src/session.ts" }, - { done: true, text: "读 server/src/project.ts" }, - { done: true, text: "扫 cli + desktop 包,看 session 怎么暴露给用户" }, - { done: true, text: "整理对照表(1001 概念 vs opencode 现状)" }, - { done: false, text: "看 v0.7 hooks 重构会不会影响我们的 fork" }, - { done: false, text: "估改造 attach 的工作量(1人)" }, - ], - }, - { - kind: "artifact", - path: "knowledge/loopat/opencode-architecture.md", - lines: 96, - time: "yesterday 12:14", - preview: - "# opencode 架构摘要\n\n## 核心 model\n- session = chat history + tool calls + working state\n- project = git repo / workdir,1:N session\n\n## 1001 想加的语义\n- driver / rfd → 加在 session metadata\n- focus → workspace-level 派生\n- chat-mount → session.contextSources 扩展\n- attach multi-client → SSE 改 ws", - }, - { - kind: "user", - text: "v0.7 release notes 我刚看了眼,hooks 系统重构了。fork 的话每次 rebase 可能都要重新对一遍 hooks 注册。注意", - time: "5h", - }, - { - kind: "ai", - text: "记到 inbox.md 了。也加进 spike-comparison.md 的 '风险' 段。", - time: "5h", - }, -] - -const RESEARCH_CLAUDE_CODE_CHAT: ChatItem[] = [ - { - kind: "user", - text: "claude code SDK 的 agent loop / tool dispatch 流程读一下,对比 opencode", - time: "2d 09:30", - }, - { - kind: "ai", - text: - "claude code 的核心是 hook 系统 + skill 系统 —— 不是把 agent 框死在某个 loop 模型里,而是通过 hooks 让用户在每个生命周期点(pre-tool / post-tool / on-stop)注入自定义行为。\n\n比 opencode 灵活,但**没有 multi-client attach** —— 它就是单进程 CLI。", - time: "2d 09:42", - }, - { - kind: "read", - path: "settings.json (claude code config)", - startLine: 1, - total: 32, - time: "2d 10:00", - lines: [ - "{", - ' "hooks": {', - ' "pre-tool": [{ "matcher": "Edit", "command": "echo $TOOL_INPUT > /tmp/last-edit.json" }],', - ' "on-stop": [{ "command": "git status --short | head -3" }]', - " },", - ' "skills": {', - ' "enabled": ["./skills/distill-to-knowledge"]', - " }", - "}", - ], - }, - { - kind: "ai", - text: - "skill 加载机制很优雅 — SKILL.md frontmatter 描述触发条件,AI 加载时只读元信息,需要时再 inline。\n\n这套对 1001 的 \`knowledge/skills/\` 子目录直接照抄就行。", - time: "2d 10:08", - }, - { - kind: "artifact", - path: "knowledge/loopat/claude-code-vs-opencode.md", - lines: 54, - time: "2d 10:18", - preview: - "# claude code vs opencode(对 1001 的启发)\n\n| 维度 | claude code | opencode |\n|---|---|---|\n| 部署形态 | CLI 单进程 | desktop + server |\n| multi-client attach | ✗ | SSE 单 subscriber |\n| 扩展机制 | hooks / skills | tools |\n| session 持久化 | 文件 jsonl | postgres |", - }, - { - kind: "user", - text: "结论:不 fork,但抄 skills 系统。回头给 conventions/ 加一条 'SKILL.md 怎么写'", - time: "2d 10:30", - }, -] - -const SITE_UPTIME_SPIKE_CHAT: ChatItem[] = [ - { - kind: "user", - text: - "[ops-bot] 🚨 loopat.ai 5xx 抖动告警\n\n- 检查窗口:09:35 – 09:42 (7min)\n- 5xx 总量:342 (baseline 8/min)\n- 受影响路径:/api/auth/callback (78%) · /api/loop (12%)\n- region: cn-shanghai\n- grafana: https://ops.loopat.ai/d/site-overview\n\n等人接 + 看是否需要 rollback。", - time: "09:42", - }, - { kind: "rfd", by: "ops-bot", time: "09:42" }, -] - -const DEMO_VIDEO_SCRIPT_CHAT: ChatItem[] = [ - { kind: "user", text: "HN show 视频先录第一版,2 分钟。帮我先列结构。", time: "08:30" }, - { - kind: "ai", - text: - "建议结构:\n\n0:00–0:15 — hook:'AI 工具像 Slack 频道,但工作不该像聊天'\n0:15–0:45 — 4 一级概念扫一遍(Loop / Focus / Chat / Context)\n0:45–1:30 — 真实场景:从 chat spawn loop → driver transfer → 沉淀 knowledge 一镜到底\n1:30–1:50 — c/s 协议 + multi-client attach 演示\n1:50–2:00 — call-to-action(loopat.ai · early access)", - time: "08:31", - }, - { kind: "user", text: "0:45-1:30 那段用今天我们做 prototype 这条 loop 当 demo —— self-referential", time: "08:35" }, - { kind: "ai", text: "👍 self-referential 是好钩子。要不要把 chat → loop spawn 那段录得稍长一些(30s+),因为 reviewer 第一次看到这个动作会愣两秒。", time: "08:36" }, - { - kind: "artifact", - path: "demo/script-v0.md", - lines: 42, - time: "09:02", - preview: - "# loopat 2-min demo · v0\n\n## Hook (0:00-0:15)\n> AI 工具像 Slack 频道,但工作不该像聊天。\n>\n> loopat 是一个让 AI 协作不再'频道化'的工具 —— 工作有 driver、有 context、有沉淀。", - }, - { kind: "user", text: "找 panlilu 帮录 attach demo 那段,他自建那条 ws 接好了正合适", time: "09:14" }, -] - -const ATTACH_SPEC_REVIEW_CHAT: ChatItem[] = [ - { - kind: "user", - text: - "[simpx] 把 attach spec 草稿单开 loop 让 panlilu review。我先睡了。\n\n关键问题:\n1. ws message envelope 格式(用 protobuf 还是纯 JSON?)\n2. reconnect 时 client 状态怎么 recover\n3. loop visibility check 放 ws auth 还是每条 message", - time: "yesterday 22:00", - }, - { kind: "rfd", by: "simpx", time: "yesterday 22:30" }, - { kind: "claim", by: "panlilu", time: "today 09:18" }, - { kind: "user", text: "[panlilu] 接了。先回 1 / 2 / 3:", time: "09:18" }, - { - kind: "ai", - text: - "好。我把 simpx 的 spec 草稿调出来,你边读边写:\n\n**spec/attach-v0** branch ready @ \`d8f3e2a\`", - time: "09:18", - }, - { - kind: "read", - path: "ATTACH-SPEC.md", - startLine: 1, - total: 48, - time: "09:19", - lines: [ - "# Attach 协议草稿", - "", - "## Topic", - "/loop/<id> — ws subscription", - "", - "## Events (server → client)", - "- snapshot — 完整 loop state", - "- message — 新 chat 增量", - "- timeline — driver-change / rfd / claim / fork", - "- tool-call — AI 调工具中间状态", - ], - }, - { - kind: "user", - text: - "[panlilu] 我的意见:\n1. 用纯 JSON,protobuf 增加部署复杂度,p1 先不引入\n2. recover 走 'event id since' query,server 拉时间窗内 events 重放\n3. visibility check 在 sub auth 时一次性做,每条 message 不重复检查(trust the topic)", - time: "09:42", - }, - { kind: "ai", text: "记下:JSON / event-id 增量 recover / topic-level auth。要不要写到 spec 里?", time: "09:43" }, - { - kind: "diff", - file: "ATTACH-SPEC.md", - time: "10:14", - lines: [ - { kind: "hunk", text: "@@ +25,15 @@ ## 待办" }, - { kind: "add", text: "## 决议(panlilu review @ 2026-05-10)" }, - { kind: "add", text: "" }, - { kind: "add", text: "1. **Envelope** — 纯 JSON。protobuf 推迟到 p1 之后" }, - { kind: "add", text: "2. **Recover** — client 带 lastEventId,server 重放窗内事件" }, - { kind: "add", text: "3. **Auth** — sub 时 workspace token + visibility check;每条 message 不重复" }, - ], - }, - { kind: "user", text: "[panlilu] 等 simpx 醒了 confirm 一下我直接 close。@coo 帮我 ping", time: "10:20" }, - { kind: "ai", text: "已 dm simpx。会议进 standup queue。", time: "10:20" }, -] - -const FEATURE_PRICING_SKETCH_CHAT: ChatItem[] = [ - { - kind: "user", - text: "猜个 loopat 的定价模型。先看 Linear / Notion / Slack 的对照", - time: "yesterday 14:20", - }, - { - kind: "ai", - text: - "拉了三家:\n\n| 工具 | 起步价 | 单位 | 主要 gating |\n|---|---|---|---|\n| Linear | $8/u/mo | seat | 私有 issue / SAML / API |\n| Notion | $10/u/mo | seat | 团队 workspace / version history |\n| Slack | $7.25/u/mo | seat | unlimited history / SSO / connect |\n\n都是 seat-based,不卖额度。", - time: "yesterday 14:35", - }, - { - kind: "artifact", - path: "pricing-sketch-v0.md", - lines: 26, - time: "yesterday 16:00", - preview: - "# loopat 定价(草稿 v0)\n\n## tier\n- **Free** — 5 人 workspace,1 active loop / 人,无 attach\n- **Team** — $10/u/mo,无限 loop,attach,agent 配额\n- **Enterprise** — 谈判,SSO/audit/private install", - }, - { kind: "user", text: "先放着,loop 还没跑通别想这事。Phase 3 之后再 revisit", time: "yesterday 16:05" }, -] - -const NAMING_BRAINSTORM_CHAT: ChatItem[] = [ - { kind: "user", text: "brand 名 brainstorm。约束:好读 / 好打 / 跟 loop 有关 / .ai 域名能注册", time: "yesterday 21:00" }, - { - kind: "ai", - text: - "拉了一批候选:\n\n- pit 系(pithub / pitops)—— 有 'pithub→pornhub' 切分歧义,否\n- 造词系(melode / klyma)—— 用户嫌怪\n- 1001 系(1001loop / 1001days)—— 累赘\n- 其他:loopey / looped / loopat / loopin\n\n推 \`loopey.ai\` —— 一眼读懂,slack 拼写感(-ey 后缀)。", - time: "yesterday 21:40", - }, - { - kind: "artifact", - path: "naming-decision-v1.md", - lines: 18, - time: "yesterday 22:00", - preview: - "# 1001 brand 名(决议)\n\n选 **loopey.ai**\n\n理由:唯一同时满足 .ai TLD / 一眼读懂 / loop 暗扣 / 不需解释。", - }, - { - kind: "user", - text: "今天再过一下,发现 loopat 内嵌 'pat' 很妙 —— pat 可以做 AI 反馈动作(给 AI 一个 pat)", - time: "today 01:14", - }, - { - kind: "ai", - text: - "你说服我了。loopat 比 loopey 强:\n- 同样满足 .ai / 好打 / loop 暗扣\n- 多一个 'pat' 隐藏词 —— UX 动词,比 thumbs-up 更具身\n- 'loop at AI' 短语解读虽不顺,但 brand 不靠这个\n\n更新 decision。", - time: "today 01:18", - }, - { - kind: "artifact", - path: "naming-decision-v2.md", - lines: 22, - time: "today 01:30", - preview: - "# 1001 brand 名(决议 v2,覆盖 v1)\n\n选 **loopat.ai**\n\n核心:\n1. loop(项目核心)\n2. pat(隐藏词,未来产品 UX 动词)\n\n放弃 'loop at AI' 短语解读 —— 不是英语 idiom。", - }, - { kind: "user", text: "logo emoji 用 🧶 毛线团 —— loop 的有机/暖感呈现,跟软品牌内核一致", time: "today 01:45" }, - { kind: "ai", text: "已记。准备 close 这条 loop,结论沉淀进 knowledge/loopat/naming.md。", time: "today 01:50" }, - { - kind: "command", - cmd: "git mv naming-decision-v2.md ../knowledge/loopat/naming.md && git commit -m 'naming: archive brainstorm, sediment to loopat/naming.md'", - ok: true, - time: "today 02:00", - output: ["[main 7c4a812] naming: archive brainstorm, sediment to loopat/naming.md", " 1 file changed, 22 insertions(+)"], - }, -] - -const PROTOTYPE_HIFI_FORK_TEST_CHAT: ChatItem[] = [ - { - kind: "user", - text: - "[panlilu] fork 一份 prototype 试试 React 改写 —— 不是要替代 simpx 这版,只是验证 'Solid 是否值得保留 vs React 一致性' 这个问题", - time: "04:30", - }, - { - kind: "ai", - text: - "已 fork。注意几个 react 跟 solid 的语义差异:\n- props 在 react 默认 destructure(解开就行),solid 解开会破坏响应性\n- createSignal vs useState:solid 不重新执行组件\n- For 用 .map 替代\n\n这次 fork 主要为评估改写成本,不要做完整 port。", - time: "04:32", - }, - { kind: "user", text: "先把 App.tsx + state.ts + chat.tsx 三个移植了,看看代码量差", time: "04:45" }, - { - kind: "command", - cmd: "wc -l src/App.tsx src/state.ts src/pages/chat.tsx (solid version)", - ok: true, - time: "04:46", - output: [ - " 168 src/App.tsx", - " 1064 src/state.ts", - " 370 src/pages/chat.tsx", - " 1602 total", - ], - }, - { kind: "user", text: "react 版我估了一下大概会 +20% 行数,主要是 useEffect / useMemo 显式声明。值得吗?", time: "06:18" }, - { - kind: "ai", - text: - "短期:值得性低(codebase 才 1.6k 行,react 一致性收益小)。\n长期:如果未来要写 SSR / 接 react 生态库(react-flow 等),solid 会卡。\n\n我建议先记结论 + 不动。等 phase 3 看 SSR 需求再 revisit。", - time: "06:21", - }, - { - kind: "artifact", - path: "fork-eval.md", - lines: 18, - time: "07:00", - preview: - "# Fork eval: solid → react 改写值不值得?\n\n## 当前\n- codebase 1.6k 行 solid\n- 改 react 估计 +20% 行数 (react useEffect/useMemo 显式)\n\n## 决议\n短期不动。phase 3 SSR 需求评估时再看。", - }, -] - -export const chats: Record<string, ChatItem[]> = { - "prototype-hifi": PROTOTYPE_HIFI_CHAT, - "loopat-runtime-spike": LOOPAT_RUNTIME_SPIKE_CHAT, - "loopat-ts-mvp": LOOPAT_TS_MVP_CHAT, - "research-opencode": RESEARCH_OPENCODE_CHAT, - "research-claude-code": RESEARCH_CLAUDE_CODE_CHAT, - "site-uptime-spike": SITE_UPTIME_SPIKE_CHAT, - "demo-video-script": DEMO_VIDEO_SCRIPT_CHAT, - "attach-spec-review": ATTACH_SPEC_REVIEW_CHAT, - "feature-pricing-sketch": FEATURE_PRICING_SKETCH_CHAT, - "naming-brainstorm": NAMING_BRAINSTORM_CHAT, - "prototype-hifi-fork-test": PROTOTYPE_HIFI_FORK_TEST_CHAT, -} - -// ============================================================================ -// notes/focus.md — the only "real state" Focus has. -// ============================================================================ - -export type FocusFile = { - pinned: string[] - listed: string[] -} - -export const [focusFile, setFocusFile] = createSignal<FocusFile>({ - pinned: ["产品侧高保真原型", "可自举的MVP"], - listed: ["初版上线"], -}) - -// ============================================================================ -// notes/inbox.md — team scratch prose. -// ============================================================================ - -export const [inboxItems, setInboxItems] = createSignal<string[]>([ - "看了下 sst/opencode v0.7 release notes,有几个 hook 点变了,回头确认 fork 还能不能 rebase", - "tweetdeck 上看到一个聊 'AI org' 的 thread,截图存了 personal/inbox/", - "@panlilu next-auth beta 的 session expire callback 跟 5.0 final 行为不一样,注意", - "把 1001-mvp.md §3 重写一版,加 c/s 协议的边界", - "https://github.com/sst/opencode/discussions/482 有人问怎么加 attach,回头看下他们怎么想的", - "loopat.ai 域名转 cloudflare 的事还没办,等 panlilu 那边 deployment 决定", - "demo 视频先录第一版(2 分钟),上 hn show 用", - "周三跟 panlilu 把两条 spike 的取舍讨论 closed,下周二之前定方向", -]) diff --git a/phase1-prototype/tsconfig.app.json b/phase1-prototype/tsconfig.app.json deleted file mode 100644 index e8a00e4f..00000000 --- a/phase1-prototype/tsconfig.app.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "es2023", - "module": "esnext", - "lib": ["ES2023", "DOM"], - "types": ["vite/client"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "preserve", - "jsxImportSource": "solid-js", - - /* Linting */ - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"] -} diff --git a/phase1-prototype/tsconfig.json b/phase1-prototype/tsconfig.json deleted file mode 100644 index 1ffef600..00000000 --- a/phase1-prototype/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/phase1-prototype/tsconfig.node.json b/phase1-prototype/tsconfig.node.json deleted file mode 100644 index d3c52ea6..00000000 --- a/phase1-prototype/tsconfig.node.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "es2023", - "lib": ["ES2023"], - "module": "esnext", - "types": ["node"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["vite.config.ts"] -} diff --git a/phase1-prototype/vite.config.ts b/phase1-prototype/vite.config.ts deleted file mode 100644 index 30a54713..00000000 --- a/phase1-prototype/vite.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vite" -import solid from "vite-plugin-solid" -import tailwindcss from "@tailwindcss/vite" - -export default defineConfig({ - plugins: [solid(), tailwindcss()], - server: { port: 5173 }, -}) diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..b2f3551a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,59 @@ +import { defineConfig } from "@playwright/test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createServer } from "node:net"; + +// ── pick free ports (far from dev defaults 5173/10001) ── +function tryPort(port: number): boolean { + try { + const s = createServer(); + s.listen(port, "127.0.0.1"); + s.close(); + return true; + } catch { + return false; + } +} + +function pickPorts(): { testServerPort: number; vitePort: number } { + // Start scanning from a range well away from common dev ports. + for (let p = 20001; p < 21000; p += 2) { + if (tryPort(p) && tryPort(p + 1)) { + return { testServerPort: p, vitePort: p + 1 }; + } + } + throw new Error("no free port pair found in 20001–21000"); +} + +const { testServerPort, vitePort } = pickPorts(); + +// Temp dir for test LOOPAT_HOME — isolated from dev ~/.loopat. +const loopatHome = mkdtempSync(join(tmpdir(), "loopat-e2e-")); + +writeFileSync( + join(import.meta.dirname, "e2e/.test-meta.json"), + JSON.stringify({ loopatHome, testServerPort, vitePort }), +); + +export default defineConfig({ + testDir: import.meta.dirname + "/e2e", + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + globalSetup: "./e2e/globalSetup.ts", + globalTeardown: "./e2e/globalTeardown.ts", + use: { + baseURL: `http://127.0.0.1:${vitePort}`, + trace: "on-first-retry", + screenshot: "only-on-failure", + storageState: "e2e/.auth.json", + }, + // Vite dev server — proxies /api → test backend on the picked free port. + // The `--` separator passes --port through to Vite, not Bun. + webServer: { + command: + `env ENV=test HOST=127.0.0.1 PORT=${testServerPort} bun --cwd=${import.meta.dirname}/web run dev -- --port ${vitePort}`, + port: vitePort, + reuseExistingServer: false, + }, +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 00000000..6877017d --- /dev/null +++ b/public/index.html @@ -0,0 +1,783 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>loopat — self-hosted AI workspace</title> +<meta name="description" content="Self-hosted AI workspace built around context management. Loop = context + AI + workdir, sandboxed per-loop with bubblewrap."> +<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🧶</text></svg>"> +<style> + :root { + --bg: #fbf9f4; + --bg-card: #ffffff; + --bg-card-hover: #f8f7f3; + --border: #d8dde4; + --border-light: #eaecf1; + --text: #1c2733; + --text-muted: #5b6776; + --text-dim: #7e8794; + --blue: #3b5bdb; + --gold: #d4a017; + --purple: #9333ea; + --green: #16a34a; + --orange: #d97706; + --teal: #0d9488; + --amber: #f59e0b; + --radius: 14px; + --max-w: 1120px; + } + + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + + html { + scroll-behavior: smooth; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Helvetica Neue", system-ui, sans-serif; + font-size: 16px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + } + + body { min-height: 100vh; } + + /* ── Nav ──────────────────────────────── */ + nav { + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: blur(12px); + background: rgba(251, 249, 244, 0.85); + border-bottom: 1px solid var(--border); + } + .nav-inner { + max-width: var(--max-w); + margin: 0 auto; + padding: 0 24px; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + } + .nav-brand { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.25rem; + font-weight: 700; + color: var(--text); + text-decoration: none; + letter-spacing: -0.3px; + } + .nav-brand .yarn { font-size: 1.5rem; } + .nav-links { display: flex; gap: 24px; align-items: center; } + .nav-links a { + color: var(--text-muted); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + transition: color 0.2s; + } + .nav-links a:hover { color: var(--text); } + .nav-gh { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 8px; + border: 1px solid var(--border); + color: var(--text-muted); + text-decoration: none; + font-size: 0.8125rem; + font-weight: 500; + transition: all 0.2s; + } + .nav-gh:hover { border-color: var(--text-dim); color: var(--text); background: rgba(0,0,0,0.02); } + .nav-gh svg { width: 16px; height: 16px; } + + .lang-toggle { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text-muted); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + text-decoration: none; + } + .lang-toggle:hover { border-color: var(--text-dim); color: var(--text); } + + /* ── Hero ─────────────────────────────── */ + .hero { + max-width: var(--max-w); + margin: 0 auto; + padding: 100px 24px 80px; + text-align: center; + } + .hero-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + border-radius: 999px; + border: 1px solid var(--border); + font-size: 0.8125rem; + color: var(--text-muted); + margin-bottom: 32px; + background: rgba(255,255,255,0.7); + } + .hero-badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); } + + .hero h1 { + font-size: clamp(2.75rem, 6vw, 4.5rem); + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1.1; + margin-bottom: 16px; + color: var(--text); + } + .hero h1 .gradient { + background: linear-gradient(135deg, var(--blue) 0%, var(--purple) 25%, var(--orange) 40%, var(--gold) 55%, var(--green) 70%, var(--teal) 85%, var(--blue) 100%); + background-size: 200% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: brand-shift 5s linear infinite; + } + @keyframes brand-shift { + to { background-position: 200% center; } + } + + .tagline { + font-size: 1rem; + color: var(--text-dim); + margin-bottom: 14px; + letter-spacing: 0.2px; + } + .tagline em { + font-style: normal; + font-weight: 600; + background: linear-gradient(135deg, var(--blue), var(--purple), var(--orange), var(--gold), var(--green), var(--teal), var(--blue)); + background-size: 200% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: brand-shift 5s linear infinite; + } + + .hero .formula { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 20px; + border-radius: 999px; + background: var(--bg-card); + border: 1px solid var(--border); + font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace; + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: 28px; + } + .hero .formula .kw { color: var(--orange); font-weight: 600; } + .hero .formula .co { color: var(--teal); } + + .hero p { + font-size: 1.125rem; + color: var(--text-muted); + max-width: 680px; + margin: 0 auto 36px; + line-height: 1.75; + } + .hero-actions { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; } + .btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 28px; + border-radius: 10px; + font-size: 0.9375rem; + font-weight: 600; + text-decoration: none; + transition: all 0.2s; + cursor: pointer; + } + .btn-primary { + background: #1c2733; + color: #fbf9f4; + border: none; + } + .btn-primary:hover { background: #2d3a4a; transform: translateY(-1px); box-shadow: 0 8px 30px rgba(28, 39, 51, 0.2); } + .btn-secondary { + background: transparent; + color: var(--text); + border: 1px solid var(--border); + } + .btn-secondary:hover { border-color: var(--text-dim); background: rgba(0,0,0,0.02); } + + /* ── Section Shared ───────────────────── */ + section { padding: 80px 24px; } + .section-inner { max-width: var(--max-w); margin: 0 auto; } + .section-label { + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--orange); + margin-bottom: 12px; + } + .section-title { + font-size: clamp(1.75rem, 3vw, 2.5rem); + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 16px; + line-height: 1.2; + } + .section-desc { + font-size: 1.0625rem; + color: var(--text-muted); + max-width: 600px; + line-height: 1.7; + } + + /* ── Features ─────────────────────────── */ + #features .section-inner { text-align: center; } + #features .section-desc { margin: 0 auto 56px; } + .features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + } + .feature-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px 28px; + text-align: left; + transition: box-shadow 0.3s, transform 0.2s; + } + .feature-card:hover { + box-shadow: 0 4px 24px rgba(0,0,0,0.06); + transform: translateY(-2px); + } + .feature-icon { + width: 42px; + height: 42px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.35rem; + margin-bottom: 18px; + background: #eef2f7; + color: var(--blue); + } + .feature-card:nth-child(2) .feature-icon { background: #fcefb6; color: var(--gold); } + .feature-card:nth-child(3) .feature-icon { background: #f3e3fb; color: var(--purple); } + .feature-card:nth-child(4) .feature-icon { background: #e3f2eb; color: var(--green); } + .feature-card:nth-child(5) .feature-icon { background: #ffedd5; color: var(--orange); } + .feature-card:nth-child(6) .feature-icon { background: #ccfbf1; color: var(--teal); } + .feature-card h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 8px; + color: var(--text); + } + .feature-card p { + font-size: 0.875rem; + color: var(--text-dim); + line-height: 1.65; + } + + /* ── Architecture ─────────────────────── */ + #architecture .section-inner { text-align: center; } + #architecture .section-desc { margin: 0 auto 56px; } + .arch-diagram { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 48px; + } + .arch-box { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px 32px; + text-align: center; + min-width: 140px; + box-shadow: 0 2px 12px rgba(0,0,0,0.04); + } + .arch-box .emoji { font-size: 2rem; margin-bottom: 10px; } + .arch-box h4 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 4px; color: var(--text); } + .arch-box p { font-size: 0.8125rem; color: var(--text-dim); } + .arch-plus { font-size: 1.5rem; color: var(--orange); font-weight: 300; } + .arch-equals { font-size: 1.5rem; color: var(--text-dim); font-weight: 300; } + .arch-result { + border-color: var(--orange); + box-shadow: 0 0 40px rgba(217, 119, 6, 0.08); + } + + .sandbox-layers { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1px; + background: var(--border); + border-radius: var(--radius); + overflow: hidden; + border: 1px solid var(--border); + margin-top: 32px; + } + .layer-card { + background: var(--bg-card); + padding: 28px 20px; + text-align: center; + } + .layer-card .layer-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(13, 148, 136, 0.1); + color: var(--teal); + font-size: 0.8125rem; + font-weight: 700; + margin-bottom: 12px; + } + .layer-card h4 { font-size: 0.875rem; font-weight: 600; margin-bottom: 4px; } + .layer-card p { font-size: 0.75rem; color: var(--text-dim); line-height: 1.5; } + + /* ── Principles ──────────────────────── */ + #principles .section-inner { text-align: center; } + #principles .section-desc { margin: 0 auto 48px; } + .principles-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + } + .principle-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px 28px; + text-align: left; + } + .principle-card .num { + font-size: 2rem; + font-weight: 800; + color: var(--orange); + opacity: 0.25; + line-height: 1; + margin-bottom: 12px; + } + .principle-card h3 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 8px; } + .principle-card p { font-size: 0.875rem; color: var(--text-dim); line-height: 1.6; } + + /* ── Quick Start ──────────────────────── */ + #quickstart .section-inner { text-align: center; } + #quickstart .section-desc { margin: 0 auto 40px; } + .code-block-wrapper { + max-width: 640px; + margin: 0 auto; + position: relative; + } + .code-block { + background: #f3f2ef; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px 32px; + text-align: left; + font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace; + font-size: 0.8125rem; + line-height: 1.8; + overflow-x: auto; + position: relative; + white-space: pre; + color: var(--text); + } + .code-block .c { color: var(--text-dim); } + .code-block .p { color: var(--blue); font-weight: 500; } + .code-block .s { color: var(--green); } + .code-block .v { color: var(--orange); } + .copy-btn { + position: absolute; + top: 12px; + right: 12px; + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text-muted); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + } + .copy-btn:hover { border-color: var(--text-dim); color: var(--text); } + .copy-btn.copied { border-color: var(--green); color: var(--green); background: #e3f2eb; } + + /* ── Compare Table ──────────────────────── */ + #compare .section-inner { text-align: center; } + #compare .section-desc { margin: 0 auto 48px; } + .table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + } + .table-wrap table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; + min-width: 720px; + } + .table-wrap th { + text-align: left; + padding: 12px 16px; + background: #eef2f7; + font-weight: 600; + color: var(--text-muted); + font-size: 0.75rem; + letter-spacing: 0.6px; + border-bottom: 1px solid var(--border); + } + .table-wrap th:last-child { background: #fef3c7; color: #8a6418; } + .table-wrap td { + padding: 10px 16px; + color: var(--text); + border-bottom: 1px solid var(--border-light); + } + .table-wrap tr:last-child td { border-bottom: none; } + .table-wrap td:last-child { background: #fffbef; color: #5c4308; font-weight: 500; } + .table-wrap .ch { color: var(--green); font-weight: 600; } + .table-wrap .cw { color: var(--red); } + + /* ── Footer ───────────────────────────── */ + footer { + border-top: 1px solid var(--border); + padding: 40px 24px; + } + .footer-inner { + max-width: var(--max-w); + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + } + .footer-text { font-size: 0.8125rem; color: var(--text-dim); } + .footer-text a { color: var(--text-muted); text-decoration: none; } + .footer-text a:hover { color: var(--text); } + + /* ── Responsive ───────────────────────── */ + @media (max-width: 768px) { + .features-grid { grid-template-columns: 1fr; } + .sandbox-layers { grid-template-columns: repeat(2, 1fr); } + .principles-grid { grid-template-columns: 1fr; } + .arch-diagram { flex-direction: column; } + .arch-plus, .arch-equals { transform: rotate(90deg); } + .hero { padding: 60px 20px 48px; } + section { padding: 56px 20px; } + } + @media (max-width: 480px) { + .sandbox-layers { grid-template-columns: 1fr; } + .hero-actions { flex-direction: column; align-items: center; } + .btn { width: 100%; justify-content: center; } + .nav-links { display: none; } + } +</style> +</head> +<body> + +<!-- Nav --> +<nav> + <div class="nav-inner"> + <a href="#" class="nav-brand"> + <span class="yarn">🧶</span> + <span>loopat</span> + </a> + <div class="nav-links"> + <a href="#features">Features</a> + <a href="#architecture">Architecture</a> + <a href="#compare">Compare</a> + <a href="#quickstart">Quick Start</a> + <a class="nav-gh" href="https://github.com/simpx/loopat" target="_blank" rel="noopener"> + <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg> + GitHub + </a> + <a href="index.zh.html" class="lang-toggle">中文</a> + </div> + </div> +</nav> + +<!-- Hero --> +<section class="hero"> + <div class="hero-badge"> + <span class="dot"></span> Apache 2.0 — open source + </div> + <h1> + <span class="gradient">loopat</span> + </h1> + <div class="tagline"><em>loop at</em> context, distill into knowledge</div> + <div class="formula"> + <span class="kw">Loop</span> + <span>=</span> + <span>context</span> + <span>+</span> + <span class="co">AI</span> + <span>+</span> + <span>workdir</span> + </div> + <p> + A self-hosted AI workspace built around context management &mdash; works solo, scales to teams. What makes loopat different is the <strong>context architecture</strong>: chat, code, memory, and knowledge interlock so context doesn't get lost across sessions or teammates. + </p> + <div class="hero-actions"> + <a href="#quickstart" class="btn btn-primary">Get Started</a> + <a href="https://github.com/simpx/loopat" class="btn btn-secondary" target="_blank" rel="noopener"> + <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg> + View on GitHub + </a> + </div> +</section> + +<!-- Features --> +<section id="features"> + <div class="section-inner"> + <div class="section-label">Features</div> + <h2 class="section-title">End-to-end context management<br>for humans and AI</h2> + <p class="section-desc"> + When humans collaborate with AI, three things only humans can bring: <strong>drive</strong>, <strong>attention</strong>, and <strong>entropy reduction</strong>. loopat builds these in as first-class concepts &mdash; Loop, Focus, and Context &mdash; with Chat coordinating the team on the sync axis. + </p> + <div class="features-grid"> + <div class="feature-card"> + <div class="feature-icon">🧶</div> + <h3>Reproducible Loops</h3> + <p>Every loop runs in its own sandbox with a versioned toolchain and pinned credential vault. Same state tomorrow, different machine.</p> + </div> + <div class="feature-card"> + <div class="feature-icon">💬</div> + <h3>Collaborative Chat</h3> + <p>WebSocket-powered real-time chat with Claude. Multiple browser tabs can share the same loop session simultaneously.</p> + </div> + <div class="feature-card"> + <div class="feature-icon">📋</div> + <h3>Kanban Boards</h3> + <p>Built-in Kanban with drag-and-drop, real-time sync across subscribers. Manage tasks alongside your AI agent.</p> + </div> + <div class="feature-card"> + <div class="feature-icon">💻</div> + <h3>Web Terminal</h3> + <p>Full PTY terminal inside the sandbox. Shared across loop subscribers — everyone sees the same bash session.</p> + </div> + <div class="feature-card"> + <div class="feature-icon">📁</div> + <h3>File Tree &amp; Editor</h3> + <p>Browse and edit files inside the sandbox from the web UI. Git integration with commit and log views.</p> + </div> + <div class="feature-card"> + <div class="feature-icon">🧠</div> + <h3>Persistent Memory</h3> + <p>Two-tier memory system &mdash; personal (<code>personal/memory</code>) and team-shared (<code>context/notes/memory</code>). Auto-recalled across sessions so context is never lost.</p> + </div> + </div> + </div> +</section> + +<!-- Architecture --> +<section id="architecture"> + <div class="section-inner"> + <div class="section-label">Architecture</div> + <h2 class="section-title">One loop, one sandbox</h2> + <p class="section-desc"> + Every loop bundles its own context, AI agent, and working directory inside a Linux mount namespace. Portable, reproducible, and secure by default. + </p> + <div class="arch-diagram"> + <div class="arch-box"> + <div class="emoji">📚</div> + <h4>Context</h4> + <p>Knowledge + Notes + Memory</p> + </div> + <span class="arch-plus">+</span> + <div class="arch-box"> + <div class="emoji">🤖</div> + <h4>AI Agent</h4> + <p>Claude CLI in bwrap</p> + </div> + <span class="arch-plus">+</span> + <div class="arch-box"> + <div class="emoji">📂</div> + <h4>Workdir</h4> + <p>Git worktree per loop</p> + </div> + <span class="arch-equals">=</span> + <div class="arch-box arch-result"> + <div class="emoji">🧶</div> + <h4>Sandboxed Loop</h4> + <p>Isolated &amp; reproducible</p> + </div> + </div> + <div class="sandbox-layers"> + <div class="layer-card"> + <div class="layer-num">1</div> + <h4>URL Routing</h4> + <p>Hono router binds requests to loop IDs via closures</p> + </div> + <div class="layer-card"> + <div class="layer-num">2</div> + <h4>Map Partitioning</h4> + <p>Per-loop session maps prevent cross-loop broadcast</p> + </div> + <div class="layer-card"> + <div class="layer-num">3</div> + <h4>Process Isolation</h4> + <p>Each loop spawns independent Claude CLI subprocess</p> + </div> + <div class="layer-card"> + <div class="layer-num">4</div> + <h4>Mount Namespace</h4> + <p>Kernel-enforced filesystem isolation via bubblewrap</p> + </div> + </div> + </div> +</section> + +<!-- Compare --> +<section id="compare"> + <div class="section-inner"> + <div class="section-label">How It Compares</div> + <h2 class="section-title">Works solo, scales to teams</h2> + <p class="section-desc"> + Same workspace whether you're alone or onboarding teammates. Solo, it's a personal AI workspace; with a team, shared knowledge and notes repos sync across members. + </p> + <div class="table-wrap"> + <table> + <thead> + <tr> + <th></th><th>Claude Code</th><th>Cursor</th><th>opencode</th><th>Codex</th><th>loopat</th> + </tr> + </thead> + <tbody> + <tr> + <td>Form factor</td><td>CLI</td><td>IDE</td><td>TUI</td><td>Web (hosted)</td><td>Web (self-hosted)</td> + </tr> + <tr> + <td>License</td><td>proprietary</td><td>proprietary</td><td>MIT</td><td>proprietary</td><td>Apache 2.0</td> + </tr> + <tr> + <td>Team IM integration</td><td>manual paste</td><td>manual paste</td><td>manual paste</td><td>manual paste</td><td><span class="ch">built-in</span></td> + </tr> + <tr> + <td>Shared team knowledge</td><td>individual config</td><td>individual config</td><td>individual config</td><td>individual config</td><td><span class="ch">git-synced</span></td> + </tr> + <tr> + <td>Per-session sandbox</td><td>process-level</td><td>process-level</td><td>process-level</td><td>managed</td><td><span class="ch">bwrap + Docker</span></td> + </tr> + <tr> + <td>Credential isolation</td><td>shared env</td><td>shared sub</td><td>shared env</td><td>acct-managed</td><td><span class="ch">per-loop vault</span></td> + </tr> + <tr> + <td>Data location</td><td>local files</td><td>cloud</td><td>local files</td><td>OpenAI servers</td><td>git repos you control</td> + </tr> + <tr> + <td>Agent engine</td><td>Anthropic</td><td>multi-model</td><td>pluggable</td><td>OpenAI</td><td>Claude Agent SDK</td> + </tr> + </tbody> + </table> + </div> + </div> +</section> + +<!-- Design Principles --> +<section id="principles"> + <div class="section-inner"> + <div class="section-label" style="text-align:center">Design Principles</div> + <h2 class="section-title" style="text-align:center">Filesystem-first, portable, transparent</h2> + <p class="section-desc" style="text-align:center;margin:0 auto 48px;"> + No database. Everything is a file. rsync your workspace to another machine and pick up exactly where you left off. + </p> + <div class="principles-grid"> + <div class="principle-card"> + <div class="num">01</div> + <h3>Sandbox = Directory</h3> + <p>Sandbox permissions arenʼt hidden in config files. Theyʼre derived from the physical structure of <code>loops/&lt;id&gt;/</code> — run <code>ls -laR</code> to see the full access scope.</p> + </div> + <div class="principle-card"> + <div class="num">02</div> + <h3>Machine-Independent</h3> + <p>rsync <code>$LOOPAT_HOME</code> to any machine, restart the server, and everything works — chat history, sessions, memory, sandbox views. All paths are virtual.</p> + </div> + <div class="principle-card"> + <div class="num">03</div> + <h3>Self-hosted, data you own</h3> + <p>All artifacts live in plain git repos you fully control; vault secrets are git-crypt encrypted. BYO API key &mdash; nothing leaves your machine except the model API call itself.</p> + </div> + </div> + </div> +</section> + +<!-- Quick Start --> +<section id="quickstart"> + <div class="section-inner"> + <div class="section-label">Quick Start</div> + <h2 class="section-title">Up and running in seconds</h2> + <p class="section-desc"> + One command to start. Works on any Linux machine. macOS via Docker. + </p> + <div class="code-block-wrapper"> + <button class="copy-btn" onclick="copyCode(this)">Copy</button> + <div class="code-block"><span class="c"># 1. Install system dependencies</span> +<span class="p">sudo apt install</span> bubblewrap openssh-client mise + +<span class="c"># 2. Install Bun</span> +<span class="p">curl -fsSL</span> https://bun.sh/install <span class="p">|</span> bash + +<span class="c"># 3. Clone &amp; start</span> +<span class="p">git clone</span> git@github.com:simpx/loopat.git +<span class="p">cd</span> loopat +<span class="p">bun run dev</span> + +<span class="c"># Open http://localhost:10001</span></div> + </div> + </div> +</section> + +<!-- Footer --> +<footer> + <div class="footer-inner"> + <div class="footer-text"> + <a href="https://github.com/simpx/loopat" target="_blank" rel="noopener">loopat</a> — self-hosted AI workspace built around context management. + </div> + <div class="footer-text"> + Built with <a href="https://bun.sh" target="_blank" rel="noopener">Bun</a> · + <a href="https://hono.dev" target="_blank" rel="noopener">Hono</a> · + <a href="https://react.dev" target="_blank" rel="noopener">React</a> · + <a href="https://github.com/anthropics/claude-agent-sdk" target="_blank" rel="noopener">Claude Agent SDK</a> + </div> + </div> +</footer> + +<script> + function copyCode(btn) { + const code = btn.nextElementSibling.innerText; + navigator.clipboard.writeText(code).then(() => { + btn.textContent = 'Copied!'; + btn.classList.add('copied'); + setTimeout(() => { + btn.textContent = 'Copy'; + btn.classList.remove('copied'); + }, 2000); + }); + } +</script> + +</body> +</html> diff --git a/public/index.zh.html b/public/index.zh.html new file mode 100644 index 00000000..ad1b1114 --- /dev/null +++ b/public/index.zh.html @@ -0,0 +1,783 @@ +<!DOCTYPE html> +<html lang="zh-CN"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>loopat — 自托管的 AI 协作工作台</title> +<meta name="description" content="围绕上下文管理构建的自托管 AI 工作台。Loop = 上下文 + AI + 工作目录,每个循环在 bubblewrap 沙箱中隔离运行。"> +<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🧶</text></svg>"> +<style> + :root { + --bg: #fbf9f4; + --bg-card: #ffffff; + --bg-card-hover: #f8f7f3; + --border: #d8dde4; + --border-light: #eaecf1; + --text: #1c2733; + --text-muted: #5b6776; + --text-dim: #7e8794; + --blue: #3b5bdb; + --gold: #d4a017; + --purple: #9333ea; + --green: #16a34a; + --orange: #d97706; + --teal: #0d9488; + --amber: #f59e0b; + --radius: 14px; + --max-w: 1120px; + } + + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + + html { + scroll-behavior: smooth; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Helvetica Neue", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif; + font-size: 16px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + } + + body { min-height: 100vh; } + + /* ── Nav ──────────────────────────────── */ + nav { + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: blur(12px); + background: rgba(251, 249, 244, 0.85); + border-bottom: 1px solid var(--border); + } + .nav-inner { + max-width: var(--max-w); + margin: 0 auto; + padding: 0 24px; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + } + .nav-brand { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.25rem; + font-weight: 700; + color: var(--text); + text-decoration: none; + letter-spacing: -0.3px; + } + .nav-brand .yarn { font-size: 1.5rem; } + .nav-links { display: flex; gap: 24px; align-items: center; } + .nav-links a { + color: var(--text-muted); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + transition: color 0.2s; + } + .nav-links a:hover { color: var(--text); } + .nav-gh { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 8px; + border: 1px solid var(--border); + color: var(--text-muted); + text-decoration: none; + font-size: 0.8125rem; + font-weight: 500; + transition: all 0.2s; + } + .nav-gh:hover { border-color: var(--text-dim); color: var(--text); background: rgba(0,0,0,0.02); } + .nav-gh svg { width: 16px; height: 16px; } + + .lang-toggle { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text-muted); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + margin-left: 8px; + } + .lang-toggle:hover { border-color: var(--text-dim); color: var(--text); } + + /* ── Hero ─────────────────────────────── */ + .hero { + max-width: var(--max-w); + margin: 0 auto; + padding: 100px 24px 80px; + text-align: center; + } + .hero-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + border-radius: 999px; + border: 1px solid var(--border); + font-size: 0.8125rem; + color: var(--text-muted); + margin-bottom: 32px; + background: rgba(255,255,255,0.7); + } + .hero-badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); } + + .hero h1 { + font-size: clamp(2.75rem, 6vw, 4.5rem); + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1.1; + margin-bottom: 16px; + color: var(--text); + } + .hero h1 .gradient { + background: linear-gradient(135deg, var(--blue) 0%, var(--purple) 25%, var(--orange) 40%, var(--gold) 55%, var(--green) 70%, var(--teal) 85%, var(--blue) 100%); + background-size: 200% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: brand-shift 5s linear infinite; + } + @keyframes brand-shift { + to { background-position: 200% center; } + } + + .tagline { + font-size: 1rem; + color: var(--text-dim); + margin-bottom: 14px; + letter-spacing: 0.2px; + } + .tagline em { + font-style: normal; + font-weight: 600; + background: linear-gradient(135deg, var(--blue), var(--purple), var(--orange), var(--gold), var(--green), var(--teal), var(--blue)); + background-size: 200% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: brand-shift 5s linear infinite; + } + + .hero .formula { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 20px; + border-radius: 999px; + background: var(--bg-card); + border: 1px solid var(--border); + font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace; + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: 28px; + } + .hero .formula .kw { color: var(--orange); font-weight: 600; } + .hero .formula .co { color: var(--teal); } + + .hero p { + font-size: 1.125rem; + color: var(--text-muted); + max-width: 680px; + margin: 0 auto 36px; + line-height: 1.85; + } + .hero-actions { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; } + .btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 28px; + border-radius: 10px; + font-size: 0.9375rem; + font-weight: 600; + text-decoration: none; + transition: all 0.2s; + cursor: pointer; + } + .btn-primary { + background: #1c2733; + color: #fbf9f4; + border: none; + } + .btn-primary:hover { background: #2d3a4a; transform: translateY(-1px); box-shadow: 0 8px 30px rgba(28, 39, 51, 0.2); } + .btn-secondary { + background: transparent; + color: var(--text); + border: 1px solid var(--border); + } + .btn-secondary:hover { border-color: var(--text-dim); background: rgba(0,0,0,0.02); } + + /* ── Section Shared ───────────────────── */ + section { padding: 80px 24px; } + .section-inner { max-width: var(--max-w); margin: 0 auto; } + .section-label { + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--orange); + margin-bottom: 12px; + } + .section-title { + font-size: clamp(1.75rem, 3vw, 2.5rem); + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 16px; + line-height: 1.2; + } + .section-desc { + font-size: 1.0625rem; + color: var(--text-muted); + max-width: 600px; + line-height: 1.85; + } + + /* ── Features ─────────────────────────── */ + #features .section-inner { text-align: center; } + #features .section-desc { margin: 0 auto 56px; } + .features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + } + .feature-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px 28px; + text-align: left; + transition: box-shadow 0.3s, transform 0.2s; + } + .feature-card:hover { + box-shadow: 0 4px 24px rgba(0,0,0,0.06); + transform: translateY(-2px); + } + .feature-icon { + width: 42px; + height: 42px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.35rem; + margin-bottom: 18px; + background: #eef2f7; + color: var(--blue); + } + .feature-card:nth-child(2) .feature-icon { background: #fcefb6; color: var(--gold); } + .feature-card:nth-child(3) .feature-icon { background: #f3e3fb; color: var(--purple); } + .feature-card:nth-child(4) .feature-icon { background: #e3f2eb; color: var(--green); } + .feature-card:nth-child(5) .feature-icon { background: #ffedd5; color: var(--orange); } + .feature-card:nth-child(6) .feature-icon { background: #ccfbf1; color: var(--teal); } + .feature-card h3 { + font-size: 1rem; + font-weight: 600; + margin-bottom: 8px; + color: var(--text); + } + .feature-card p { + font-size: 0.875rem; + color: var(--text-dim); + line-height: 1.75; + } + + /* ── Architecture ─────────────────────── */ + #architecture .section-inner { text-align: center; } + #architecture .section-desc { margin: 0 auto 56px; } + .arch-diagram { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 48px; + } + .arch-box { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px 32px; + text-align: center; + min-width: 140px; + box-shadow: 0 2px 12px rgba(0,0,0,0.04); + } + .arch-box .emoji { font-size: 2rem; margin-bottom: 10px; } + .arch-box h4 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 4px; color: var(--text); } + .arch-box p { font-size: 0.8125rem; color: var(--text-dim); } + .arch-plus { font-size: 1.5rem; color: var(--orange); font-weight: 300; } + .arch-equals { font-size: 1.5rem; color: var(--text-dim); font-weight: 300; } + .arch-result { + border-color: var(--orange); + box-shadow: 0 0 40px rgba(217, 119, 6, 0.08); + } + + .sandbox-layers { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1px; + background: var(--border); + border-radius: var(--radius); + overflow: hidden; + border: 1px solid var(--border); + margin-top: 32px; + } + .layer-card { + background: var(--bg-card); + padding: 28px 20px; + text-align: center; + } + .layer-card .layer-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(13, 148, 136, 0.1); + color: var(--teal); + font-size: 0.8125rem; + font-weight: 700; + margin-bottom: 12px; + } + .layer-card h4 { font-size: 0.875rem; font-weight: 600; margin-bottom: 4px; } + .layer-card p { font-size: 0.75rem; color: var(--text-dim); line-height: 1.6; } + + /* ── Principles ──────────────────────── */ + #principles .section-inner { text-align: center; } + #principles .section-desc { margin: 0 auto 48px; } + .principles-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + } + .principle-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px 28px; + text-align: left; + } + .principle-card .num { + font-size: 2rem; + font-weight: 800; + color: var(--orange); + opacity: 0.25; + line-height: 1; + margin-bottom: 12px; + } + .principle-card h3 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 8px; } + .principle-card p { font-size: 0.875rem; color: var(--text-dim); line-height: 1.75; } + + /* ── Quick Start ──────────────────────── */ + #quickstart .section-inner { text-align: center; } + #quickstart .section-desc { margin: 0 auto 40px; } + .code-block-wrapper { + max-width: 640px; + margin: 0 auto; + position: relative; + } + .code-block { + background: #f3f2ef; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px 32px; + text-align: left; + font-family: "SF Mono", "Fira Code", "JetBrains Mono", monospace; + font-size: 0.8125rem; + line-height: 1.8; + overflow-x: auto; + position: relative; + white-space: pre; + color: var(--text); + } + .code-block .c { color: var(--text-dim); } + .code-block .p { color: var(--blue); font-weight: 500; } + .code-block .s { color: var(--green); } + .code-block .v { color: var(--orange); } + .copy-btn { + position: absolute; + top: 12px; + right: 12px; + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text-muted); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + } + .copy-btn:hover { border-color: var(--text-dim); color: var(--text); } + .copy-btn.copied { border-color: var(--green); color: var(--green); background: #e3f2eb; } + + /* ── Compare Table ──────────────────────── */ + #compare .section-inner { text-align: center; } + #compare .section-desc { margin: 0 auto 48px; } + .table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + } + .table-wrap table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; + min-width: 720px; + } + .table-wrap th { + text-align: left; + padding: 12px 16px; + background: #eef2f7; + font-weight: 600; + color: var(--text-muted); + font-size: 0.75rem; + letter-spacing: 0.6px; + border-bottom: 1px solid var(--border); + } + .table-wrap th:last-child { background: #fef3c7; color: #8a6418; } + .table-wrap td { + padding: 10px 16px; + color: var(--text); + border-bottom: 1px solid var(--border-light); + } + .table-wrap tr:last-child td { border-bottom: none; } + .table-wrap td:last-child { background: #fffbef; color: #5c4308; font-weight: 500; } + .table-wrap .ch { color: var(--green); font-weight: 600; } + .table-wrap .cw { color: var(--red); } + + /* ── Footer ───────────────────────────── */ + footer { + border-top: 1px solid var(--border); + padding: 40px 24px; + } + .footer-inner { + max-width: var(--max-w); + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + } + .footer-text { font-size: 0.8125rem; color: var(--text-dim); } + .footer-text a { color: var(--text-muted); text-decoration: none; } + .footer-text a:hover { color: var(--text); } + + /* ── Responsive ───────────────────────── */ + @media (max-width: 768px) { + .features-grid { grid-template-columns: 1fr; } + .sandbox-layers { grid-template-columns: repeat(2, 1fr); } + .principles-grid { grid-template-columns: 1fr; } + .arch-diagram { flex-direction: column; } + .arch-plus, .arch-equals { transform: rotate(90deg); } + .hero { padding: 60px 20px 48px; } + section { padding: 56px 20px; } + } + @media (max-width: 480px) { + .sandbox-layers { grid-template-columns: 1fr; } + .hero-actions { flex-direction: column; align-items: center; } + .btn { width: 100%; justify-content: center; } + .nav-links { display: none; } + } +</style> +</head> +<body> + +<!-- Nav --> +<nav> + <div class="nav-inner"> + <a href="#" class="nav-brand"> + <span class="yarn">🧶</span> + <span>loopat</span> + </a> + <div class="nav-links"> + <a href="#features">功能</a> + <a href="#architecture">架构</a> + <a href="#compare">对比</a> + <a href="#quickstart">快速开始</a> + <a class="nav-gh" href="https://github.com/simpx/loopat" target="_blank" rel="noopener"> + <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg> + GitHub + </a> + <a href="index.html" class="lang-toggle">EN</a> + </div> + </div> +</nav> + +<!-- Hero --> +<section class="hero"> + <div class="hero-badge"> + <span class="dot"></span> Apache 2.0 — 开源软件 + </div> + <h1> + <span class="gradient">loopat</span> + </h1> + <div class="tagline"><em>loop at</em> context, distill into knowledge</div> + <div class="formula"> + <span class="kw">Loop</span> + <span>=</span> + <span>上下文</span> + <span>+</span> + <span class="co">AI</span> + <span>+</span> + <span>工作目录</span> + </div> + <p> + 一个围绕上下文管理构建的自托管 AI 协作工作台 &mdash; 单人可用,团队可扩展。loopat 的独特之处在于其 <strong>上下文架构</strong>:聊天、代码、记忆和知识环环相扣,上下文不会在会话或团队成员之间丢失。 + </p> + <div class="hero-actions"> + <a href="#quickstart" class="btn btn-primary">快速开始</a> + <a href="https://github.com/simpx/loopat" class="btn btn-secondary" target="_blank" rel="noopener"> + <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg> + GitHub 仓库 + </a> + </div> +</section> + +<!-- Features --> +<section id="features"> + <div class="section-inner"> + <div class="section-label">核心功能</div> + <h2 class="section-title">为人与 AI 协作打造的<br>端到端上下文管理</h2> + <p class="section-desc"> + 当人与 AI 协作时,有三件事只有人类才能做到:<strong>驱动</strong>、<strong>注意力</strong>和<strong>熵减</strong>。loopat 将它们构建为第一等概念 &mdash; 循环、专注和上下文 &mdash; 而聊天则协调团队的同步。 + </p> + <div class="features-grid"> + <div class="feature-card"> + <div class="feature-icon">🧶</div> + <h3>可复现的循环</h3> + <p>每个循环在独立沙箱中运行,配备版本化的工具链和隔离的凭据 Vault。明天换台机器,同样的初始状态。</p> + </div> + <div class="feature-card"> + <div class="feature-icon">💬</div> + <h3>多人协作聊天</h3> + <p>基于 WebSocket 的实时 Claude 对话。多个浏览器标签页可同时加入同一个循环会话。</p> + </div> + <div class="feature-card"> + <div class="feature-icon">📋</div> + <h3>看板</h3> + <p>内置看板,支持拖拽排序、多人在线实时同步。与 AI 智能体配合管理任务。</p> + </div> + <div class="feature-card"> + <div class="feature-icon">💻</div> + <h3>Web 终端</h3> + <p>沙箱内的完整 PTY 终端。所有循环订阅者共享同一个 bash 会话。</p> + </div> + <div class="feature-card"> + <div class="feature-icon">📁</div> + <h3>文件树与编辑器</h3> + <p>通过 Web UI 浏览和编辑沙箱内的文件。集成 Git 提交和历史查看。</p> + </div> + <div class="feature-card"> + <div class="feature-icon">🧠</div> + <h3>持久化记忆</h3> + <p>双层记忆体系 &mdash; 个人记忆(<code>personal/memory</code>)与团队共享记忆(<code>context/notes/memory</code>)。跨会话自动召回,上下文永不丢失。</p> + </div> + </div> + </div> +</section> + +<!-- Architecture --> +<section id="architecture"> + <div class="section-inner"> + <div class="section-label">架构</div> + <h2 class="section-title">一个循环,一个沙箱</h2> + <p class="section-desc"> + 每个循环将上下文、AI 智能体和工作目录打包在 Linux 挂载命名空间中。可移植、可复现、默认安全。 + </p> + <div class="arch-diagram"> + <div class="arch-box"> + <div class="emoji">📚</div> + <h4>上下文</h4> + <p>知识 + 笔记 + 记忆</p> + </div> + <span class="arch-plus">+</span> + <div class="arch-box"> + <div class="emoji">🤖</div> + <h4>AI 智能体</h4> + <p>bwrap 中的 Claude CLI</p> + </div> + <span class="arch-plus">+</span> + <div class="arch-box"> + <div class="emoji">📂</div> + <h4>工作目录</h4> + <p>每个循环独立 Git 工作树</p> + </div> + <span class="arch-equals">=</span> + <div class="arch-box arch-result"> + <div class="emoji">🧶</div> + <h4>沙箱化循环</h4> + <p>隔离 &amp; 可复现</p> + </div> + </div> + <div class="sandbox-layers"> + <div class="layer-card"> + <div class="layer-num">1</div> + <h4>URL 路由</h4> + <p>Hono 路由器通过闭包绑定请求到循环 ID</p> + </div> + <div class="layer-card"> + <div class="layer-num">2</div> + <h4>Map 分区</h4> + <p>每个循环独立的会话映射,防止跨循环广播</p> + </div> + <div class="layer-card"> + <div class="layer-num">3</div> + <h4>进程隔离</h4> + <p>每个循环启动独立的 Claude CLI 子进程</p> + </div> + <div class="layer-card"> + <div class="layer-num">4</div> + <h4>挂载命名空间</h4> + <p>通过 bubblewrap 实现内核级文件系统隔离</p> + </div> + </div> + </div> +</section> + +<!-- Compare --> +<section id="compare"> + <div class="section-inner"> + <div class="section-label">对比竞品</div> + <h2 class="section-title">单人可用,团队可扩展</h2> + <p class="section-desc"> + 无论是一个人工作还是团队协作,都是同一个工作台。单人使用时,它是个人 AI 工作区;加入团队后,共享的知识和笔记仓库会在成员之间同步。 + </p> + <div class="table-wrap"> + <table> + <thead> + <tr> + <th></th><th>Claude Code</th><th>Cursor</th><th>opencode</th><th>Codex</th><th>loopat</th> + </tr> + </thead> + <tbody> + <tr> + <td>产品形态</td><td>CLI</td><td>IDE</td><td>TUI</td><td>Web(托管)</td><td>Web(自托管)</td> + </tr> + <tr> + <td>许可证</td><td>专有</td><td>专有</td><td>MIT</td><td>专有</td><td>Apache 2.0</td> + </tr> + <tr> + <td>团队 IM 集成</td><td>手动粘贴</td><td>手动粘贴</td><td>手动粘贴</td><td>手动粘贴</td><td><span class="ch">内置集成</span></td> + </tr> + <tr> + <td>共享团队知识</td><td>个人配置</td><td>个人配置</td><td>个人配置</td><td>个人配置</td><td><span class="ch">Git 同步</span></td> + </tr> + <tr> + <td>会话沙箱</td><td>进程级</td><td>进程级</td><td>进程级</td><td>平台管理</td><td><span class="ch">bwrap + Docker</span></td> + </tr> + <tr> + <td>凭据隔离</td><td>共享环境变量</td><td>共享订阅</td><td>共享环境变量</td><td>账户管理</td><td><span class="ch">每循环独立 Vault</span></td> + </tr> + <tr> + <td>数据存储</td><td>本地文件</td><td>云端</td><td>本地文件</td><td>OpenAI 服务器</td><td>你控制的 Git 仓库</td> + </tr> + <tr> + <td>智能体引擎</td><td>Anthropic</td><td>多模型</td><td>可插拔</td><td>OpenAI</td><td>Claude Agent SDK</td> + </tr> + </tbody> + </table> + </div> + </div> +</section> + +<!-- Design Principles --> +<section id="principles"> + <div class="section-inner"> + <div class="section-label" style="text-align:center">设计原则</div> + <h2 class="section-title" style="text-align:center">文件系统优先、可移植、透明</h2> + <p class="section-desc" style="text-align:center;margin:0 auto 48px;"> + 没有数据库,一切都是文件。rsync 你的工作区到另一台机器,完美衔接刚才的工作。 + </p> + <div class="principles-grid"> + <div class="principle-card"> + <div class="num">01</div> + <h3>沙箱即目录</h3> + <p>沙箱权限不会隐藏在配置文件中。它们源自 <code>loops/&lt;id&gt;/</code> 的物理目录结构 — 运行 <code>ls -laR</code> 就能看到完整的访问范围。</p> + </div> + <div class="principle-card"> + <div class="num">02</div> + <h3>机器无关</h3> + <p>rsync <code>$LOOPAT_HOME</code> 到任意机器,重启服务,一切正常 — 聊天记录、会话、记忆、沙箱视图。所有路径都是虚拟的。</p> + </div> + <div class="principle-card"> + <div class="num">03</div> + <h3>自托管,数据自有</h3> + <p>所有产物都保存在你完全掌控的纯 Git 仓库中;Vault 凭据经 git-crypt 加密。自带 API key &mdash; 除了调用模型 API 本身之外,没有任何数据离开你的机器。</p> + </div> + </div> + </div> +</section> + +<!-- Quick Start --> +<section id="quickstart"> + <div class="section-inner"> + <div class="section-label">快速开始</div> + <h2 class="section-title">几秒钟内启动运行</h2> + <p class="section-desc"> + 一个命令即可启动。适用于任意 Linux 机器。macOS 可通过 Docker 运行。 + </p> + <div class="code-block-wrapper"> + <button class="copy-btn" onclick="copyCode(this)">复制</button> + <div class="code-block"><span class="c"># 1. 安装系统依赖</span> +<span class="p">sudo apt install</span> bubblewrap openssh-client mise + +<span class="c"># 2. 安装 Bun</span> +<span class="p">curl -fsSL</span> https://bun.sh/install <span class="p">|</span> bash + +<span class="c"># 3. 克隆并启动</span> +<span class="p">git clone</span> git@github.com:simpx/loopat.git +<span class="p">cd</span> loopat +<span class="p">bun run dev</span> + +<span class="c"># 浏览器打开 http://localhost:10001</span></div> + </div> + </div> +</section> + +<!-- Footer --> +<footer> + <div class="footer-inner"> + <div class="footer-text"> + <a href="https://github.com/simpx/loopat" target="_blank" rel="noopener">loopat</a> — 围绕上下文管理构建的自托管 AI 协作工作台。 + </div> + <div class="footer-text"> + 基于 <a href="https://bun.sh" target="_blank" rel="noopener">Bun</a> · + <a href="https://hono.dev" target="_blank" rel="noopener">Hono</a> · + <a href="https://react.dev" target="_blank" rel="noopener">React</a> · + <a href="https://github.com/anthropics/claude-agent-sdk" target="_blank" rel="noopener">Claude Agent SDK</a> + </div> + </div> +</footer> + +<script> + function copyCode(btn) { + const code = btn.nextElementSibling.innerText; + navigator.clipboard.writeText(code).then(() => { + btn.textContent = '已复制!'; + btn.classList.add('copied'); + setTimeout(() => { + btn.textContent = '复制'; + btn.classList.remove('copied'); + }, 2000); + }); + } +</script> + +</body> +</html> diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..4e76cb69 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,55 @@ +# scripts/ + +Misc dev scripts. Not loaded by the loopat server at runtime. + +## mock-mcp-server.ts + +A self-contained OAuth-protected MCP server for **local testing** of +loopat's `Settings → MCP Auth` flow without depending on a real MCP server. + +Implements RFC 9728 (protected-resource metadata), RFC 8414 +(authorization-server metadata), RFC 7591 (dynamic client registration), +Authorization Code Flow + PKCE, and a minimal MCP HTTP endpoint with one +fake tool `mock_echo`. Auto-approves the consent page (no human click). + +### Usage + +```sh +bun run scripts/mock-mcp-server.ts +# listens on http://127.0.0.1:7799 +``` + +Add to your workspace `knowledge/.loopat/.claude/settings.json`: + +```json +{ + "mcpServers": { + "mock": { + "type": "http", + "url": "http://127.0.0.1:7799/mcp" + } + } +} +``` + +Then in loopat UI: **Settings → MCP Auth → Connect mock**. Your browser +will round-trip through the mock's OAuth endpoint and land back with a +token written to `personal/<user>/.loopat/vaults/<vault>/mcp-tokens.json`. + +The next loop you spawn will see the `mock` MCP server with the +`Authorization: Bearer <token>` header pre-injected — sandboxed CC will +**not** trigger its own OAuth flow. + +### Configuration + +| env | default | meaning | +|---|---|---| +| `PORT` | `7799` | port to listen on | +| `HOST` | `127.0.0.1` | bind address | +| `PUBLIC_BASE` | derived | URL announced in OAuth metadata. Override if proxying. | + +### Resetting + +Mock state is in-memory. Restart the script to drop all clients + +tokens. Existing loopat tokens for `mock` will then 401 against `/mcp` +until you re-`Connect` via the UI. diff --git a/scripts/build-to-nginx.sh b/scripts/build-to-nginx.sh new file mode 100755 index 00000000..69823c06 --- /dev/null +++ b/scripts/build-to-nginx.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +TARGET="${NGINX_ROOT:-/var/www/loopat}" + +echo "==> Installing dependencies..." +(cd server && bun install) +(cd web && bun install) + +echo "==> Building web frontend..." +(cd web && bun run build) + +echo "==> Copying to $TARGET ..." +sudo mkdir -p "$TARGET" +sudo rm -rf "$TARGET"/* +sudo cp -r web/dist/* "$TARGET"/ + +echo "==> Done. Files in $TARGET:" +ls -lh "$TARGET" diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100755 index 00000000..5d4f7b5f --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# End-to-end demo of the tiered .claude/ model. +# Assumes scripts/setup-demo.sh has been run (LOOPAT_HOME=/tmp/loopat-demo). +# +# Walks through several composition scenarios to show how the merge produces +# different sandboxes for different users + CLI flag combinations. +set -euo pipefail + +LOOPAT_HOME="${LOOPAT_HOME:-/tmp/loopat-demo}" +export LOOPAT_HOME + +if [[ ! -d "$LOOPAT_HOME" ]]; then + echo "[!] LOOPAT_HOME does not exist: $LOOPAT_HOME" + echo " Run scripts/setup-demo.sh first." + exit 2 +fi + +cd "$(dirname "$0")/.." + +banner() { + echo "" + echo "╔════════════════════════════════════════════════════════════════════════════╗" + printf "║ %-74s ║\n" "$1" + echo "╚════════════════════════════════════════════════════════════════════════════╝" +} + +echo "Demo workspace: $LOOPAT_HOME" +echo "" + +banner "1. List available profiles" +bun scripts/loopat.ts list + +banner "2. Alice (backend + security) — defaults only" +bun scripts/loopat.ts run --user alice --dry-run | sed 's/^/ /' + +banner "3. Bob (frontend + review) — defaults only" +bun scripts/loopat.ts run --user bob --dry-run | sed 's/^/ /' + +banner "4. Carol (security + oncall) — defaults only" +bun scripts/loopat.ts run --user carol --dry-run | sed 's/^/ /' + +banner "5. Alice + incident mode (CLI +mode-incident)" +bun scripts/loopat.ts run --user alice +mode-incident --dry-run | sed 's/^/ /' + +banner "6. Alice — STRESS: load 5 profiles at once" +bun scripts/loopat.ts run --user alice +mode-oncall +mode-incident +role-eng-ml --dry-run | sed 's/^/ /' + +banner "7. Alice — override to a single profile" +bun scripts/loopat.ts run --user alice --profiles=mode-incident --dry-run | sed 's/^/ /' + +banner "8. Real materialize: Alice + mode-oncall (no spawn)" +bun scripts/loopat.ts run --user alice +mode-oncall 2>&1 | sed 's/^/ /' + +LOOP=$(ls -td "$LOOPAT_HOME/loops/loop-"* | head -1) +echo "" +echo " → Inspecting materialized loop: $LOOP" +echo "" +echo " ── settings.json (merged) ──" +sed 's/^/ /' "$LOOP/.claude/settings.json" +echo "" +echo " ── CLAUDE.md sources ──" +grep "<!-- ==========" "$LOOP/.claude/CLAUDE.md" | sed 's/^/ /' +echo "" +echo " ── skills (symlinked from sources) ──" +ls "$LOOP/.claude/skills/" 2>/dev/null | sed 's/^/ /' +echo "" +echo " ── agents ──" +ls "$LOOP/.claude/agents/" 2>/dev/null | sed 's/^/ /' + +banner "Demo complete" +echo "" +echo "Next steps:" +echo " - Run a real session: bun scripts/loopat.ts run --user alice +mode-oncall --bwrap" +echo " - Hit the web UI: LOOPAT_HOME=$LOOPAT_HOME bun --cwd server run dev (then http://localhost:5173)" +echo " - Run the merge tests: bun --cwd server run test" diff --git a/scripts/e2e/context-flow-ai.sh b/scripts/e2e/context-flow-ai.sh new file mode 100644 index 00000000..88c0b598 --- /dev/null +++ b/scripts/e2e/context-flow-ai.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# e2e scenario #3 — context flow driven by a REAL AI (idealab), over a real ssh +# remote, authenticated with the user's vault key. Black-box: drives the loop +# entirely through `npx loopat@latest` + the v1 REST API, like a real client. +# +# Proves both edges of docs/context-flow.md on a live loop: +# ② promote — the loop's AI edits notes in the sandbox and git-pushes it; +# it lands on the ssh remote. +# ① pull — an external edit to the remote is visible to the NEXT loop. +# +# NOTE on the two urls: under ROOTLESS podman a sandbox can only reach the ssh +# server by its container name (same bridge), while the host can only reach it +# by its published port (host ip) — there is no single address both can use. +# So each edge uses the address reachable from its own side, both pointing at +# the SAME bare repo. In production the ssh host has one address everyone +# reaches; this split is purely a fixture artifact. +# +# Requires: node/npx + podman (+ a running podman machine on macOS) + an idealab +# API key (IDEALAB_KEY, default: the host's dashscope vault). Safe + self- +# contained: throwaway LOOPAT_HOME + container + network, removed on exit (trap). +# Run: bash scripts/e2e/context-flow-ai.sh +set -e + +IDEALAB_KEY="${IDEALAB_KEY:-$HOME/.dashscope/personal/simpx/.loopat/vaults/default/envs/IDEALAB_API_KEY}" +[ -s "$IDEALAB_KEY" ] || { echo "FAIL: idealab key not found at $IDEALAB_KEY (set IDEALAB_KEY=...)"; exit 1; } + +H=/tmp/loopat-e2e-ai-$$; P=10094; PORT=2227 +WS=$(basename "$H"); NET=loopat-$WS; CTR=loopat-e2e-ai-ssh-$$ +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +cleanup() { + [ -n "$SRV" ] && { pkill -P "$SRV" 2>/dev/null; kill "$SRV" 2>/dev/null; } || true + podman ps -aq --filter "label=loopat.workspace=$WS" 2>/dev/null | xargs -r podman rm -f >/dev/null 2>&1 || true + podman rm -f "$CTR" >/dev/null 2>&1 || true + podman network rm "$NET" >/dev/null 2>&1 || true + rm -rf "$H" +} +trap cleanup EXIT +SRV="" + +mkdir -p "$H" +ssh-keygen -t ed25519 -N "" -f "$H/host" -q -C host +ssh-keygen -t ed25519 -N "" -f "$H/vault" -q -C vault +HKEYENV="ssh -i $H/host -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null" + +podman build -q -t loopat-gitssh-test "$REPO_ROOT/scripts/e2e/git-ssh-server" >/dev/null +podman network create --label "loopat.workspace=$WS" "$NET" >/dev/null +podman run -d --name "$CTR" --network "$NET" -p "$PORT:22" -e AUTHORIZED_KEY="$(cat "$H/host.pub")" loopat-gitssh-test >/dev/null +sleep 3 +podman exec "$CTR" sh -c "echo '$(cat "$H/vault.pub")' >> /home/git/.ssh/authorized_keys" +podman exec "$CTR" su git -c "git init --bare -q -b main /srv/git/notes.git" + +HOST_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1) +[ -z "$HOST_IP" ] && HOST_IP=$(ipconfig getifaddr en0 2>/dev/null) # macOS +[ -z "$HOST_IP" ] && { echo "FAIL: could not determine host ip"; exit 1; } +HOST_URL="ssh://git@$HOST_IP:$PORT/srv/git/notes.git" # host-reachable (published port) +CTR_URL="ssh://git@$CTR/srv/git/notes.git" # sandbox-reachable (container name) + +# seed (host key, host url) +export GIT_SSH_COMMAND="$HKEYENV" +git clone -q "$HOST_URL" "$H/seed" +( cd "$H/seed" && echo seed > SEED.md && git -c user.email=h@x -c user.name=h add -A && git -c user.email=h@x -c user.name=h commit -qm seed && git push -q origin HEAD:main ) +unset GIT_SSH_COMMAND + +# workspace: idealab provider + vault key + host config (display-clone, host url) +mkdir -p "$H/personal/simpx/.loopat/vaults/default/envs" "$H/personal/simpx/.loopat/vaults/default/mounts/home/.ssh" +cp "$IDEALAB_KEY" "$H/personal/simpx/.loopat/vaults/default/envs/IDEALAB_API_KEY" +cp "$H/vault" "$H/personal/simpx/.loopat/vaults/default/mounts/home/.ssh/id"; chmod 600 "$H/personal/simpx/.loopat/vaults/default/mounts/home/.ssh/id" +printf '{"knowledge":{"git":""},"notes":{"git":"%s"},"providers":{},"repos":[]}\n' "$HOST_URL" > "$H/config.json" + +GIT_SSH_COMMAND="$HKEYENV" LOOPAT_HOME="$H" PORT="$P" npx -y loopat@latest > "$H/server.log" 2>&1 & +SRV=$! +for i in $(seq 1 90); do curl -fsS "localhost:$P/api/auth/me" >/dev/null 2>&1 && break; sleep 1; done +echo "server up" +curl -fsS -c "$H/cj" -X POST "localhost:$P/api/auth/register" -H 'content-type: application/json' -d '{"username":"simpx","password":"test1234"}' >/dev/null + +# write personal config with a given notes url (idealab provider stays fixed) +set_notes_url() { + python3 - "$1" "$H/personal/simpx/.loopat/config.json" <<'PY' +import json,sys +url, path = sys.argv[1], sys.argv[2] +json.dump({"providers":{"default":"idealab/claude-opus-4-7","idealab":{"baseUrl":"https://idealab.alibaba-inc.com/api/anthropic","models":[{"id":"claude-opus-4-7","enabled":True}],"apiKey":"${IDEALAB_API_KEY}","enabled":True}},"notes":{"git":url}}, open(path,"w")) +PY +} +api() { curl -fsS -H "authorization: Bearer $TOK" "$@"; } +TOK=$(curl -fsS -b "$H/cj" -X POST "localhost:$P/api/v1/me/tokens" -H 'content-type: application/json' -d '{"label":"e2e"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") + +# ── ② promote: loop A's AI edits notes + pushes (sandbox → container-name url) ── +set_notes_url "$CTR_URL" +LA=$(api -X POST "localhost:$P/api/v1/loops" -H 'content-type: application/json' -d '{"title":"writer"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])") +body=$(python3 -c "import json;print(json.dumps({'content':'In the directory /loopat/context/notes, create a file named ai-note.md whose only content is the line: AI WAS HERE. Then publish it by running exactly: cd /loopat/context/notes && git add -A && git -c user.email=ai@loopat -c user.name=ai commit -m \\'ai note\\' && git push origin HEAD:main . Then report whether the push succeeded.','permission_mode':'bypassPermissions'}))") +api -N -X POST "localhost:$P/api/v1/loops/$LA/messages" -H 'content-type: application/json' -d "$body" --max-time 540 >/dev/null 2>&1 || true +export GIT_SSH_COMMAND="$HKEYENV" +rm -rf "$H/v"; git clone -q "$HOST_URL" "$H/v" 2>/dev/null || true +unset GIT_SSH_COMMAND +PROMOTE=$([ -f "$H/v/ai-note.md" ] && grep -q "AI WAS HERE" "$H/v/ai-note.md" && echo ok || echo fail) + +# ── ① pull: external edit (host url) → next loop B's worktree sees it ── +set_notes_url "$HOST_URL" +export GIT_SSH_COMMAND="$HKEYENV" +( cd "$H/v" && echo "EXTERNAL EDIT" > external.md && git -c user.email=x@x -c user.name=x add -A && git -c user.email=x@x -c user.name=x commit -qm ext && git push -q origin HEAD:main ) +unset GIT_SSH_COMMAND +LB=$(api -X POST "localhost:$P/api/v1/loops" -H 'content-type: application/json' -d '{"title":"reader"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])") +UB=${LB#loop_} +PULL=$([ -f "$H/loops/$UB/context/notes/external.md" ] && echo ok || echo fail) + +echo " $([ "$PROMOTE" = ok ] && echo '✓' || echo '✗') ② promote: AI's notes edit reached the ssh remote ($PROMOTE)" +echo " $([ "$PULL" = ok ] && echo '✓' || echo '✗') ① pull: external edit visible in the next loop ($PULL)" +[ "$PROMOTE" = ok ] && [ "$PULL" = ok ] && echo "PASS — real-AI context flow works both ways over ssh with the vault key." || { echo FAIL; exit 1; } diff --git a/scripts/e2e/context-flow-ssh.ts b/scripts/e2e/context-flow-ssh.ts new file mode 100644 index 00000000..d838f221 --- /dev/null +++ b/scripts/e2e/context-flow-ssh.ts @@ -0,0 +1,106 @@ +/** + * e2e scenario #4 — context flow over a REAL ssh remote, authenticated with the + * user's VAULT key (not the host's ssh). + * + * Spins up a throwaway git-over-ssh server (podman), declares it as the + * personal repo's authoritative kn/notes remote, drops the matching private key + * into the user's vault, then creates a loop. The loop's git ops must reach the + * ssh server — and CAN ONLY do so with the vault key, because this process + * inherits no GIT_SSH_COMMAND and the server authorizes only the test key. So a + * successful fetch of the seeded content proves the vault-key path end to end: + * - personal config is authoritative (origin = the ssh url it declared) + * - server-side git authenticates AS THE USER (vault key), not as the host + * + * Safe + self-contained: throwaway LOOPAT_HOME + container, both removed on + * exit. Requires bun + podman. First run builds the tiny ssh-server image. + * + * Run: bun run scripts/e2e/context-flow-ssh.ts + */ +const HOME = `/tmp/loopat-e2e-sshcf-${process.pid}` +process.env.LOOPAT_HOME = HOME +delete process.env.GIT_SSH_COMMAND // loopat must rely on the vault key alone + +const { execFile } = await import("node:child_process") +const { promisify } = await import("node:util") +const { writeFile, readFile, mkdir, copyFile, chmod, rm } = await import("node:fs/promises") +const { join } = await import("node:path") +const x = promisify(execFile) +const G = (...a: string[]) => x("git", a) +const podman = (...a: string[]) => x("podman", a) + +const { ensureWorkspaceDirs, createLoop, provisionUserPersonal } = await import("../../server/src/loops") +const { createUser } = await import("../../server/src/auth") +const { workspaceKnowledgeDir, personalVaultDir, personalLoopatConfigPath, personalLoopatDir } = + await import("../../server/src/paths") + +const IMAGE = "loopat-gitssh-test" +const CTR = `loopat-e2e-gitssh-${process.pid}` +const PORT = "2223" +const serverDir = join(import.meta.dir, "git-ssh-server") + +let ctrUp = false +async function cleanup() { + if (ctrUp) await podman("rm", "-f", CTR).catch(() => {}) + await rm(HOME, { recursive: true, force: true }).catch(() => {}) +} + +async function main(): Promise<boolean> { + await podman("build", "-q", "-t", IMAGE, serverDir) // cached after first run + + await mkdir(HOME, { recursive: true }) + const key = join(HOME, "id") + await x("ssh-keygen", ["-t", "ed25519", "-N", "", "-f", key, "-q", "-C", "e2e"]) + const pub = (await readFile(key + ".pub", "utf8")).trim() + + await podman("rm", "-f", CTR).catch(() => {}) + await podman("run", "-d", "--name", CTR, "-p", `${PORT}:22`, "-e", `AUTHORIZED_KEY=${pub}`, IMAGE) + ctrUp = true + await new Promise((r) => setTimeout(r, 3000)) // let sshd come up + + const SSH_URL = `ssh://git@127.0.0.1:${PORT}/srv/git/repo.git` + const keyEnv = { + ...process.env, + GIT_SSH_COMMAND: `ssh -i ${key} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`, + } + + // seed the remote with a commit (directly, with the test key) + const seed = join(HOME, "seed") + await x("git", ["clone", "-q", SSH_URL, seed], { env: keyEnv }) + await writeFile(join(seed, "TEAM.md"), "team knowledge\n") + const author = ["-c", "user.email=e2e@x", "-c", "user.name=e2e"] + await x("git", ["-C", seed, ...author, "add", "-A"]) + await x("git", ["-C", seed, ...author, "commit", "-qm", "seed"]) + await x("git", ["-C", seed, "push", "-q", "origin", "HEAD:main"], { env: keyEnv }) + + // loopat: vault key + self-describing personal config pointing at the ssh url + await ensureWorkspaceDirs() + await createUser({ id: "e2e", password: "test1234" }) + await provisionUserPersonal("e2e") + const vssh = join(personalVaultDir("e2e", "default"), "mounts", "home", ".ssh") + await mkdir(vssh, { recursive: true }) + await copyFile(key, join(vssh, "id")) + await chmod(join(vssh, "id"), 0o600) + await mkdir(personalLoopatDir("e2e"), { recursive: true }) + await writeFile( + personalLoopatConfigPath("e2e"), + JSON.stringify({ providers: { default: "" }, knowledge: { git: SSH_URL }, notes: { git: SSH_URL } }, null, 2), + ) + + // createLoop → ensureUserContext fetches the ssh remote WITH THE VAULT KEY + const loop = await createLoop({ title: "writer", createdBy: "e2e" }) + console.log(`loop ${loop.id.slice(0, 8)} created`) + + const kn = workspaceKnowledgeDir() + const origin = (await G("-C", kn, "remote", "get-url", "origin")).stdout.trim() + const hasMain = await G("-C", kn, "rev-parse", "--verify", "-q", "origin/main").then(() => true).catch(() => false) + const teamFile = await G("-C", kn, "cat-file", "-e", "origin/main:TEAM.md").then(() => true).catch(() => false) + console.log(` ${origin === SSH_URL ? "✓" : "✗"} origin = personal-declared ssh url (authoritative)`) + console.log(` ${hasMain ? "✓" : "✗"} fetched origin/main — vault-key ssh auth succeeded`) + console.log(` ${teamFile ? "✓" : "✗"} seeded team content reachable via the remote`) + return origin === SSH_URL && hasMain && teamFile +} + +let ok = false +try { ok = await main() } finally { await cleanup() } +if (ok) console.log("PASS — loop reached the ssh remote with the user's vault key alone.") +else { console.log("FAIL"); process.exit(1) } diff --git a/scripts/e2e/context-flow.ts b/scripts/e2e/context-flow.ts new file mode 100644 index 00000000..deac88db --- /dev/null +++ b/scripts/e2e/context-flow.ts @@ -0,0 +1,109 @@ +/** + * e2e scenario #2 — basic context flow. + * + * Proves the core loop⇄context contract from docs/context-flow.md: edits made + * and PROMOTED inside one loop become visible to the NEXT loop, across all + * three shared layers (notes, knowledge, personal). This is what "working in a + * loop updates the shared context" means concretely. + * + * init contexts (each a git repo with a local bare origin, seeded) + * → loop A writes + promotes to each layer + * → loop B opens fresh worktrees from origin/main + * → loop B sees A's edits + * + * Self-contained + safe: throwaway LOOPAT_HOME under /tmp, removed on exit. + * Pure git/fs — no podman. Run: bun run scripts/e2e/context-flow.ts + */ +const HOME = process.env.LOOPAT_HOME ?? `/tmp/loopat-e2e-cf-${process.pid}` +process.env.LOOPAT_HOME = HOME + +// Dynamic imports so LOOPAT_HOME is set BEFORE paths.ts captures it. +const { rm, writeFile, readFile } = await import("node:fs/promises") +const { existsSync } = await import("node:fs") +const { execFile } = await import("node:child_process") +const { promisify } = await import("node:util") +const { join } = await import("node:path") +const execFileP = promisify(execFile) + +const { ensureWorkspaceDirs, createLoop, provisionUserPersonal } = await import("../../server/src/loops") +const { createUser } = await import("../../server/src/auth") +const { loopContextNotes, loopContextKnowledge, loopContextPersonal, + workspaceNotesDir, workspaceKnowledgeDir, personalDir } = await import("../../server/src/paths") + +const G = (cwd: string, ...args: string[]) => execFileP("git", ["-C", cwd, ...args]) +const AUTHOR = ["-c", "user.email=e2e@loopat", "-c", "user.name=e2e"] + +async function hasCommit(dir: string): Promise<boolean> { + return G(dir, "rev-parse", "--verify", "-q", "HEAD").then(() => true).catch(() => false) +} + +/** Seed an empty context repo with an initial commit on origin/main, so the + * first loop has a consensus to branch from (mimics an already-set-up team + * context; solo backend just starts from a fresh local bare origin). */ +async function seed(dir: string, name: string) { + if (await hasCommit(dir)) return + await writeFile(join(dir, "README.md"), `# ${name}\n`) + await G(dir, "add", "-A") + await G(dir, ...AUTHOR, "commit", "-q", "-m", "seed") + await G(dir, "push", "-q", "origin", "HEAD:main") +} + +/** promote (ungated edge, docs/context-flow.md §②): commit local edits and push + * HEAD onto origin/main — the same git the /promote skill runs in a loop. */ +async function promote(wt: string, msg: string) { + await G(wt, "add", "-A") + await G(wt, ...AUTHOR, "commit", "-q", "-m", msg) + await G(wt, "push", "-q", "origin", "HEAD:main") +} + +async function main(): Promise<boolean> { + await ensureWorkspaceDirs() + await createUser({ id: "e2e", password: "test1234" }) + await provisionUserPersonal("e2e") + + // "指定 kn / notes / personal" — each is a git repo on a local bare origin; + // seed an initial consensus commit. + await seed(workspaceNotesDir(), "notes") + await seed(workspaceKnowledgeDir(), "knowledge") + await seed(personalDir("e2e"), "personal") + + const marker = `e2e-cf-${process.pid}` + const layers = [ + { name: "notes", wt: loopContextNotes, file: "e2e-note.md" }, + { name: "knowledge", wt: loopContextKnowledge, file: "e2e-kn.md" }, + { name: "personal", wt: loopContextPersonal, file: "e2e-personal.md" }, + ] + + // loop A — write to each layer, then promote. + const a = await createLoop({ title: "writer", createdBy: "e2e", knowledgeRw: true }) + console.log(`loop A = ${a.id.slice(0, 8)} — write + promote`) + for (const L of layers) { + await writeFile(join(L.wt(a.id), L.file), `${L.name} ${marker}\n`) + await promote(L.wt(a.id), `e2e ${L.name}`) + } + // negative control: an edit written AFTER the promotes and never pushed — + // it must NOT reach loop B (proves visibility comes from promote, not from a + // shared directory). + await writeFile(join(loopContextNotes(a.id), "e2e-unpromoted.md"), `should-not-flow ${marker}\n`) + + // loop B — fresh worktrees opened from origin/main (① pull). + const b = await createLoop({ title: "reader", createdBy: "e2e", knowledgeRw: true }) + console.log(`loop B = ${b.id.slice(0, 8)} — check visibility`) + let allOk = true + for (const L of layers) { + const p = join(L.wt(b.id), L.file) + const ok = existsSync(p) && (await readFile(p, "utf8")).includes(marker) + console.log(` ${ok ? "✓" : "✗"} ${L.name}: ${ok ? "loop A's edit visible in loop B" : "MISSING"}`) + if (!ok) allOk = false + } + const leaked = existsSync(join(loopContextNotes(b.id), "e2e-unpromoted.md")) + console.log(` ${!leaked ? "✓" : "✗"} un-promoted edit ${leaked ? "LEAKED into" : "correctly absent from"} loop B`) + if (leaked) allOk = false + return allOk +} + +let ok = false +try { ok = await main() } +finally { await rm(HOME, { recursive: true, force: true }).catch(() => {}) } +if (ok) { console.log("PASS — promoted context flows from one loop to the next.") } +else { console.log("FAIL"); process.exit(1) } diff --git a/scripts/e2e/git-ssh-server/Containerfile b/scripts/e2e/git-ssh-server/Containerfile new file mode 100644 index 00000000..0eabad21 --- /dev/null +++ b/scripts/e2e/git-ssh-server/Containerfile @@ -0,0 +1,31 @@ +# Minimal git-over-ssh server for e2e tests. Key-only auth; the test's public +# key is injected at run time via $AUTHORIZED_KEY (so each run uses a fresh +# keypair). Hosts one bare repo at /srv/git/repo.git. +FROM alpine:3.20 +RUN apk add --no-cache openssh git \ + && adduser -D -s /bin/sh git \ + && mkdir -p /home/git/.ssh /srv/git \ + && chmod 700 /home/git/.ssh \ + && chown -R git:git /home/git /srv/git \ + && ssh-keygen -A \ + && su git -c "git init --bare -b main /srv/git/repo.git" \ + && sed -i 's/#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config \ + && sed -i 's/#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config \ + && sed -i 's/^git:!:/git::/' /etc/shadow +# Extra per-repo accounts for the permissions case (02): each is its own ssh +# principal with its own (initially empty) authorized_keys + bare repo, so a +# key can be authorized PER REPO (kn / notes / personal independently). +RUN for u in git-kn git-notes git-personal; do \ + adduser -D -s /bin/sh "$u" \ + && mkdir -p "/home/$u/.ssh" "/srv/$u" \ + && chmod 700 "/home/$u/.ssh" \ + && touch "/home/$u/.ssh/authorized_keys" \ + && chmod 600 "/home/$u/.ssh/authorized_keys" \ + && chown -R "$u:$u" "/home/$u" "/srv/$u" \ + && su "$u" -c "git init --bare -b main /srv/$u/repo.git" \ + && sed -i "s/^$u:!:/$u::/" /etc/shadow ; \ + done +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +EXPOSE 22 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/scripts/e2e/git-ssh-server/entrypoint.sh b/scripts/e2e/git-ssh-server/entrypoint.sh new file mode 100755 index 00000000..a4f1842d --- /dev/null +++ b/scripts/e2e/git-ssh-server/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Inject the test's public key (passed as $AUTHORIZED_KEY) and start sshd. +set -e +echo "$AUTHORIZED_KEY" > /home/git/.ssh/authorized_keys +chown git:git /home/git/.ssh/authorized_keys +chmod 600 /home/git/.ssh/authorized_keys +exec /usr/sbin/sshd -D -e diff --git a/scripts/e2e/install-uninstall.sh b/scripts/e2e/install-uninstall.sh new file mode 100755 index 00000000..d28c928c --- /dev/null +++ b/scripts/e2e/install-uninstall.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# e2e scenario #1 — install → create loops/containers → uninstall → zero residue. +# +# What it proves (the lifecycle a user actually runs): +# 1. a workspace can be provisioned and a real sandbox container started +# (same code path the server uses); +# 2. `loopat uninstall` removes ONLY that workspace's resources — container, +# images, network, data dir — leaving nothing behind; +# 3. ISOLATION: with two workspaces side by side, uninstalling one never +# touches the other — even when one name is a PREFIX of the other +# (loopat-e2e-a vs loopat-e2e-a2), because deletion is by the +# loopat.workspace label, not a name glob. +# +# Safety: uses throwaway LOOPAT_HOMEs under /tmp; never touches ~/.loopat or any +# real workspace. A trap cleans up even on failure. +# +# Requires: bun + podman. First run builds a real sandbox base image (~minutes); +# later runs reuse cached layers. +# +# Run from anywhere: bash scripts/e2e/install-uninstall.sh +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +UNINSTALL="$REPO/server/src/uninstall.ts" +SETUP="$REPO/scripts/e2e/setup-ws.ts" + +HOME_A=/tmp/loopat-e2e-a +HOME_A2=/tmp/loopat-e2e-a2 # "loopat-e2e-a" is a prefix of "loopat-e2e-a2" +WS_A=loopat-e2e-a +WS_A2=loopat-e2e-a2 + +c() { podman ps -aq --filter "label=loopat.workspace=$1" 2>/dev/null | wc -l | tr -d ' '; } +i() { podman images --filter "label=loopat.workspace=$1" -q 2>/dev/null | sort -u | wc -l | tr -d ' '; } +n() { podman network ls --filter "label=loopat.workspace=$1" -q 2>/dev/null | wc -l | tr -d ' '; } + +uninstall() { LOOPAT_HOME="$1" bun run "$UNINSTALL" --yes >/dev/null 2>&1 || true; } +setup() { LOOPAT_HOME="$1" bun run "$SETUP"; } + +cleanup() { uninstall "$HOME_A"; uninstall "$HOME_A2"; rm -rf "$HOME_A" "$HOME_A2"; } +trap cleanup EXIT + +fail() { echo " ✗ FAIL: $1"; exit 1; } +ok() { echo " ✓ $1"; } + +echo "e2e #1: install → uninstall (+ isolation, prefix ambiguity)" +cleanup # start clean + +echo "── provision two workspaces (real containers) ──" +setup "$HOME_A" +setup "$HOME_A2" + +echo "── after install: both have resources ──" +[ "$(c "$WS_A")" -ge 1 ] && [ "$(i "$WS_A")" -ge 1 ] && [ "$(n "$WS_A")" -ge 1 ] || fail "$WS_A not fully provisioned" +[ "$(c "$WS_A2")" -ge 1 ] && [ "$(i "$WS_A2")" -ge 1 ] && [ "$(n "$WS_A2")" -ge 1 ] || fail "$WS_A2 not fully provisioned" +ok "both workspaces have container + image + network + data" + +echo "── uninstall $WS_A ──" +uninstall "$HOME_A" +[ "$(c "$WS_A")" = 0 ] && [ "$(i "$WS_A")" = 0 ] && [ "$(n "$WS_A")" = 0 ] || fail "$WS_A left podman residue" +[ -d "$HOME_A" ] && fail "$WS_A data dir not removed" +ok "$WS_A fully removed (container/image/network/data = 0)" + +echo "── isolation + prefix check: $WS_A2 must be intact ──" +[ "$(c "$WS_A2")" -ge 1 ] && [ "$(i "$WS_A2")" -ge 1 ] && [ "$(n "$WS_A2")" -ge 1 ] || fail "$WS_A2 collateral-damaged by uninstalling its prefix $WS_A" +[ -d "$HOME_A2" ] || fail "$WS_A2 data dir wrongly removed" +ok "$WS_A2 untouched despite being '$WS_A' + suffix" + +echo "── uninstall $WS_A2 ──" +uninstall "$HOME_A2" +[ "$(c "$WS_A2")" = 0 ] && [ "$(i "$WS_A2")" = 0 ] && [ "$(n "$WS_A2")" = 0 ] || fail "$WS_A2 left podman residue" +[ -d "$HOME_A2" ] && fail "$WS_A2 data dir not removed" +ok "$WS_A2 fully removed" + +trap - EXIT +echo "PASS — install/uninstall leaves zero residue; workspaces are isolated." diff --git a/scripts/e2e/personal-permissions.ts b/scripts/e2e/personal-permissions.ts new file mode 100644 index 00000000..6371c6ee --- /dev/null +++ b/scripts/e2e/personal-permissions.ts @@ -0,0 +1,149 @@ +/** + * e2e scenario #2 — personal permissions: authorization tracks the personal + * repo, NOT the host. + * + * A git-over-ssh server hosts three repos under three ssh accounts + * (git-kn / git-notes / git-personal), each with its own authorized_keys, so a + * key can be authorized PER repo. The HOST key is authorized on all three (the + * host "has permission" — and the startup display-clone uses it). The USER's + * vault key is authorized in stages to produce three outcomes: + * + * stage 1 empty personal (no vault key) → kn ✗ personal ✗ + * stage 2 vault key authorized on kn+notes only → kn ✓ personal ✗ + * stage 3 vault key authorized on personal too → kn ✓ personal ✓ + * + * The process default GIT_SSH_COMMAND is the host key, yet loop work still + * fails until the VAULT key is authorized — proving a loop never borrows the + * host's access. "loop work" = the server-side git a loop drives: pull a + * context repo, push personal. (createLoop itself always succeeds — it just + * writes metadata; the real success/failure is in reaching the remotes.) + * + * Safe + self-contained: throwaway LOOPAT_HOME + container, removed on exit. + * Requires bun + podman. Run: bun run scripts/e2e/personal-permissions.ts + */ +const HOME = `/tmp/loopat-e2e-perm-${process.pid}` +process.env.LOOPAT_HOME = HOME + +const { execFile } = await import("node:child_process") +const { promisify } = await import("node:util") +const { writeFile, readFile, mkdir, copyFile, chmod, rm } = await import("node:fs/promises") +const { join } = await import("node:path") +const x = promisify(execFile) +const podman = (...a: string[]) => x("podman", a) + +const IMAGE = "loopat-gitssh-test" +const CTR = `loopat-e2e-perm-${process.pid}` +const PORT = "2224" +const serverDir = join(import.meta.dir, "git-ssh-server") +const sshUrl = (acct: string) => `ssh://${acct}@127.0.0.1:${PORT}/srv/${acct}/repo.git` +const sshCmd = (key: string) => + `ssh -i ${key} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null` +const author = ["-c", "user.email=e2e@x", "-c", "user.name=e2e"] + +let ctrUp = false +async function cleanup() { + if (ctrUp) await podman("rm", "-f", CTR).catch(() => {}) + await rm(HOME, { recursive: true, force: true }).catch(() => {}) +} + +async function authorize(acct: string, pub: string) { + await podman("exec", CTR, "sh", "-c", `echo '${pub}' >> /home/${acct}/.ssh/authorized_keys`) +} + +async function main(): Promise<boolean> { + await podman("build", "-q", "-t", IMAGE, serverDir) + await mkdir(HOME, { recursive: true }) + + // two keypairs: the host's, and the user's (vault) key. + const hostKey = join(HOME, "host"), vaultKey = join(HOME, "vault") + await x("ssh-keygen", ["-t", "ed25519", "-N", "", "-f", hostKey, "-q", "-C", "host"]) + await x("ssh-keygen", ["-t", "ed25519", "-N", "", "-f", vaultKey, "-q", "-C", "vault"]) + const hostPub = (await readFile(hostKey + ".pub", "utf8")).trim() + const vaultPub = (await readFile(vaultKey + ".pub", "utf8")).trim() + const hostEnv = { ...process.env, GIT_SSH_COMMAND: sshCmd(hostKey) } + + await podman("rm", "-f", CTR).catch(() => {}) + await podman("run", "-d", "--name", CTR, "-p", `${PORT}:22`, IMAGE) + ctrUp = true + await new Promise((r) => setTimeout(r, 3000)) + + // host key authorized on ALL three repos (host "has permission"). + for (const acct of ["git-kn", "git-notes", "git-personal"]) await authorize(acct, hostPub) + // Seed kn/notes only (pullRepoFromRemote needs an origin/main to fetch). + // git-personal stays an EMPTY bare repo, so personal's first push creates + // main with no unrelated-history rebase against a host-seeded commit. + for (const acct of ["git-kn", "git-notes"]) { + const d = join(HOME, `seed-${acct}`) + await x("git", ["clone", "-q", sshUrl(acct), d], { env: hostEnv }) + await writeFile(join(d, "README.md"), `${acct}\n`) + await x("git", ["-C", d, ...author, "add", "-A"]) + await x("git", ["-C", d, ...author, "commit", "-qm", "seed"]) + await x("git", ["-C", d, "push", "-q", "origin", "HEAD:main"], { env: hostEnv }) + } + + // The process default ssh is the HOST key — so the startup display-clone + // (which uses no explicit key) reaches the repos. Loop work overrides this + // per-call with the vault key. + process.env.GIT_SSH_COMMAND = sshCmd(hostKey) + + const { ensureWorkspaceDirs, createUser, provisionUserPersonal, pullRepoFromRemote, pushPersonalToRemote } = + await loadLoopat() + const { workspaceKnowledgeDir, personalDir, personalVaultDir, personalLoopatConfigPath, personalLoopatDir } = + await import("../../server/src/paths") + const { configPath } = await import("../../server/src/config") + + // host config declares kn/notes (display mirror); startup clones with host key. + await writeFile(configPath(), JSON.stringify({ knowledge: { git: sshUrl("git-kn") }, notes: { git: sshUrl("git-notes") }, providers: {}, repos: [] }, null, 2)) + await ensureWorkspaceDirs() + const displayOk = await readFile(join(workspaceKnowledgeDir(), "README.md"), "utf8").then((s) => s.includes("git-kn")).catch(() => false) + + await createUser({ id: "e2e", password: "test1234" }) + await provisionUserPersonal("e2e") + // personal is self-describing; its repo lives under the git-personal account. + await mkdir(personalLoopatDir("e2e"), { recursive: true }) + await writeFile(personalLoopatConfigPath("e2e"), JSON.stringify({ providers: { default: "" }, knowledge: { git: sshUrl("git-kn") }, notes: { git: sshUrl("git-notes") } }, null, 2)) + await x("git", ["-C", personalDir("e2e"), "remote", "set-url", "origin", sshUrl("git-personal")]).catch(async () => { + await x("git", ["-C", personalDir("e2e"), "remote", "add", "origin", sshUrl("git-personal")]) + }) + + const vssh = join(personalVaultDir("e2e", "default"), "mounts", "home", ".ssh") + const knOk = () => pullRepoFromRemote(workspaceKnowledgeDir(), "e2e").then((r: any) => r.ok) + const persOk = () => pushPersonalToRemote("e2e").then((r: any) => r.ok) + + // stage 1 — empty personal: no vault key at all. + const s1 = { kn: await knOk(), pers: await persOk() } + + // stage 2 — vault key present + authorized on kn/notes ONLY. + await mkdir(vssh, { recursive: true }) + await copyFile(vaultKey, join(vssh, "id")) + await chmod(join(vssh, "id"), 0o600) + await authorize("git-kn", vaultPub) + await authorize("git-notes", vaultPub) + const s2 = { kn: await knOk(), pers: await persOk() } + + // stage 3 — vault key also authorized on personal. + await authorize("git-personal", vaultPub) + const s3 = { kn: await knOk(), pers: await persOk() } + + const ok = + displayOk && + !s1.kn && !s1.pers && + s2.kn && !s2.pers && + s3.kn && s3.pers + console.log(` ${displayOk ? "✓" : "✗"} host key display-clone of kn worked (host has permission)`) + console.log(` ${!s1.kn && !s1.pers ? "✓" : "✗"} stage 1 (empty personal): kn=${s1.kn} personal=${s1.pers} — both must fail`) + console.log(` ${s2.kn && !s2.pers ? "✓" : "✗"} stage 2 (kn+notes authorized): kn=${s2.kn} personal=${s2.pers} — kn ok, personal fails`) + console.log(` ${s3.kn && s3.pers ? "✓" : "✗"} stage 3 (all authorized): kn=${s3.kn} personal=${s3.pers} — both ok`) + return ok +} + +async function loadLoopat() { + const loops = await import("../../server/src/loops") + const auth = await import("../../server/src/auth") + return { ...loops, createUser: auth.createUser } +} + +let ok = false +try { ok = await main() } finally { await cleanup() } +if (ok) console.log("PASS — loop work is gated by the personal/vault key per repo, not by the host.") +else { console.log("FAIL"); process.exit(1) } diff --git a/scripts/e2e/setup-ws.ts b/scripts/e2e/setup-ws.ts new file mode 100644 index 00000000..10f1964b --- /dev/null +++ b/scripts/e2e/setup-ws.ts @@ -0,0 +1,18 @@ +/** + * e2e helper: provision a throwaway workspace (taken from $LOOPAT_HOME) with a + * user, a loop, and a real running sandbox container — the SAME code path the + * server uses on terminal/session attach. Driven by install-uninstall.sh. + * + * Prints `ws=<workspace> loop=<id>` so the harness can assert on it. + */ +import { ensureWorkspaceDirs, createLoop, provisionUserPersonal } from "../../server/src/loops" +import { createUser } from "../../server/src/auth" +import { ensureContainer } from "../../server/src/podman" +import { WORKSPACE } from "../../server/src/paths" + +await ensureWorkspaceDirs() +await createUser({ id: "e2e", password: "test1234" }) +await provisionUserPersonal("e2e") +const loop = await createLoop({ title: "e2e-loop", createdBy: "e2e" }) +await ensureContainer({ loopId: loop.id, createdBy: "e2e" }) +console.log(`ws=${WORKSPACE} loop=${loop.id}`) diff --git a/scripts/install-sandbox-claude.mjs b/scripts/install-sandbox-claude.mjs new file mode 100644 index 00000000..6173ce2b --- /dev/null +++ b/scripts/install-sandbox-claude.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** + * postinstall: make sure a LINUX claude binary is available for the sandbox. + * + * loopat runs the AI inside a linux podman sandbox, so it needs the + * linux-<arch> claude binary. npm only installs the claude-agent-sdk platform + * binary matching the HOST (os/cpu filtered optionalDependencies) — so on a + * linux host we already have it (no-op here), but on macOS/Windows npm installs + * the darwin/win binary and the sandbox would hit "Exec format error". + * + * On a non-linux host we fetch the linux-<arch> binary into + * <loopat>/sandbox-claude (pinned to the SDK version we depend on) using npm's + * --os/--cpu override. Best-effort: a failure only means sandbox AI won't run + * on this host until fixed; the install itself still succeeds. + */ +import { execFileSync } from "node:child_process" +import { existsSync, mkdirSync } from "node:fs" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { createRequire } from "node:module" + +if (process.platform === "linux") process.exit(0) // host claude IS the sandbox claude + +const arch = process.arch // "arm64" | "x64" +const pkg = `@anthropic-ai/claude-agent-sdk-linux-${arch}` +const installDir = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const dest = join(installDir, "sandbox-claude") +const binary = join(dest, "node_modules", pkg, "claude") + +if (existsSync(binary)) process.exit(0) // already fetched + +try { + const require = createRequire(import.meta.url) + let version = "" + try { + version = require("@anthropic-ai/claude-agent-sdk/package.json").version + } catch {} + const spec = version ? `${pkg}@${version}` : pkg + mkdirSync(dest, { recursive: true }) + console.log(`[loopat] host is ${process.platform}/${arch}; fetching linux claude for the sandbox (${spec})…`) + // The platform binary declares os=linux, so `--os=linux --cpu` hits + // EBADPLATFORM on a darwin host — `--force` is what actually fetches it. + execFileSync("npm", ["install", "--prefix", dest, "--no-save", "--force", spec], { stdio: "inherit" }) + if (existsSync(binary)) console.log(`[loopat] sandbox claude ready at ${binary}`) + else console.warn(`[loopat] sandbox claude install finished but ${binary} is missing`) +} catch (e) { + console.warn(`[loopat] could not fetch linux claude for the sandbox: ${e?.message ?? e}`) + console.warn(`[loopat] sandbox AI won't run on this host until fixed; everything else works.`) +} diff --git a/scripts/loopat.ts b/scripts/loopat.ts new file mode 100644 index 00000000..3bda16ee --- /dev/null +++ b/scripts/loopat.ts @@ -0,0 +1,289 @@ +#!/usr/bin/env bun +/** + * loopat CLI — end-to-end profile-driven loop runner (CC-native model). + * + * Uses server-track modules: + * - server/src/profiles.ts — resolve plan + * - server/src/compose.ts — materialize loop's .claude/ + * - server/src/plugin-installer.ts — orchestrate plugin install + * + * Usage: + * LOOPAT_HOME=/tmp/loopat-experience bun scripts/loopat.ts run +mode-oncall --bwrap + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { spawn } from "node:child_process" +import { join } from "node:path" +import { listProfiles, resolveLoopPlan, type LoopPlan } from "../server/src/profiles" +import { composeFromPlan, type ComposeResult } from "../server/src/compose" +import { ensureLoopPluginsInstalled } from "../server/src/plugin-installer" +import { + LOOPAT_HOME, + loopDir, + loopClaudeDir, + personalVaultDir, + workspaceKnowledgeDir, +} from "../server/src/paths" + +type Args = { + cmd: "run" | "list" | "help" + user: string + vault?: string + cliAdded: string[] + cliRemoved: string[] + overrideProfiles?: string[] + dryRun: boolean + doSpawn: boolean + useBwrap: boolean + showEnv: boolean + verbose: boolean + claudeArgs: string[] +} + +function parseArgs(argv: string[]): Args { + const out: Args = { + cmd: "help", + user: process.env.LOOPAT_USER ?? "alice", + cliAdded: [], + cliRemoved: [], + dryRun: false, + doSpawn: false, + useBwrap: false, + showEnv: false, + verbose: true, + claudeArgs: [], + } + let i = 0 + while (i < argv.length) { + const a = argv[i] + if (a === "--") { + out.claudeArgs = argv.slice(i + 1) + break + } + if (a === "run" || a === "list" || a === "help") out.cmd = a + else if (a === "--user") out.user = argv[++i] + else if (a === "--vault") out.vault = argv[++i] + else if (a === "--profiles") { + out.overrideProfiles = argv[++i].split(",").map((s) => s.trim()).filter(Boolean) + } else if (a === "--dry-run") out.dryRun = true + else if (a === "--spawn") out.doSpawn = true + else if (a === "--bwrap") { + out.useBwrap = true + out.doSpawn = true + } else if (a === "--show-env") out.showEnv = true + else if (a === "--quiet") out.verbose = false + else if (a === "-h" || a === "--help") out.cmd = "help" + else if (a.startsWith("+")) out.cliAdded.push(a.slice(1)) + else if (a.startsWith("-") && !a.startsWith("--")) out.cliRemoved.push(a.slice(1)) + i++ + } + if (out.cmd === "help" && (out.cliAdded.length || out.cliRemoved.length || out.overrideProfiles)) { + out.cmd = "run" + } + return out +} + +function printHelp() { + console.log(` +loopat — profile-driven loop runner (CC-native model) + +LOOPAT_HOME=${LOOPAT_HOME} + +USAGE: + loopat run [opts] [+profile...] [-profile...] [-- claude-args...] + loopat list + loopat help + +OPTIONS: + --user <u> Personal user (default: $LOOPAT_USER or "alice") + --vault <v> Override default_vault from personal config + --profiles a,b,c Replace default_profiles entirely + --dry-run Plan only, no side effects + --spawn Launch claude after materialize + --bwrap --spawn + bubblewrap isolation + --show-env Print vault env var names (not values) + --quiet Suppress orchestration logs + -- Pass remaining args to claude +`.trim()) +} + +/** Filename = env var name (validated). File content = value. */ +const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/ +function loadVaultEnv(vaultDir: string | undefined): { env: Record<string, string>; source: string | null } { + if (!vaultDir || !existsSync(vaultDir)) return { env: {}, source: null } + const env: Record<string, string> = {} + for (const name of readdirSync(vaultDir)) { + if (name.startsWith(".") || !ENV_NAME_RE.test(name)) continue + env[name] = readFileSync(join(vaultDir, name), "utf8").replace(/\n$/, "") + } + return { env, source: vaultDir } +} + +function printPlan(plan: LoopPlan, vaultLoad: ReturnType<typeof loadVaultEnv>, showEnv: boolean) { + console.log("─".repeat(60)) + console.log(`LOOPAT_HOME: ${LOOPAT_HOME}`) + console.log(`USER: ${plan.user}`) + console.log(`VAULT: ${plan.vault ?? "(none)"}${plan.vaultDir ? ` (${plan.vaultDir})` : ""}`) + console.log("\nACTIVE PROFILES:") + if (plan.profiles.length === 0) console.log(" (none — team + personal only)") + for (const p of plan.profiles) console.log(` · ${p}`) + console.log("\n.claude/ SOURCES (merge order, later wins):") + for (const s of plan.claudeSources) console.log(` · ${s.source} (${s.dir})`) + + console.log("\nVAULT ENV:") + console.log(` source: ${vaultLoad.source ?? "(not found)"}`) + const names = Object.keys(vaultLoad.env).sort() + console.log(` vars: ${names.length}`) + if (showEnv) { + for (const k of names) console.log(` · ${k}=<${vaultLoad.env[k].length} bytes>`) + } else if (names.length > 0) { + console.log(` · ${names.join(", ")}`) + } + console.log("─".repeat(60)) +} + +function printCompose(c: ComposeResult) { + console.log("\n" + "─".repeat(60)) + console.log("MATERIALIZED:") + console.log(` settings.json: ${c.settingsPath}`) + console.log(` CLAUDE.md: ${c.claudeMdPath}`) + console.log(` sources merged: ${c.sources.join(" → ")}`) + console.log(` enabledPlugins: ${c.enabledPlugins.length}`) + for (const p of c.enabledPlugins) console.log(` · ${p}`) + console.log(` extraMarketplaces: ${c.extraMarketplaces.length}`) + for (const m of c.extraMarketplaces) console.log(` · ${m}`) +} + +function buildBwrapArgv(opts: { + loopDir: string + vaultDir?: string + homeDir: string + claudeArgs: string[] +}): string[] { + const SYSTEM_RO = ["/usr", "/lib", "/lib64", "/lib32", "/bin", "/sbin", "/etc", "/opt", "/var"] + const args: string[] = [] + for (const p of SYSTEM_RO) args.push("--ro-bind-try", p, p) + args.push( + "--proc", "/proc", + "--dev", "/dev", + "--bind", "/tmp", "/tmp", + "--bind", opts.loopDir, "/loopat/loop", + "--bind", opts.homeDir, opts.homeDir, + ) + const knowledgeSrc = workspaceKnowledgeDir() + if (existsSync(knowledgeSrc)) args.push("--ro-bind", knowledgeSrc, "/loopat/knowledge") + if (opts.vaultDir) args.push("--ro-bind", opts.vaultDir, "/loopat/vault") + args.push("--chdir", "/loopat/loop", "--unshare-pid", "--die-with-parent") + args.push("claude", ...opts.claudeArgs) + return args +} + +async function spawnClaude( + loopDirPath: string, + vaultEnv: Record<string, string>, + vaultDir: string | undefined, + useBwrap: boolean, + claudeArgs: string[], +): Promise<number> { + const home = process.env.HOME ?? "/root" + const env = { ...process.env, ...vaultEnv } + console.log(`\n[loopat] launching claude (${useBwrap ? "bwrap" : "direct"})…`) + return new Promise((resolve) => { + let child + if (useBwrap) { + const args = buildBwrapArgv({ loopDir: loopDirPath, vaultDir, homeDir: home, claudeArgs }) + child = spawn("bwrap", args, { env, stdio: "inherit" }) + } else { + child = spawn("claude", claudeArgs, { cwd: loopDirPath, env, stdio: "inherit" }) + } + child.on("exit", (code, sig) => resolve(code ?? (sig ? 130 : 1))) + child.on("error", (e) => { + console.error(`[loopat] spawn failed: ${e.message}`) + resolve(127) + }) + }) +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + + if (args.cmd === "help") { + printHelp() + return + } + + if (!existsSync(LOOPAT_HOME)) { + console.error(`LOOPAT_HOME does not exist: ${LOOPAT_HOME}`) + console.error("Run scripts/setup-experience.sh first.") + process.exit(2) + } + + if (args.cmd === "list") { + const profiles = await listProfiles() + console.log(`Available profiles in ${LOOPAT_HOME}:`) + if (profiles.length === 0) console.log(" (none)") + for (const p of profiles) console.log(` · ${p}`) + return + } + + // run + const plan = await resolveLoopPlan({ + user: args.user, + cliAdded: args.cliAdded, + cliRemoved: args.cliRemoved, + overrideProfiles: args.overrideProfiles, + vaultOverride: args.vault, + }) + const vaultLoad = loadVaultEnv(plan.vaultDir) + printPlan(plan, vaultLoad, args.showEnv) + + if (args.dryRun) { + console.log("\n[dry-run] skipping materialize + spawn") + return + } + + const loopId = `loop-${Date.now()}` + const compose = await composeFromPlan(loopId, plan) + printCompose(compose) + + // Ensure host CC has every marketplace registered + plugin installed. + // The CLI claude reads enabledPlugins from settings.json natively (same as + // the SDK does now post-2026-05). + console.log("\n[loopat] installing plugins …") + await ensureLoopPluginsInstalled(loopId) + + if (!args.doSpawn) { + console.log("\n(materialize complete — pass --spawn or --bwrap to launch claude)") + return + } + + // CLI spawn: vault env via process env; bwrap for isolation; CC reads + // CLAUDE.md as project-tier from loopDir (we materialize to loopDir, not + // loopDir/.claude/, for the CLI-claude case — see compose.ts comment). + // Wait: we DO materialize to loopDir/.claude/. For CLI claude to pick it + // up as project-tier, we symlink loopDir/CLAUDE.md → .claude/CLAUDE.md. + const symlinkProjectClaudeMd = async () => { + const { symlink, rm } = await import("node:fs/promises") + const link = join(loopDir(loopId), "CLAUDE.md") + await rm(link, { force: true }).catch(() => {}) + if (existsSync(compose.claudeMdPath)) { + await symlink(".claude/CLAUDE.md", link, "file").catch(() => {}) + } + } + await symlinkProjectClaudeMd() + + const exit = await spawnClaude( + loopDir(loopId), + vaultLoad.env, + plan.vaultDir, + args.useBwrap, + args.claudeArgs, + ) + console.log(`\n[loopat] claude exited with code ${exit}`) + process.exit(exit) +} + +main().catch((e) => { + console.error("Error:", e.message ?? e) + process.exit(1) +}) diff --git a/scripts/mock-mcp-server.ts b/scripts/mock-mcp-server.ts new file mode 100644 index 00000000..015f9fc6 --- /dev/null +++ b/scripts/mock-mcp-server.ts @@ -0,0 +1,379 @@ +#!/usr/bin/env bun +/** + * Mock MCP server + OAuth 2.0 authorization server, for end-to-end testing + * loopat's MCP auth flow without depending on a real internal MCP server. + * + * Implements: + * + * - RFC 9728 protected-resource metadata + * GET /.well-known/oauth-protected-resource[/mcp] + * - RFC 8414 authorization-server metadata + * GET /.well-known/oauth-authorization-server + * - RFC 7591 dynamic client registration + * POST /oauth/register + * - Authorization code flow with PKCE + * GET /oauth/authorize → renders an "Approve" page (auto-approves) + * POST /oauth/token → exchanges code for access_token + * - A minimal MCP HTTP endpoint requiring Bearer auth + * POST /mcp → returns one fake tool "mock_echo" + * + * Auth is intentionally NOT secure — codes / tokens are stored in-memory and + * the "Approve" page just auto-redirects. This is for local dev only. + * + * Usage: + * bun run scripts/mock-mcp-server.ts # listens on :7799 + * PORT=8888 bun run scripts/mock-mcp-server.ts # custom port + * + * Then in your loopat workspace claude.json add: + * { + * "mcpServers": { + * "mock": { + * "type": "http", + * "url": "http://localhost:7799/mcp" + * } + * } + * } + * + * Restart loopat, then Settings → MCP Auth → Connect mock. + */ +import { createHash, randomBytes } from "node:crypto" + +const PORT = Number(process.env.PORT) || 7799 +const HOST = process.env.HOST || "127.0.0.1" +const PUBLIC_BASE = process.env.PUBLIC_BASE || `http://${HOST}:${PORT}` + +// ── in-memory stores ─────────────────────────────────────────────────── + +type Client = { + client_id: string + client_secret: string + redirect_uris: string[] +} + +type PendingCode = { + code: string + client_id: string + redirect_uri: string + code_challenge: string + code_challenge_method: string + scope: string + createdAt: number +} + +type Token = { + access_token: string + refresh_token: string + client_id: string + scope: string + expiresAt: number +} + +const clients = new Map<string, Client>() +const pendingCodes = new Map<string, PendingCode>() +const tokens = new Map<string, Token>() // by access_token +const refreshTokens = new Map<string, Token>() // by refresh_token + +function b64url(buf: Buffer) { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +function rnd(n = 24) { + return b64url(randomBytes(n)) +} + +// ── routes ───────────────────────────────────────────────────────────── + +function json(body: any, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { + ...init, + headers: { "content-type": "application/json", ...(init.headers ?? {}) }, + }) +} + +function html(body: string) { + return new Response(body, { headers: { "content-type": "text/html" } }) +} + +const handlers: Record<string, (req: Request, url: URL) => Promise<Response> | Response> = { + // RFC 9728 protected-resource metadata + "GET /.well-known/oauth-protected-resource": () => + json({ + resource: `${PUBLIC_BASE}/mcp`, + authorization_servers: [PUBLIC_BASE], + }), + "GET /.well-known/oauth-protected-resource/mcp": () => + json({ + resource: `${PUBLIC_BASE}/mcp`, + authorization_servers: [PUBLIC_BASE], + }), + + // RFC 8414 auth-server metadata + "GET /.well-known/oauth-authorization-server": () => + json({ + issuer: PUBLIC_BASE, + authorization_endpoint: `${PUBLIC_BASE}/oauth/authorize`, + token_endpoint: `${PUBLIC_BASE}/oauth/token`, + registration_endpoint: `${PUBLIC_BASE}/oauth/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "none"], + scopes_supported: ["mock:read", "mock:write"], + }), + + // RFC 7591 DCR + "POST /oauth/register": async (req) => { + const body = await req.json().catch(() => ({})) as any + const client_id = "mock-" + rnd(8) + const client_secret = "mocks_" + rnd(16) + const redirect_uris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [] + clients.set(client_id, { client_id, client_secret, redirect_uris }) + console.log(`[mock-mcp] DCR: registered client ${client_id} (redirect_uris=${JSON.stringify(redirect_uris)})`) + return json({ + client_id, + client_secret, + client_id_issued_at: Math.floor(Date.now() / 1000), + client_secret_expires_at: 0, + redirect_uris, + grant_types: body.grant_types ?? ["authorization_code", "refresh_token"], + response_types: body.response_types ?? ["code"], + }) + }, + + // Authorization endpoint — renders an auto-approve "Approve" page. + "GET /oauth/authorize": async (req, url) => { + const client_id = url.searchParams.get("client_id") ?? "" + const redirect_uri = url.searchParams.get("redirect_uri") ?? "" + const state = url.searchParams.get("state") ?? "" + const code_challenge = url.searchParams.get("code_challenge") ?? "" + const code_challenge_method = url.searchParams.get("code_challenge_method") ?? "S256" + const scope = url.searchParams.get("scope") ?? "" + + const client = clients.get(client_id) + if (!client) return new Response("unknown client", { status: 400 }) + if (!client.redirect_uris.includes(redirect_uri)) { + return new Response("redirect_uri mismatch", { status: 400 }) + } + if (!code_challenge) return new Response("missing code_challenge", { status: 400 }) + + // Render a tiny consent page that auto-redirects (since this is mock). + // For realism / debugging you can comment out the meta refresh below and + // click the link manually. + const code = "mockcode_" + rnd(16) + pendingCodes.set(code, { + code, + client_id, + redirect_uri, + code_challenge, + code_challenge_method, + scope, + createdAt: Date.now(), + }) + const target = new URL(redirect_uri) + target.searchParams.set("code", code) + if (state) target.searchParams.set("state", state) + + return html(` +<!doctype html><meta charset="utf-8"/> +<title>mock-mcp authorize</title> +<meta http-equiv="refresh" content="0;url=${target.toString()}"/> +<style>body{font:14px system-ui;padding:2em;max-width:600px;margin:auto}</style> +<h2>Mock MCP Authorization</h2> +<p>Client <code>${client_id}</code> wants to access scope: <code>${scope || "(default)"}</code>.</p> +<p>Auto-redirecting to <a href="${target.toString()}">${target.toString()}</a>…</p> +`) + }, + + // Token endpoint + "POST /oauth/token": async (req) => { + const form = await req.formData() + const grant_type = form.get("grant_type")?.toString() + if (grant_type === "authorization_code") { + const code = form.get("code")?.toString() ?? "" + const code_verifier = form.get("code_verifier")?.toString() ?? "" + const redirect_uri = form.get("redirect_uri")?.toString() ?? "" + const client_id_form = form.get("client_id")?.toString() ?? "" + + const pending = pendingCodes.get(code) + if (!pending) return json({ error: "invalid_grant", error_description: "unknown code" }, { status: 400 }) + pendingCodes.delete(code) + if (pending.redirect_uri !== redirect_uri) { + return json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, { status: 400 }) + } + // PKCE verify + const expectedChallenge = b64url(createHash("sha256").update(code_verifier).digest()) + if (expectedChallenge !== pending.code_challenge) { + return json({ error: "invalid_grant", error_description: "PKCE verifier mismatch" }, { status: 400 }) + } + // (Optional) client auth — accept either Basic or form `client_id`. + const auth = req.headers.get("authorization") ?? "" + let authedClientId = "" + if (auth.startsWith("Basic ")) { + try { + const [u] = Buffer.from(auth.slice(6), "base64").toString().split(":") + authedClientId = decodeURIComponent(u ?? "") + } catch {} + } else if (client_id_form) { + authedClientId = client_id_form + } + if (authedClientId && authedClientId !== pending.client_id) { + return json({ error: "invalid_client", error_description: "client_id mismatch" }, { status: 401 }) + } + + const access_token = "mocka_" + rnd(24) + const refresh_token = "mockr_" + rnd(24) + const tok: Token = { + access_token, + refresh_token, + client_id: pending.client_id, + scope: pending.scope, + expiresAt: Date.now() + 3600 * 1000, + } + tokens.set(access_token, tok) + refreshTokens.set(refresh_token, tok) + console.log(`[mock-mcp] issued token for client=${pending.client_id} scope=${pending.scope}`) + return json({ + access_token, + refresh_token, + token_type: "Bearer", + expires_in: 3600, + scope: pending.scope, + }) + } + if (grant_type === "refresh_token") { + const rt = form.get("refresh_token")?.toString() ?? "" + const existing = refreshTokens.get(rt) + if (!existing) return json({ error: "invalid_grant" }, { status: 400 }) + // rotate + refreshTokens.delete(rt) + tokens.delete(existing.access_token) + const access_token = "mocka_" + rnd(24) + const refresh_token = "mockr_" + rnd(24) + const tok: Token = { ...existing, access_token, refresh_token, expiresAt: Date.now() + 3600 * 1000 } + tokens.set(access_token, tok) + refreshTokens.set(refresh_token, tok) + return json({ + access_token, + refresh_token, + token_type: "Bearer", + expires_in: 3600, + scope: tok.scope, + }) + } + return json({ error: "unsupported_grant_type" }, { status: 400 }) + }, + + // MCP endpoint — minimal JSON-RPC 2.0 / streamable HTTP. Requires bearer. + "POST /mcp": async (req) => { + const auth = req.headers.get("authorization") ?? "" + if (!auth.startsWith("Bearer ")) { + return new Response("Unauthorized", { status: 401, headers: { "WWW-Authenticate": `Bearer realm="${PUBLIC_BASE}"` } }) + } + const token = auth.slice(7) + if (!tokens.has(token)) { + return new Response("Unauthorized", { status: 401 }) + } + let payload: any + try { + payload = await req.json() + } catch { + return json({ jsonrpc: "2.0", error: { code: -32700, message: "parse error" }, id: null }, { status: 400 }) + } + const id = payload.id ?? null + const method = payload.method + if (method === "initialize") { + return json({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + serverInfo: { name: "mock-mcp", version: "0.1.0" }, + capabilities: { tools: {} }, + }, + }) + } + if (method === "tools/list") { + return json({ + jsonrpc: "2.0", + id, + result: { + tools: [ + { + name: "mock_echo", + description: "Echoes back the input — a smoke test for OAuth-protected MCP.", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + }, + ], + }, + }) + } + if (method === "tools/call") { + const name = payload.params?.name + const args = payload.params?.arguments ?? {} + if (name === "mock_echo") { + return json({ + jsonrpc: "2.0", + id, + result: { + content: [{ type: "text", text: `mock-mcp echoes: ${String(args.text ?? "")}` }], + }, + }) + } + return json({ jsonrpc: "2.0", id, error: { code: -32601, message: `unknown tool ${name}` } }) + } + if (method === "notifications/initialized") { + return new Response(null, { status: 204 }) + } + return json({ jsonrpc: "2.0", id, error: { code: -32601, message: `unknown method ${method}` } }) + }, + + "GET /": () => + html(` +<!doctype html><meta charset="utf-8"/> +<title>mock-mcp</title> +<style>body{font:14px system-ui;padding:2em;max-width:700px;margin:auto;line-height:1.5}</style> +<h1>mock-mcp dev server</h1> +<p>Running on <code>${PUBLIC_BASE}</code>. Endpoints:</p> +<ul> +<li><code>GET /.well-known/oauth-protected-resource</code></li> +<li><code>GET /.well-known/oauth-authorization-server</code></li> +<li><code>POST /oauth/register</code></li> +<li><code>GET /oauth/authorize</code></li> +<li><code>POST /oauth/token</code></li> +<li><code>POST /mcp</code> (Bearer required)</li> +</ul> +<p>In your loopat workspace claude.json, add:</p> +<pre>{ + "mcpServers": { + "mock": { + "type": "http", + "url": "${PUBLIC_BASE}/mcp" + } + } +}</pre> +`), +} + +const server = Bun.serve({ + port: PORT, + hostname: HOST, + fetch(req) { + const url = new URL(req.url) + const key = `${req.method} ${url.pathname}` + const h = handlers[key] + if (!h) return new Response("not found", { status: 404 }) + try { + return h(req, url) + } catch (e: any) { + console.error(`[mock-mcp] handler error ${key}:`, e) + return new Response("server error", { status: 500 }) + } + }, +}) + +console.log(`[mock-mcp] listening on http://${HOST}:${PORT} (public base: ${PUBLIC_BASE})`) +console.log(`[mock-mcp] add the snippet at ${PUBLIC_BASE}/ to your workspace claude.json`) diff --git a/scripts/production_start.sh b/scripts/production_start.sh new file mode 100755 index 00000000..c2523ab8 --- /dev/null +++ b/scripts/production_start.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# production_start.sh — infinite-loop launcher for the loopat production server. +# +# Designed to pair with simple_ci.sh: +# simple_ci.sh — polls git, builds on update, pkills the server process +# production_start.sh — restarts the server whenever it exits (crash, CI kill, OOM) +# +# Usage: +# ./scripts/production_start.sh # foreground, logs to stdout +# ./scripts/production_start.sh & # background (or use a process manager) +# LOG_FILE=/var/log/loopat.log ./scripts/production_start.sh # custom log path +# +# Stop: kill the script's pid. The script traps SIGTERM/SIGINT and stops the +# running server before exiting. +# +# Env vars (all optional): +# HOST Bind address for main server (default: 0.0.0.0) +# LOOPAT_SERVE_HOST Bind address for workspace serve (default: 0.0.0.0) +# LOG_FILE Path to log file (default: .run/production.log) +# RESTART_DELAY Seconds to wait before restarting (default: 3) +# BUILD_BEFORE_START If set, run bun run build before starting + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +RUN_DIR="$REPO_ROOT/.run" + +LOG_FILE="${LOG_FILE:-$RUN_DIR/production.log}" +RESTART_DELAY="${RESTART_DELAY:-3}" + +export HOST="${HOST:-127.0.0.1}" +export LOOPAT_SERVE_HOST="${LOOPAT_SERVE_HOST:-127.0.0.1}" +export NODE_ENV=production + +mkdir -p "$RUN_DIR" + +log() { echo "[loopat $(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; } + +# pid of the running server child (set in run_server, cleared on exit) +SERVER_PID="" + +cleanup() { + log "caught signal, shutting down..." + if [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then + log "stopping server (pid $SERVER_PID)..." + kill -TERM "$SERVER_PID" 2>/dev/null || true + for _ in $(seq 1 30); do + kill -0 "$SERVER_PID" 2>/dev/null || break + sleep 0.5 + done + if kill -0 "$SERVER_PID" 2>/dev/null; then + log "server did not stop — SIGKILL" + kill -KILL "$SERVER_PID" 2>/dev/null || true + fi + fi + log "exiting" + exit 0 +} + +trap cleanup SIGTERM SIGINT SIGHUP + +run_server() { + cd "$REPO_ROOT" + + if [[ -n "${BUILD_BEFORE_START:-}" ]]; then + log "building before start..." + bun install && (cd web && bun run build) || { + log "BUILD FAILED — will retry on next loop iteration" + return 1 + } + fi + + log "starting server (HOST=$HOST NODE_ENV=$NODE_ENV)..." + bun run server/src/index.ts & + SERVER_PID=$! + + echo "$SERVER_PID" > "$RUN_DIR/server.pid" + + wait "$SERVER_PID" || true + local exit_code=$? + SERVER_PID="" + + log "server exited (code=${exit_code})" + return 0 +} + +log "=== loopat production launcher starting (pid=$$) ===" +log "log file: $LOG_FILE" +log "restart delay: ${RESTART_DELAY}s" + +iteration=0 +while true; do + iteration=$((iteration + 1)) + log "--- iteration $iteration ---" + + run_server + + log "restarting in ${RESTART_DELAY}s..." + sleep "$RESTART_DELAY" +done diff --git a/scripts/setup-demo.sh b/scripts/setup-demo.sh new file mode 100755 index 00000000..5a3c0b27 --- /dev/null +++ b/scripts/setup-demo.sh @@ -0,0 +1,439 @@ +#!/usr/bin/env bash +# Build a rich LOOPAT_HOME demo workspace at /tmp/loopat-demo showing the +# tiered .claude/ model end-to-end with multiple profiles, plugins, and users. +# +# Layout (all 5 layers of the merge model represented): +# +# /tmp/loopat-demo/ +# ├── context/ +# │ ├── knowledge/ ← team git repo (SoT) +# │ │ ├── architecture.md ← team docs +# │ │ ├── runbook-overview.md +# │ │ ├── marketplace/ ← team's CC marketplace +# │ │ │ ├── .claude-plugin/marketplace.json +# │ │ │ ├── internal-mcp/ +# │ │ │ ├── pagerduty-mcp/ +# │ │ │ └── deploy-cli/ +# │ │ └── .loopat/ +# │ │ ├── .claude/ ← layer 1: team +# │ │ │ ├── settings.json +# │ │ │ ├── CLAUDE.md +# │ │ │ ├── skills/ +# │ │ │ └── agents/ +# │ │ └── profiles/ ← layer 2: profiles (×6) +# │ │ ├── role-eng-backend/.claude/ +# │ │ ├── role-eng-frontend/.claude/ +# │ │ ├── role-eng-ml/.claude/ +# │ │ ├── role-security/.claude/ +# │ │ ├── mode-oncall/.claude/ +# │ │ ├── mode-review/.claude/ +# │ │ └── mode-incident/.claude/ +# │ ├── notes/ ← team memory +# │ │ └── memory/ +# │ └── repos/ +# │ └── acme-api/ ← workdir mock; layer 5 (repo) +# │ ├── README.md +# │ ├── src/ +# │ └── .claude/ ← project-tier .claude/ +# │ ├── settings.json +# │ └── CLAUDE.md +# ├── personal/ ← layer 4: personal (×3) +# │ ├── alice/ (eng-backend + security) +# │ ├── bob/ (eng-frontend + review) +# │ └── carol/ (sre, oncall by default) +# └── loops/ ← populated on `loopat run` +set -euo pipefail + +LOOPAT_HOME="${LOOPAT_HOME:-/tmp/loopat-demo}" + +echo "Setting up demo workspace: $LOOPAT_HOME" + +if [[ -d "$LOOPAT_HOME" ]]; then + echo "[!] $LOOPAT_HOME exists. Remove and rebuild? (y/N)" + read -r ans + [[ "$ans" == "y" || "$ans" == "Y" ]] || { echo "abort"; exit 1; } + rm -rf "$LOOPAT_HOME" +fi + +mkdir -p "$LOOPAT_HOME/loops" + +# ─── workspace knowledge git repo ─────────────────────────────────────── +WS="$LOOPAT_HOME/context/knowledge" +mkdir -p "$WS" + +cat > "$WS/architecture.md" <<'EOF' +# Acme System Architecture + +API gateway → product / order / notification services → Postgres + Redis + Kafka. +Worker pool consumes from Kafka for async tasks. +EOF + +cat > "$WS/runbook-overview.md" <<'EOF' +# Runbook Overview + +- DB issues: see runbook/db.md +- API 5xx spikes: see runbook/api.md +- Worker queue backup: see runbook/worker.md +EOF + +# Team-tier .claude/ (layer 1) +TEAM="$WS/.loopat/.claude" +mkdir -p "$TEAM/skills" "$TEAM/agents" +cat > "$TEAM/settings.json" <<'EOF' +{ + "_comment": "Team-tier CC config. Shared via knowledge git. Marketplace at knowledge/marketplace/.", + "enabledPlugins": {}, + "extraKnownMarketplaces": { + "acme-internal": { + "source": { "source": "directory", "path": "../../marketplace" } + } + } +} +EOF +# Team-tier mise.toml — baseline toolchain everyone gets +cat > "$TEAM/mise.toml" <<'EOF' +[tools] +node = "20" + +[env] +NODE_ENV = "development" +EOF + +cat > "$TEAM/CLAUDE.md" <<'EOF' +# Acme · team baseline + +> Loaded for every loop. Lowest precedence — overridable by profile / personal. + +## Conventions +- Conventional Commits (feat / fix / chore) +- Never push to `main`; always go through PR +- Never `git push --force` to shared branches + +## Safety +- Credentials live in `personal/<user>/.loopat/vaults/<v>/`, never in the repo +- Ask before destructive ops (rm -rf, drop table, branch -D) +EOF + +# Team skill: always-on team helper +mkdir -p "$TEAM/skills/team-onboarding" +cat > "$TEAM/skills/team-onboarding/SKILL.md" <<'EOF' +--- +name: team-onboarding +description: Guide new team members through their loopat setup. +--- + +# Acme team onboarding + +Walk new members through: personal config, default profiles, vault setup. +EOF + +# Team agent (subagent that can be delegated to) +cat > "$TEAM/agents/code-archaeologist.md" <<'EOF' +--- +name: code-archaeologist +description: Trace the history of a code path or decision through commits + notes. +--- + +You are a code archaeologist. Given a file or symbol, walk git log, blame, +and team notes to surface why it exists in its current form. +EOF + +# ─── Profiles (layer 2) ───────────────────────────────────────────────── +PROFILES="$WS/.loopat/profiles" + +make_profile() { + local name="$1" claude_md="$2" plugin_spec="$3" + local pdir="$PROFILES/$name/.claude" + mkdir -p "$pdir/skills" "$pdir/agents" + if [[ -n "$plugin_spec" ]]; then + cat > "$pdir/settings.json" <<EOF +{ "enabledPlugins": { $plugin_spec } } +EOF + else + echo '{ "enabledPlugins": {} }' > "$pdir/settings.json" + fi + printf '%s\n' "$claude_md" > "$pdir/CLAUDE.md" +} + +make_profile "role-eng-backend" '# Role · Backend Engineer + +- Schema changes need staging migration dry-run first +- Hot-path edits need benchmarks +- DB tools are read-only by default +- Use internal-mcp to query services without curl' \ + '"internal-mcp@acme-internal": true, "deploy-cli@acme-internal": true' + +# backend role brings go toolchain +cat > "$PROFILES/role-eng-backend/.claude/mise.toml" <<'EOF' +[tools] +go = "1.22" +golangci-lint = "1.62" + +[env] +GOFLAGS = "-mod=readonly" +EOF + +make_profile "role-eng-frontend" '# Role · Frontend Engineer + +- Storybook stories accompany every new component +- Accessibility checks via `make a11y` before merging +- Bundle size deltas commented on PR' \ + '' + +make_profile "role-eng-ml" '# Role · ML Engineer + +- Experiment tracking in MLflow; commit run IDs to notes +- Dataset versioning via DVC +- GPU jobs go through the cluster scheduler, not local' \ + '' + +# ML role brings python toolchain +cat > "$PROFILES/role-eng-ml/.claude/mise.toml" <<'EOF' +[tools] +python = "3.12" +uv = "latest" + +[env] +PYTHONDONTWRITEBYTECODE = "1" +EOF + +make_profile "role-security" '# Role · Security + +- Review auth / crypto / SQL paths first +- Credentials always from vault, never inline +- Run audit on every new MCP integration' \ + '' + +make_profile "mode-oncall" 'You are on call. Prioritize stability. Defer new features. +Use the runbook search before changing anything in prod. +Escalate to SRE lead if errors > 5% within 15 minutes.' \ + '"pagerduty-mcp@acme-internal": true' + +make_profile "mode-review" 'You are a reviewer. Do not write new code. +Check correctness > security > tests > style. +Every comment must include an actionable suggestion.' \ + '' + +make_profile "mode-incident" 'INCIDENT mode. Focus: triage, communicate, restore. +- Acknowledge the page in <2 minutes +- Open #incident-<service>-<date> if customer-facing +- Rollback before debug if user impact is active' \ + '"pagerduty-mcp@acme-internal": true' + +# Profile-specific skill (only loaded when role-eng-backend is active) +mkdir -p "$PROFILES/role-eng-backend/.claude/skills/db-explore" +cat > "$PROFILES/role-eng-backend/.claude/skills/db-explore/SKILL.md" <<'EOF' +--- +name: db-explore +description: Read-only database exploration helper for backend engineers. +--- + +Run safe SELECT queries against staging. Never modify production data. +EOF + +# Profile-specific skill for oncall +mkdir -p "$PROFILES/mode-oncall/.claude/skills/runbook-search" +cat > "$PROFILES/mode-oncall/.claude/skills/runbook-search/SKILL.md" <<'EOF' +--- +name: runbook-search +description: Search Acme runbooks by symptom keyword. Use during incidents. +--- + +Given a symptom phrase, fuzzy-match against runbook/*.md and surface top 3 hits. +EOF + +# ─── Team marketplace (3 plugins) ─────────────────────────────────────── +MP="$WS/marketplace" +mkdir -p "$MP/.claude-plugin" +cat > "$MP/.claude-plugin/marketplace.json" <<'EOF' +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "acme-internal", + "description": "Acme private plugin marketplace", + "owner": { "name": "Acme Platform" }, + "plugins": [ + { "name": "internal-mcp", "source": "./internal-mcp", "description": "Internal services MCP" }, + { "name": "pagerduty-mcp", "source": "./pagerduty-mcp", "description": "PagerDuty MCP wrapper" }, + { "name": "deploy-cli", "source": "./deploy-cli", "description": "Deploy tooling (dev/staging/prod)" } + ] +} +EOF + +make_plugin() { + local name="$1" desc="$2" mcp_env="$3" + local d="$MP/$name" + mkdir -p "$d/.claude-plugin" + cat > "$d/.claude-plugin/plugin.json" <<EOF +{ "name": "$name", "version": "1.0.0", "description": "$desc", "author": { "name": "Acme Platform" } } +EOF + # Plugin-shipped MCP servers go in the plugin's settings.json mcpServers + # field. loopat's compose merges these as low-priority defaults — team / + # profile / personal mcpServers with the same key win. + if [[ -n "$mcp_env" ]]; then + cat > "$d/settings.json" <<EOF +{ "mcpServers": { "$name": { "command": "node", "args": ["\${CLAUDE_PLUGIN_ROOT}/server.js"], "env": $mcp_env } } } +EOF + fi +} + +make_plugin "internal-mcp" "Internal services MCP" '{ "INTERNAL_API_TOKEN": "${INTERNAL_API_TOKEN}" }' +make_plugin "pagerduty-mcp" "PagerDuty MCP wrapper" '{ "PAGERDUTY_TOKEN": "${PAGERDUTY_TOKEN}" }' +make_plugin "deploy-cli" "Deploy tooling" "" + +# Add slash commands + skills so plugins appear in CC's `/` menu + +# pagerduty-mcp: a skill (auto-triggered or via /pagerduty-mcp:ack-incident) +mkdir -p "$MP/pagerduty-mcp/skills/ack-incident" +cat > "$MP/pagerduty-mcp/skills/ack-incident/SKILL.md" <<'EOF' +--- +name: ack-incident +description: Acknowledge a PagerDuty incident by ID or by symptom keywords. +--- + +# Acknowledge incident + +Given an incident ID, call pagerduty.ack(id). If the user described symptoms +instead of an ID, search for the most recent open incident matching, then ack. +EOF + +# internal-mcp: a skill — invokable as /internal-mcp:db-explore +mkdir -p "$MP/internal-mcp/skills/db-explore" +cat > "$MP/internal-mcp/skills/db-explore/SKILL.md" <<'EOF' +--- +name: db-explore +description: Open a read-only DB exploration session against the internal services +--- + +# DB explore + +Use the internal-mcp `db.query` tool to run safe SELECT statements. +Refuse any write operation; suggest creating a migration PR instead. +EOF + +# deploy-cli: a skill — invokable as /deploy-cli:deploy +mkdir -p "$MP/deploy-cli/skills/deploy" +cat > "$MP/deploy-cli/skills/deploy/SKILL.md" <<'EOF' +--- +name: deploy +description: Deploy current branch to a chosen environment +--- + +# Deploy + +Ask the user: which env? (dev / staging / prod). For prod, require an extra +confirmation. Then invoke the deploy tool and watch for completion. +EOF + +# ─── Notes (team memory) ──────────────────────────────────────────────── +mkdir -p "$LOOPAT_HOME/context/notes/memory" +cat > "$LOOPAT_HOME/context/notes/memory/MEMORY.md" <<'EOF' +# Team memory index + +- [API rate-limit history](api-ratelimit-history.md) +- [Postgres failover lessons](postgres-failover-lessons.md) +EOF +cat > "$LOOPAT_HOME/context/notes/memory/api-ratelimit-history.md" <<'EOF' +# API rate-limit history + +2025-Q3 we moved from leaky-bucket to token-bucket to handle burst traffic. +Constants live in `services/api-gateway/config/ratelimit.go`. +EOF + +# ─── Mock workdir (layer 5: repo .claude/) ────────────────────────────── +REPO="$LOOPAT_HOME/context/repos/acme-api" +mkdir -p "$REPO/src" "$REPO/.claude" +cat > "$REPO/README.md" <<'EOF' +# acme-api + +Internal API gateway. Routes requests to product/order/notification services. +EOF +cat > "$REPO/src/main.go" <<'EOF' +package main + +func main() { + // placeholder +} +EOF +cat > "$REPO/.claude/settings.json" <<'EOF' +{ + "_comment": "Repo-tier (CC project-tier). Highest precedence in the merge.", + "enabledPlugins": {} +} +EOF +cat > "$REPO/.claude/CLAUDE.md" <<'EOF' +# acme-api repo conventions + +- Go modules; minimum Go 1.22 +- `make test` runs unit tests + race detector +- API contracts in `api/v1/*.proto` — regenerate with `make proto` +EOF +(cd "$REPO" && git init -q -b main && git add -A && git -c user.email=a@b -c user.name=demo commit -qm "init") + +# ─── Personal users (layer 4) ─────────────────────────────────────────── +make_personal() { + local user="$1" profiles_json="$2" + local pers="$LOOPAT_HOME/personal/$user" + mkdir -p "$pers/.claude/skills" "$pers/.claude/agents" "$pers/.loopat/vaults/dev" + + cat > "$pers/.claude/settings.json" <<'EOF' +{ "_comment": "Personal CC config — only this user sees it." } +EOF + cat > "$pers/.claude/CLAUDE.md" <<EOF +# $user · personal preferences + +- Prefer direct patches over explanations +EOF + cat > "$pers/.loopat/config.json" <<EOF +{ + "_comment": "$user's loopat personal config — loopat-specific (not CC).", + "default_profiles": $profiles_json, + "default_vault": "dev" +} +EOF + echo -n "placeholder-internal-token-$user" > "$pers/.loopat/vaults/dev/INTERNAL_API_TOKEN" + echo -n "placeholder-pagerduty-token-$user" > "$pers/.loopat/vaults/dev/PAGERDUTY_TOKEN" + echo -n "placeholder-github-token-$user" > "$pers/.loopat/vaults/dev/GITHUB_TOKEN" +} + +# 3 users with different default profiles +make_personal "alice" '["role-eng-backend", "role-security"]' +make_personal "bob" '["role-eng-frontend", "mode-review"]' +make_personal "carol" '["role-security", "mode-oncall"]' + +# Initialize knowledge as a git repo (loopat expects this in production) +(cd "$WS" && git init -q -b main && git add -A && git -c user.email=setup@local -c user.name=setup commit -qm "initial demo workspace") + +cat <<EOF + +✓ Demo workspace ready at: $LOOPAT_HOME + +Layout summary: + team layer: knowledge/.loopat/.claude/ (1 skill, 1 agent) + profiles: 7 profiles (4 role-*, 3 mode-*) + marketplace: knowledge/marketplace/ (3 plugins) + notes: notes/memory/ (sample entries) + repos: repos/acme-api/ (with own .claude/) + personal users: alice, bob, carol (different default_profiles) + +Try: + export LOOPAT_HOME=$LOOPAT_HOME + + # list available profiles + bun scripts/loopat.ts list + + # dry-run for each user (different default_profiles) + bun scripts/loopat.ts run --user alice --dry-run + bun scripts/loopat.ts run --user bob --dry-run + bun scripts/loopat.ts run --user carol --dry-run + + # stress: load many profiles at once + bun scripts/loopat.ts run --user alice +mode-oncall +mode-incident +role-eng-ml --dry-run + + # run with repo layer (5th merge source) + bun scripts/loopat.ts run --user alice --dry-run + # (workdir mounting: would need to set up loop with repo=acme-api in real spawn) + + # full bwrap end-to-end + bun scripts/loopat.ts run --user alice +mode-oncall --bwrap + +EOF diff --git a/scripts/simple_ci.sh b/scripts/simple_ci.sh new file mode 100755 index 00000000..74c9d177 --- /dev/null +++ b/scripts/simple_ci.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TARGET_BRANCH_FILE="$HOME/.loopat/personal/panlilu/target_branch" +SLEEP_SEC=300 + +log() { echo "[ci $(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; } + +cd "$REPO_ROOT" + +while true; do + branch="main" + if [[ -f "$TARGET_BRANCH_FILE" ]]; then + branch="$(< "$TARGET_BRANCH_FILE")" + branch="${branch:-main}" + fi + + log "checking branch: $branch" + + git fetch origin "$branch" 2>&1 | tail -1 || { log "fetch failed, will retry"; sleep "$SLEEP_SEC"; continue; } + + local_sha=$(git rev-parse "$branch" 2>/dev/null || echo "") + remote_sha=$(git rev-parse "origin/$branch" 2>/dev/null || echo "") + + if [[ -z "$local_sha" ]]; then + log "local branch $branch does not exist, creating from origin/$branch" + git checkout -b "$branch" "origin/$branch" + bash "$REPO_ROOT/scripts/build-to-nginx.sh" + pkill -f "bun run server/src/index.ts" 2>/dev/null || true + log "deployed $remote_sha, server restart signaled" + elif [[ "$local_sha" != "$remote_sha" ]]; then + log "update detected: $local_sha -> $remote_sha" + git checkout "$branch" + git reset --hard "origin/$branch" + bash "$REPO_ROOT/scripts/build-to-nginx.sh" + pkill -f "bun run server/src/index.ts" 2>/dev/null || true + log "deployed $remote_sha, server restart signaled" + else + log "up to date at $local_sha" + fi + + sleep "$SLEEP_SEC" +done diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 00000000..9a38cd9f --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Launch loopat in the background (nohup'd `bun run dev`). pid → .run/server.pid, +# log → .run/server.log. Stop with scripts/stop.sh. +# +# bun --hot picks up file changes, so `git pull` is usually enough for an +# update — no restart needed. If a pull touches deps or schema, run +# scripts/stop.sh && scripts/start.sh. +set -euo pipefail +cd "$(dirname "$0")/.." + +mkdir -p .run +if [ -f .run/server.pid ] && kill -0 "$(cat .run/server.pid)" 2>/dev/null; then + echo "already running (pid $(cat .run/server.pid)) — use scripts/stop.sh first" + exit 1 +fi + +# Default LAN-accessible. Override with HOST=127.0.0.1 for local-only. +export HOST="${HOST:-0.0.0.0}" +export LOOPAT_SERVE_HOST="${LOOPAT_SERVE_HOST:-0.0.0.0}" + +nohup bun run dev > .run/server.log 2>&1 & +echo $! > .run/server.pid + +echo "loopat started" +echo " pid: $(cat .run/server.pid)" +echo " log: .run/server.log (tail -f .run/server.log)" +echo " stop: scripts/stop.sh" diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100755 index 00000000..0831acfd --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Stop the loopat server started by scripts/start.sh. +# Kills the whole process group (bun run dev + its server/vite children). +set -euo pipefail +cd "$(dirname "$0")/.." + +PID_FILE=.run/server.pid +[ -f "$PID_FILE" ] || { echo "not running (no $PID_FILE)"; exit 0; } +PID=$(cat "$PID_FILE") + +if ! kill -0 "$PID" 2>/dev/null; then + echo "stale pid $PID — removing pid file" + rm "$PID_FILE" + exit 0 +fi + +# Negative pid = process group. Catches bun + vite + server children. +kill -TERM -- -"$PID" 2>/dev/null || kill -TERM "$PID" 2>/dev/null || true + +for _ in $(seq 1 20); do + kill -0 "$PID" 2>/dev/null || { rm "$PID_FILE"; echo "stopped"; exit 0; } + sleep 0.25 +done + +echo "did not exit in 5s — SIGKILL" +kill -KILL -- -"$PID" 2>/dev/null || kill -KILL "$PID" 2>/dev/null || true +rm "$PID_FILE" +echo "killed" diff --git a/scripts/stop_all.sh b/scripts/stop_all.sh new file mode 100755 index 00000000..9ed217ff --- /dev/null +++ b/scripts/stop_all.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# stop_all.sh — kill every loopat-related process on this host. +# +# Pairs with production_start.sh / start.sh. Targets: +# 1. production_start.sh wrapper (else it'll relaunch the server we kill) +# 2. dev/prod server tree under this repo (bun run dev → --hot index.ts + +# vite, or bun run server/src/index.ts) +# 3. sandboxed loop processes (bwrap) whose bind args reference LOOPAT_HOME +# +# Conservative by design: matches only on absolute paths into this repo or +# LOOPAT_HOME, never blindly `pkill bun` / `pkill claude` — you might run +# unrelated bun or claude sessions on the same machine. +# +# Usage: +# ./scripts/stop_all.sh # SIGTERM, escalate to SIGKILL after 7.5s +# ./scripts/stop_all.sh --dry-run # list what would be killed, take no action +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LOOPAT_HOME_DIR="${LOOPAT_HOME:-$HOME/.loopat}" + +DRY_RUN=0 +[[ "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]] && DRY_RUN=1 + +# Absolute-path-anchored command-line patterns — catch anything whose cmd +# string itself names this repo or LOOPAT_HOME (vite spawn, bwrap binds, etc.) +PATTERNS=( + "${REPO_ROOT}/scripts/production_start\\.sh" + "${REPO_ROOT}.*server/src/index\\.ts" + "${REPO_ROOT}/web/node_modules/.*vite" + "bwrap.*${LOOPAT_HOME_DIR}" +) + +# Collect pids: cmd-pattern matches + cwd matches + pid file. Dedup at end. +pids=() + +# (a) Command-line pattern matches +for pat in "${PATTERNS[@]}"; do + while IFS= read -r pid; do + [[ -n "$pid" ]] && pids+=("$pid") + done < <(pgrep -f -- "$pat" || true) +done + +# (b) Procs whose cwd lives inside the repo — catches `bun run dev` started +# from the repo root, since the bun cmd line itself has no absolute +# paths. Also picks up child bun --hot procs which inherit the cwd. +for proc_cwd in /proc/[0-9]*/cwd; do + pid="${proc_cwd#/proc/}"; pid="${pid%/cwd}" + resolved="$(readlink -f "$proc_cwd" 2>/dev/null || true)" + [[ -z "$resolved" ]] && continue + if [[ "$resolved" == "$REPO_ROOT" || "$resolved" == "$REPO_ROOT"/* ]]; then + # Filter to actual loopat-process names so we don't match unrelated + # editors / shells someone has open in this dir. + # Whitelist of process names that belong to the dev/prod server tree. + # Notably NOT `claude` — that's the user's interactive CLI session; loopat + # itself only spawns CC via bwrap, which is caught by the cmd-line pattern. + comm="$(cat /proc/"$pid"/comm 2>/dev/null || true)" + case "$comm" in + bun|node|vite) pids+=("$pid") ;; + esac + fi +done + +# (c) Pid file (start.sh / production_start.sh writes it) +if [[ -f "$REPO_ROOT/.run/server.pid" ]]; then + pid_from_file="$(cat "$REPO_ROOT/.run/server.pid" 2>/dev/null || true)" + [[ -n "${pid_from_file:-}" ]] && pids+=("$pid_from_file") +fi + +# Filter: skip ourselves, skip non-existent pids +me="$$" +uniq_pids=() +declare -A seen=() +for pid in "${pids[@]}"; do + [[ "$pid" == "$me" ]] && continue + [[ -n "${seen[$pid]:-}" ]] && continue + kill -0 "$pid" 2>/dev/null || continue + seen[$pid]=1 + uniq_pids+=("$pid") +done + +if [[ ${#uniq_pids[@]} -eq 0 ]]; then + echo "no loopat processes found" + rm -f "$REPO_ROOT/.run/server.pid" + exit 0 +fi + +echo "found ${#uniq_pids[@]} pid(s):" +ps -o pid,ppid,pgid,etime,cmd -p "${uniq_pids[@]}" 2>/dev/null || true + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo + echo "(dry-run — no signals sent)" + exit 0 +fi + +echo +echo "sending SIGTERM..." +for pid in "${uniq_pids[@]}"; do + kill -TERM "$pid" 2>/dev/null || true +done + +# Poll up to 7.5s for graceful exit. +for _ in $(seq 1 30); do + still=() + for pid in "${uniq_pids[@]}"; do + kill -0 "$pid" 2>/dev/null && still+=("$pid") + done + if [[ ${#still[@]} -eq 0 ]]; then + rm -f "$REPO_ROOT/.run/server.pid" + echo "all stopped" + exit 0 + fi + sleep 0.25 +done + +echo "${#still[@]} pid(s) didn't exit in 7.5s — SIGKILL: ${still[*]}" +for pid in "${still[@]}"; do + kill -KILL "$pid" 2>/dev/null || true +done + +rm -f "$REPO_ROOT/.run/server.pid" +echo "killed" diff --git a/server/package.json b/server/package.json new file mode 100644 index 00000000..1c267e9b --- /dev/null +++ b/server/package.json @@ -0,0 +1,22 @@ +{ + "name": "@loopat/server", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --hot src/index.ts", + "serve": "bun run --hot src/serve.ts", + "test": "bun test" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.150", + "@anthropic-ai/sandbox-runtime": "^0.0.52", + "@scalar/hono-api-reference": "^0.10.19", + "bun-pty": "^0.4.8", + "hono": "^4.12.18", + "smol-toml": "^1.6.1" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.6.0" + } +} diff --git a/server/src/a2a.ts b/server/src/a2a.ts new file mode 100644 index 00000000..4158a050 --- /dev/null +++ b/server/src/a2a.ts @@ -0,0 +1,319 @@ +/** + * A2A (Agent-to-Agent) adapter — in-process, standard A2A (Google's Agent2Agent). + * + * Each loopat account is its own A2A agent: + * - GET /a2a/<user>/agent-card.json → that user's Agent Card + * - POST /a2a/<user> → JSON-RPC (message/send | message/stream) + * + * Auth is per-user: the caller presents that user's loopat API token + * (`Authorization: Bearer la_…`). We validate the token resolves to <user> and + * forward it to loopat's own `/api/v1` over loopback — which already does + * per-token-user auth, loop creation, turn execution, and SSE — so this adapter + * is a thin protocol translator (A2A ↔ /api/v1 SSE) that can't break web or + * /api/v1. The A2A `contextId` (conversation) maps to a loopat loop. + */ +import { Hono, type Context } from "hono" +import { streamSSE } from "hono/streaming" +import { randomBytes } from "node:crypto" +import { resolveApiToken } from "./api-tokens" +import { loadA2AConfig, type A2AUserConfig } from "./config" + +const A2A_PROTOCOL_VERSION = "0.3.0" +const SELF_BASE = `http://127.0.0.1:${process.env.PORT ?? 10001}` + +// contextId (A2A conversation) → loopat loopId. In-memory: a restart starts a +// fresh conversation (acceptable for v1). +const ctxToLoop = new Map<string, string>() + +function resolvePublicUrl(c: Context): string { + const configured = process.env.LOOPAT_A2A_PUBLIC_URL + if (configured) return configured.replace(/\/+$/, "") + const proto = c.req.header("x-forwarded-proto") ?? "http" + const host = c.req.header("host") ?? `127.0.0.1:${process.env.PORT ?? 10001}` + return `${proto}://${host}` +} + +function agentCard(user: string, publicUrl: string, cfg: A2AUserConfig) { + const name = cfg.card?.name?.trim() || `Loopat Agent (${user})` + const description = cfg.card?.description?.trim() || + "Self-hosted AI workspace (Claude Agent SDK). Runs development tasks in an isolated sandbox: writing code, reading repos, editing files, running commands, and researching." + return { + protocolVersion: A2A_PROTOCOL_VERSION, + name, + description, + url: `${publicUrl}/a2a/${encodeURIComponent(user)}`, + version: "1.0.0", + preferredTransport: "JSONRPC", + capabilities: { streaming: true, pushNotifications: false, stateTransitionHistory: false }, + defaultInputModes: ["text/plain", "application/json"], + defaultOutputModes: ["text/plain"], + securitySchemes: { + bearer: { type: "http", scheme: "bearer", description: "A loopat API token belonging to this user." }, + }, + security: [{ bearer: [] }], + skills: [ + { + id: "loopat_dev_turn", + name: "General development task", + description: + "Run one development task in a loopat sandbox: write/edit code, read the repo, run commands, research, and return the result. Multi-turn within a conversation (reuse contextId to continue).", + tags: ["coding", "agent", "dev", "sandbox"], + examples: ["Add a health-check endpoint to the repo and make the tests pass."], + inputModes: ["text/plain", "application/json"], + outputModes: ["text/plain"], + }, + ], + } +} + +type ParsedMsg = { text: string; contextId?: string; taskId?: string } + +function parseMessage(params: any): ParsedMsg { + const message = params?.message ?? {} + let text = "" + const parts = Array.isArray(message.parts) ? message.parts : [] + for (const p of parts) { + if (p?.kind === "text" && typeof p.text === "string") { text += p.text } + else if (p?.kind === "data" && p.data) { + const d = p.data + text += typeof d.message === "string" ? d.message + : typeof d.text === "string" ? d.text + : typeof d === "string" ? d : "" + } + } + return { + text: text.trim(), + contextId: typeof message.contextId === "string" && message.contextId ? message.contextId : undefined, + taskId: typeof message.taskId === "string" && message.taskId ? message.taskId : undefined, + } +} + +async function getOrCreateLoop(parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig): Promise<{ loopId: string; contextId: string }> { + let contextId = parsed.contextId + if (contextId && ctxToLoop.has(contextId)) return { loopId: ctxToLoop.get(contextId)!, contextId } + if (!contextId) contextId = `ctx_${randomBytes(8).toString("hex")}` + + const title = parsed.text.slice(0, 60) || "a2a" + const res = await fetch(`${SELF_BASE}/api/v1/loops`, { + method: "POST", + headers: { authorization: callerAuth, "content-type": "application/json" }, + body: JSON.stringify({ + title, + profiles: cfg.profiles && cfg.profiles.length ? cfg.profiles : undefined, + vault: cfg.vault || undefined, + metadata: { a2a_context_id: contextId }, + }), + }) + if (!res.ok) { + const body = await res.text().catch(() => "") + throw new Error(`loop create failed: ${res.status} ${body}`) + } + const loop = await res.json() + const loopId = loop.id as string + ctxToLoop.set(contextId, loopId) + return { loopId, contextId } +} + +type LoopatEvent = { event: string; data: any } + +async function* loopbackTurn(loopId: string, content: string, taskId: string, callerAuth: string): AsyncGenerator<LoopatEvent> { + const res = await fetch(`${SELF_BASE}/api/v1/loops/${loopId}/messages`, { + method: "POST", + headers: { + authorization: callerAuth, + "content-type": "application/json", + accept: "text/event-stream", + "idempotency-key": taskId, + }, + body: JSON.stringify({ content, permission_mode: "bypassPermissions" }), + }) + if (!res.ok || !res.body) { + const body = await res.text().catch(() => "") + yield { event: "error", data: { code: "send_failed", message: `${res.status} ${body}` } } + return + } + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buf = "" + let curEvent = "message" + let curData = "" + const flush = (): LoopatEvent | null => { + if (!curData) { curEvent = "message"; return null } + let parsed: any = curData + try { parsed = JSON.parse(curData) } catch { /* keep string */ } + const ev = { event: curEvent, data: parsed } + curEvent = "message"; curData = "" + return ev + } + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + const lines = buf.split("\n") + buf = lines.pop() ?? "" + for (const line of lines) { + if (line === "") { const ev = flush(); if (ev) yield ev; continue } + if (line.startsWith("event:")) curEvent = line.slice(6).trim() + else if (line.startsWith("data:")) curData += line.slice(5).trim() + } + } + const ev = flush(); if (ev) yield ev +} + +function extractAssistantText(d: any): string | null { + if (!d || d.type !== "assistant") return null + const content = d.message?.content + if (!Array.isArray(content)) return null + const txt = content.filter((b: any) => b?.type === "text" && typeof b.text === "string").map((b: any) => b.text).join("") + return txt || null +} + +function resultError(d: any): string | null { + if (!d || d.type !== "result") return null + if (d.is_error === true) return typeof d.result === "string" && d.result ? d.result : "agent error" + return null +} + +// ── Standard A2A streaming event builders ───────────────────────────────── +function artifactUpdate(reqId: unknown, taskId: string, contextId: string, artifactId: string, text: string, append: boolean, lastChunk: boolean) { + return { + jsonrpc: "2.0", id: reqId, + result: { + kind: "artifact-update", + taskId, contextId, append, lastChunk, + artifact: { artifactId, name: "response", parts: [{ kind: "text", text }] }, + }, + } +} +function statusUpdate(reqId: unknown, taskId: string, contextId: string, state: "working" | "completed" | "failed", final: boolean, errorText?: string) { + const status: Record<string, unknown> = { state } + if (errorText) { + status.message = { + kind: "message", role: "agent", messageId: `msg_${randomBytes(6).toString("hex")}`, + parts: [{ kind: "text", text: errorText }], taskId, contextId, + } + } + return { jsonrpc: "2.0", id: reqId, result: { kind: "status-update", taskId, contextId, status, final } } +} + +async function handleStream(c: Context, reqId: unknown, parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig) { + return streamSSE(c, async (stream) => { + const artifactId = `artifact_${randomBytes(8).toString("hex")}` + const taskId = parsed.taskId || `task_${randomBytes(8).toString("hex")}` + try { + const { loopId, contextId } = await getOrCreateLoop(parsed, callerAuth, cfg) + await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "working", false)) }) + let gotDelta = false + let fallbackText: string | null = null + let errMsg: string | undefined + const finish = async () => { + if (errMsg) { + await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "failed", true, errMsg)) }) + return + } + if (!gotDelta && fallbackText) { + await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, fallbackText, false, false)) }) + } + await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, "", true, true)) }) + await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "completed", true)) }) + } + for await (const ev of loopbackTurn(loopId, parsed.text, taskId, callerAuth)) { + if (ev.event === "assistant_delta" && typeof ev.data?.text === "string") { + await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, ev.data.text, gotDelta, false)) }) + gotDelta = true + } else if (ev.event === "sdk_message") { + const t = extractAssistantText(ev.data); if (t) fallbackText = t + const e = resultError(ev.data); if (e) errMsg = e + } else if (ev.event === "done") { + await finish(); return + } else if (ev.event === "error" || ev.event === "interrupted") { + errMsg = ev.data?.message ?? ev.event + await finish(); return + } + } + await finish() + } catch (e: any) { + const taskIdSafe = taskId + await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskIdSafe, parsed.contextId ?? "", "failed", true, e?.message ?? String(e))) }) + } + }) +} + +async function handleSend(c: Context, reqId: unknown, parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig) { + const artifactId = `artifact_${randomBytes(8).toString("hex")}` + const taskId = parsed.taskId || `task_${randomBytes(8).toString("hex")}` + try { + const { loopId, contextId } = await getOrCreateLoop(parsed, callerAuth, cfg) + let full = "" + let fallbackText: string | null = null + let errMsg: string | undefined + for await (const ev of loopbackTurn(loopId, parsed.text, taskId, callerAuth)) { + if (ev.event === "assistant_delta" && typeof ev.data?.text === "string") full += ev.data.text + else if (ev.event === "sdk_message") { + const t = extractAssistantText(ev.data); if (t) fallbackText = t + const e = resultError(ev.data); if (e) errMsg = e + } + else if (ev.event === "error" || ev.event === "interrupted") errMsg = ev.data?.message ?? ev.event + else if (ev.event === "done") break + } + if (!full && fallbackText) full = fallbackText + if (errMsg && !full) full = errMsg + const success = !errMsg + // A2A Task object. + return c.json({ + jsonrpc: "2.0", + id: reqId, + result: { + kind: "task", + id: taskId, + contextId, + status: { state: success ? "completed" : "failed" }, + artifacts: [{ artifactId, name: "response", parts: [{ kind: "text", text: full }] }], + }, + }) + } catch (e: any) { + return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32603, message: e?.message ?? String(e) } }) + } +} + +export function buildA2A() { + const a = new Hono() + + const cardHandler = async (c: Context) => { + const user = c.req.param("user") + if (!user) return c.json({ error: "not found" }, 404) + const cfg = await loadA2AConfig(user) + return c.json(agentCard(user, resolvePublicUrl(c), cfg)) + } + a.get("/a2a/:user/agent-card.json", cardHandler) + a.get("/a2a/:user/.well-known/agent-card.json", cardHandler) + a.get("/a2a/:user/agent.json", cardHandler) // legacy filename alias + + a.post("/a2a/:user", async (c) => { + const user = c.req.param("user") + const rpc = await c.req.json().catch(() => null) + const reqId = rpc?.id ?? null + if (!rpc || typeof rpc.method !== "string") { + return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32600, message: "invalid request" } }, 400) + } + // Per-user auth: the caller must present THIS user's loopat API token. + const auth = c.req.header("authorization") ?? null + const tokenUser = await resolveApiToken(auth) + if (!tokenUser) { + return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32000, message: "unauthorized: present a loopat API token" } }, 401) + } + if (tokenUser !== user) { + return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32000, message: "token does not belong to this agent" } }, 403) + } + const cfg = await loadA2AConfig(user) + const parsed = parseMessage(rpc.params) + if (!parsed.text) { + return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32602, message: "empty message" } }, 400) + } + if (rpc.method === "message/stream") return handleStream(c, reqId, parsed, auth!, cfg) + if (rpc.method === "message/send") return handleSend(c, reqId, parsed, auth!, cfg) + return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32601, message: `method not found: ${rpc.method}` } }, 404) + }) + + return a +} diff --git a/server/src/api-tokens.ts b/server/src/api-tokens.ts new file mode 100644 index 00000000..020891c4 --- /dev/null +++ b/server/src/api-tokens.ts @@ -0,0 +1,161 @@ +/** + * Per-user API tokens for the v1 Loop API. + * + * Storage: `$LOOPAT_HOME/api-tokens.json`. Tokens are SHA-256 hashed at rest; + * the plaintext (`la_<hex>`) is only returned to the caller at creation. + * + * Each entry has a stable `tokenId` (independent of the token value) so the + * web UI can list / revoke without exposing or suffix-matching the secret. + * + * Writes are serialized via an in-process promise chain. loopat is + * single-process so file-level locking isn't needed. + */ +import { existsSync } from "node:fs" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { createHash, randomBytes } from "node:crypto" +import { dirname, join } from "node:path" +import { LOOPAT_HOME } from "./paths" + +const TOKENS_PATH = join(LOOPAT_HOME, "api-tokens.json") + +type StoredToken = { + tokenId: string // stable short id, surfaced to UI + userId: string + label: string + createdAt: string + lastUsedAt?: string +} + +type TokensFile = { + // keyed by SHA-256(token plaintext) + tokens: Record<string, StoredToken> +} + +let cached: TokensFile | null = null + +let writeLock: Promise<void> = Promise.resolve() +function withWriteLock<T>(fn: () => Promise<T>): Promise<T> { + const next = writeLock.then(fn, fn) + writeLock = next.then(() => {}, () => {}) + return next +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex") +} + +function generateTokenValue(): string { + return `la_${randomBytes(24).toString("hex")}` +} + +function generateTokenId(): string { + return `tok_${randomBytes(6).toString("hex")}` +} + +async function readTokensFile(): Promise<TokensFile> { + if (cached) return cached + if (!existsSync(TOKENS_PATH)) { + cached = { tokens: {} } + return cached + } + try { + const raw = await readFile(TOKENS_PATH, "utf8") + const parsed = JSON.parse(raw) as TokensFile + if (!parsed.tokens || typeof parsed.tokens !== "object") { + cached = { tokens: {} } + } else { + cached = parsed + } + return cached + } catch { + cached = { tokens: {} } + return cached + } +} + +async function writeTokensFile(data: TokensFile): Promise<void> { + await mkdir(dirname(TOKENS_PATH), { recursive: true }) + await writeFile(TOKENS_PATH, JSON.stringify(data, null, 2) + "\n") + cached = data +} + +export type ApiTokenView = { + tokenId: string + label: string + createdAt: string + lastUsedAt?: string +} + +/** Resolve `Authorization: Bearer la_...` to a userId, or null. */ +export async function resolveApiToken(authHeader: string | null): Promise<string | null> { + if (!authHeader || !authHeader.startsWith("Bearer ")) return null + const bearerToken = authHeader.slice("Bearer ".length).trim() + if (!bearerToken || !bearerToken.startsWith("la_")) return null + const file = await readTokensFile() + const entry = file.tokens[hashToken(bearerToken)] + if (!entry) return null + // Best-effort lastUsedAt update; don't block resolution on it. + withWriteLock(async () => { + const f = await readTokensFile() + const h = hashToken(bearerToken) + if (f.tokens[h]) { + f.tokens[h].lastUsedAt = new Date().toISOString() + await writeTokensFile(f) + } + }).catch(() => {}) + return entry.userId +} + +export async function createApiToken(userId: string, label: string): Promise<{ + tokenId: string + token: string + label: string + createdAt: string +}> { + return withWriteLock(async () => { + const file = await readTokensFile() + const token = generateTokenValue() + const tokenId = generateTokenId() + const stored: StoredToken = { + tokenId, + userId, + label: (label || "").trim() || "default", + createdAt: new Date().toISOString(), + } + file.tokens[hashToken(token)] = stored + await writeTokensFile(file) + return { tokenId, token, label: stored.label, createdAt: stored.createdAt } + }) +} + +export async function listApiTokens(userId: string): Promise<ApiTokenView[]> { + const file = await readTokensFile() + return Object.values(file.tokens) + .filter((t) => t.userId === userId) + .map((t) => ({ + tokenId: t.tokenId, + label: t.label, + createdAt: t.createdAt, + lastUsedAt: t.lastUsedAt, + })) + .sort((a, b) => a.createdAt < b.createdAt ? 1 : -1) +} + +export async function revokeApiToken(userId: string, tokenId: string): Promise<boolean> { + return withWriteLock(async () => { + const file = await readTokensFile() + const hash = Object.keys(file.tokens).find((h) => { + const t = file.tokens[h] + return t.userId === userId && t.tokenId === tokenId + }) + if (!hash) return false + delete file.tokens[hash] + await writeTokensFile(file) + return true + }) +} + +/** For tests only — drops the in-memory cache. */ +export function _resetCache(): void { + cached = null +} diff --git a/server/src/api-v1-openapi.ts b/server/src/api-v1-openapi.ts new file mode 100644 index 00000000..8aa60e5a --- /dev/null +++ b/server/src/api-v1-openapi.ts @@ -0,0 +1,363 @@ +/** + * OpenAPI 3.1 schema for the v1 Loop API. + * + * Source of truth is `docs/api-v1.md`. This file mirrors that contract in + * a machine-readable form so the interactive docs (Scalar) can render it. + * + * Keep these two in sync when changing the API — spec doc first, then this. + */ + +export const v1OpenApiSpec = { + openapi: "3.1.0", + info: { + title: "Loopat Loop API", + version: "1.0.0", + description: [ + "External API for creating and driving loops. The same surface powers ", + "the loopat web chat UI — bot frameworks and the web app speak the same ", + "protocol.", + "", + "**Auth**: pass `Authorization: Bearer la_<token>` for external programs, ", + "or a `loopat_session` cookie for same-origin web requests.", + "", + "**Scope**: v1 only covers chat conversation. Operator features (queue, ", + "goal, provider, archive admin flags) stay on internal endpoints; see the ", + "full spec at `docs/api-v1.md`.", + ].join("\n"), + }, + servers: [{ url: "/api/v1", description: "current host, v1 prefix" }], + components: { + securitySchemes: { + BearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "la_<48 hex>", + description: "External programs. Token from Settings → API Tokens.", + }, + CookieAuth: { + type: "apiKey", + in: "cookie", + name: "loopat_session", + description: "Web same-origin requests; set by /api/auth/login.", + }, + }, + schemas: { + Loop: { + type: "object", + required: ["id", "title", "created_at", "created_by", "archived", "metadata", "profiles", "vault"], + properties: { + id: { type: "string", example: "loop_3a91ce5e-9c2f-4f0a-bcc9-7c7e..." }, + title: { type: "string", maxLength: 200 }, + created_at: { type: "string", format: "date-time" }, + created_by: { type: "string", example: "alice" }, + archived: { type: "boolean" }, + archived_at: { type: "string", format: "date-time", nullable: true }, + metadata: { + type: "object", + additionalProperties: { type: "string" }, + description: "Caller-supplied k/v, ≤16 KB JSON-stringified. Not visible to the agent.", + }, + profiles: { type: "array", items: { type: "string" } }, + vault: { type: "string", example: "default" }, + repo: { type: "string", nullable: true, example: "myproject" }, + busy: { type: "boolean", description: "Only on GET /loops/{id}" }, + queue_depth: { type: "integer", description: "Only on GET /loops/{id}" }, + turn_count: { type: "integer", description: "Only on GET /loops/{id}" }, + current_turn: { + type: "object", + nullable: true, + description: "Present iff busy=true", + properties: { + turn_id: { type: "string", nullable: true }, + started_at: { type: "string", format: "date-time", nullable: true }, + pending_choice_id: { type: "string", nullable: true }, + }, + }, + }, + }, + LoopList: { + type: "object", + required: ["data", "has_more"], + properties: { + data: { type: "array", items: { $ref: "#/components/schemas/Loop" } }, + first_id: { type: "string", nullable: true }, + last_id: { type: "string", nullable: true }, + has_more: { type: "boolean" }, + }, + }, + ApiTokenView: { + type: "object", + required: ["tokenId", "label", "createdAt"], + properties: { + tokenId: { type: "string", example: "tok_a1b2c3d4e5f6" }, + label: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + lastUsedAt: { type: "string", format: "date-time" }, + }, + }, + ApiTokenCreated: { + type: "object", + required: ["tokenId", "token", "label", "createdAt"], + properties: { + tokenId: { type: "string" }, + token: { + type: "string", + description: "Bearer token plaintext — only returned once at creation.", + example: "la_a1b2c3...", + }, + label: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + Error: { + type: "object", + required: ["error"], + properties: { + error: { + type: "object", + required: ["type", "code", "message"], + properties: { + type: { + type: "string", + enum: [ + "authentication_error", + "permission_error", + "invalid_request_error", + "not_found_error", + "conflict_error", + "rate_limit_error", + "internal_error", + ], + }, + code: { type: "string", example: "loop_not_found" }, + message: { type: "string" }, + }, + }, + }, + }, + }, + }, + security: [{ BearerAuth: [] }, { CookieAuth: [] }], + paths: { + "/me/tokens": { + post: { + summary: "Create an API token", + description: "Cookie-only (web Settings UI). Bots cannot self-issue tokens.", + security: [{ CookieAuth: [] }], + requestBody: { + required: false, + content: { + "application/json": { + schema: { + type: "object", + properties: { label: { type: "string", default: "default" } }, + }, + }, + }, + }, + responses: { + "201": { + description: "Token created. Plaintext returned once.", + content: { + "application/json": { schema: { $ref: "#/components/schemas/ApiTokenCreated" } }, + }, + }, + "401": { description: "Session required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + }, + }, + get: { + summary: "List my API tokens", + security: [{ CookieAuth: [] }], + responses: { + "200": { + description: "Token list (plaintext omitted)", + content: { + "application/json": { + schema: { + type: "object", + properties: { tokens: { type: "array", items: { $ref: "#/components/schemas/ApiTokenView" } } }, + }, + }, + }, + }, + }, + }, + }, + "/me/tokens/{tokenId}": { + delete: { + summary: "Revoke an API token", + security: [{ CookieAuth: [] }], + parameters: [ + { name: "tokenId", in: "path", required: true, schema: { type: "string" }, example: "tok_a1b2c3..." }, + ], + responses: { + "204": { description: "Revoked" }, + "404": { description: "Not found" }, + }, + }, + }, + "/loops": { + post: { + summary: "Create a loop", + requestBody: { + required: false, + content: { + "application/json": { + schema: { + type: "object", + properties: { + title: { type: "string", default: "untitled", maxLength: 200 }, + metadata: { type: "object", additionalProperties: { type: "string" } }, + profiles: { type: "array", items: { type: "string" } }, + vault: { type: "string", default: "default" }, + repo: { type: "string" }, + }, + }, + }, + }, + }, + responses: { + "201": { description: "Loop created", content: { "application/json": { schema: { $ref: "#/components/schemas/Loop" } } } }, + "400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + }, + }, + get: { + summary: "List my loops", + parameters: [ + { name: "limit", in: "query", schema: { type: "integer", default: 20, maximum: 100 } }, + { name: "after", in: "query", schema: { type: "string" }, description: "Cursor: returns loops older than this id" }, + { name: "before", in: "query", schema: { type: "string" }, description: "Cursor: returns loops newer than this id" }, + { name: "archived", in: "query", schema: { type: "boolean", default: false } }, + ], + responses: { + "200": { description: "Loop list", content: { "application/json": { schema: { $ref: "#/components/schemas/LoopList" } } } }, + }, + }, + }, + "/loops/{id}": { + get: { + summary: "Get a loop", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + responses: { + "200": { description: "Loop with runtime state", content: { "application/json": { schema: { $ref: "#/components/schemas/Loop" } } } }, + "403": { description: "Not loop owner", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + "404": { description: "Not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + }, + }, + delete: { + summary: "Archive a loop", + description: "Soft-delete. Sandbox is killed, meta retained. Unarchive/hard-delete are web-only.", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + responses: { + "204": { description: "Archived" }, + "403": { description: "Not loop owner" }, + "404": { description: "Not found" }, + }, + }, + }, + "/loops/{id}/messages": { + post: { + summary: "Send a message and stream the turn (SSE)", + description: [ + "Returns `text/event-stream`. See the SSE event vocabulary in `docs/api-v1.md`.", + "Closing the connection does **not** cancel the turn. Reconnect using the same", + "`Idempotency-Key` to replay buffered events and attach to the live stream.", + ].join(" "), + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + { name: "Idempotency-Key", in: "header", schema: { type: "string", maxLength: 256 } }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["content"], + properties: { + content: { type: "string", maxLength: 1048576 }, + permission_mode: { + type: "string", + enum: ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"], + description: "Override the loop's current permission mode for this turn.", + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "SSE stream. Event vocabulary: queued, started, assistant_delta, thinking_delta, tool_call, tool_result, requires_choice, choice_resolved, done, interrupted, error, ping, sdk_message (web-internal).", + content: { "text/event-stream": { schema: { type: "string" } } }, + }, + "400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + "409": { description: "Idempotency-Key reused with different body", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, + }, + }, + }, + "/loops/{id}/events": { + get: { + summary: "Watch a loop's events (read-only SSE)", + description: "Attach to live events without sending a message. Useful for reconnect and passive observation.", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + responses: { + "200": { + description: "SSE stream. Same vocabulary as POST /messages, prefixed by `event: snapshot` if a turn is already running.", + content: { "text/event-stream": { schema: { type: "string" } } }, + }, + }, + }, + }, + "/loops/{id}/choices/{choiceId}": { + post: { + summary: "Answer a choice (permission or question)", + description: "Unblocks an agent that emitted `requires_choice`. Body shape depends on the choice kind.", + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + { name: "choiceId", in: "path", required: true, schema: { type: "string" } }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + oneOf: [ + { + title: "Permission", + type: "object", + required: ["allow"], + properties: { allow: { type: "boolean" } }, + }, + { + title: "Question", + type: "object", + required: ["answers"], + properties: { + answers: { type: "object", additionalProperties: { type: "string" } }, + }, + }, + ], + }, + }, + }, + }, + responses: { + "202": { description: "Choice resolved" }, + "400": { description: "Invalid body shape" }, + "404": { description: "Choice not pending or already answered" }, + }, + }, + }, + "/loops/{id}/interrupt": { + post: { + summary: "Cancel the current turn", + description: "Open SSE streams receive `event: interrupted` and close.", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + responses: { + "202": { description: "Interrupted" }, + }, + }, + }, + }, +} as const diff --git a/server/src/api-v1.ts b/server/src/api-v1.ts new file mode 100644 index 00000000..b8c95e80 --- /dev/null +++ b/server/src/api-v1.ts @@ -0,0 +1,689 @@ +/** + * Loop API v1 — see docs/api-v1.md. + * + * Public surface: just "chat with a loop". CRUD on loops + send message (SSE) + * + watch events (SSE) + answer choices + interrupt. + * + * All other web features (loop-list status, kanban, terminal, token usage + * meter, DM/channels) continue to use their existing WS or internal REST. + */ +import { Hono, type Context, type MiddlewareHandler } from "hono" +import { streamSSE } from "hono/streaming" +import { Scalar } from "@scalar/hono-api-reference" +import { randomBytes } from "node:crypto" +import { getRequestUserId } from "./auth" +import { resolveApiToken, createApiToken, listApiTokens, revokeApiToken } from "./api-tokens" +import { v1OpenApiSpec } from "./api-v1-openapi" +import { + createLoop as internalCreateLoop, + getLoop, + listLoops, + loopExists, + patchLoopMeta, + userOnboarding, + type LoopMeta, +} from "./loops" +import { getSession, type LoopSessionMessageListener } from "./session" + +// ── ID prefixing ───────────────────────────────────────────────────────── + +const LOOP_PREFIX = "loop_" +const TURN_PREFIX = "turn_" +const CHOICE_PREFIX = "choice_" + +function loopIdToApi(rawId: string): string { + return rawId.startsWith(LOOP_PREFIX) ? rawId : `${LOOP_PREFIX}${rawId}` +} +function loopIdFromApi(apiId: string): string { + return apiId.startsWith(LOOP_PREFIX) ? apiId.slice(LOOP_PREFIX.length) : apiId +} +function genTurnId(): string { + return `${TURN_PREFIX}${randomBytes(10).toString("hex")}` +} +function choiceIdToApi(toolUseId: string): string { + return toolUseId.startsWith(CHOICE_PREFIX) ? toolUseId : `${CHOICE_PREFIX}${toolUseId}` +} +function choiceIdFromApi(apiId: string): string { + return apiId.startsWith(CHOICE_PREFIX) ? apiId.slice(CHOICE_PREFIX.length) : apiId +} + +// ── Auth: cookie OR Bearer ─────────────────────────────────────────────── + +async function resolveCaller(c: Context): Promise<string | null> { + // Try cookie session first (web same-origin), then Bearer token. + const sessionUser = getRequestUserId(c) + if (sessionUser) return sessionUser + const auth = c.req.header("authorization") ?? null + return await resolveApiToken(auth) +} + +const requireApiAuth: MiddlewareHandler = async (c, next) => { + const userId = await resolveCaller(c) + if (!userId) { + return c.json({ + error: { type: "authentication_error", code: "missing_credentials", message: "missing or invalid credentials" }, + }, 401) + } + c.set("userId", userId) + await next() +} + +// ── Error helper ───────────────────────────────────────────────────────── + +function apiError(c: Context, status: number, type: string, code: string, message: string) { + return c.json({ error: { type, code, message } }, status as any) +} + +// ── Loop resource shape ────────────────────────────────────────────────── + +function metaToApi(meta: LoopMeta, opts: { withRuntime: boolean } = { withRuntime: false }) { + const base: Record<string, unknown> = { + id: loopIdToApi(meta.id), + title: meta.title, + created_at: meta.createdAt, + created_by: meta.createdBy, + archived: !!meta.archived, + archived_at: meta.archivedAt ?? null, + metadata: (meta as any).metadata ?? {}, + profiles: meta.config?.profiles ?? [], + vault: meta.config?.vault ?? "default", + repo: meta.repo ?? null, + } + if (opts.withRuntime) { + const session = getSession(meta.id) + const busy = session.isBusy() + base.busy = busy + base.queue_depth = session.getQueueLength() + const currentChoice = pendingChoiceFor(meta.id) + base.current_turn = busy + ? { + turn_id: currentTurnIdFor(meta.id) ?? null, + started_at: currentTurnStartedAtFor(meta.id) ?? null, + pending_choice_id: currentChoice ?? null, + } + : null + } + return base +} + +// ── Per-loop runtime trackers (in-memory) ──────────────────────────────── +// +// MVP: track the current turn id + start time and the latest pending choice +// for each loop in memory. Used only to surface `current_turn` / snapshot +// state via the API; not authoritative — if the server restarts, the loop +// continues but these trackers reset. + +type LoopRuntime = { + currentTurnId?: string + currentTurnStartedAt?: string + currentAssistantText: string + pendingChoiceId?: string +} +const loopRuntime = new Map<string, LoopRuntime>() +function rt(loopId: string): LoopRuntime { + let r = loopRuntime.get(loopId) + if (!r) { + r = { currentAssistantText: "" } + loopRuntime.set(loopId, r) + } + return r +} +function currentTurnIdFor(id: string): string | undefined { return loopRuntime.get(id)?.currentTurnId } +function currentTurnStartedAtFor(id: string): string | undefined { return loopRuntime.get(id)?.currentTurnStartedAt } +function pendingChoiceFor(id: string): string | undefined { return loopRuntime.get(id)?.pendingChoiceId } + +// ── SDK message → v1 SSE event mapping ─────────────────────────────────── + +type V1Event = { event: string; data: Record<string, unknown> } + +/** + * Pass-through event that emits the raw SDK / session-broadcast message + * verbatim. Loopat's own web UI consumes this to drive its rich chat view + * without forcing the team to rewrite its SDK-shaped dispatch pipeline. + * + * Bot frameworks should NOT depend on the shape — it's the underlying + * Anthropic SDK message format, which can change. The stable bot-facing + * events (assistant_delta, tool_call, etc.) are what's contractual. + */ +function sdkPassthrough(msg: any): V1Event | null { + if (!msg || typeof msg !== "object") return null + if (typeof msg.type !== "string") return null + // Skip our own synthetic control events — they don't represent a real + // session-broadcast message worth replaying. + if (msg.type === "choice_resolved" || msg.type === "interrupted") return null + return { event: "sdk_message", data: msg as Record<string, unknown> } +} + +function mapSdkMessageToV1(msg: any, runtime: LoopRuntime): V1Event[] { + const out: V1Event[] = [] + const type = msg?.type + + // Fine-grained streaming deltas via stream_event. + if (type === "stream_event") { + const ev = msg.event + if (ev?.type === "content_block_delta") { + const delta = ev.delta + if (delta?.type === "text_delta" && typeof delta.text === "string") { + runtime.currentAssistantText += delta.text + out.push({ event: "assistant_delta", data: { text: delta.text } }) + } else if (delta?.type === "thinking_delta" && typeof delta.text === "string") { + out.push({ event: "thinking_delta", data: { text: delta.text } }) + } + } + return out + } + + // tool_call from assistant content blocks; tool_result from synthetic user content blocks. + if (type === "assistant" && Array.isArray(msg.message?.content)) { + for (const block of msg.message.content) { + if (block?.type === "tool_use" && typeof block.id === "string") { + out.push({ + event: "tool_call", + data: { + tool_use_id: block.id, + tool: block.name ?? "unknown", + input_summary: summarizeToolInput(block.input), + }, + }) + } + } + return out + } + if (type === "user" && Array.isArray(msg.message?.content)) { + for (const block of msg.message.content) { + if (block?.type === "tool_result" && typeof block.tool_use_id === "string") { + out.push({ + event: "tool_result", + data: { tool_use_id: block.tool_use_id, ok: !block.is_error }, + }) + } + } + return out + } + + // Choices. + if (type === "permission_prompt" && typeof msg.tool_use_id === "string") { + const choiceId = choiceIdToApi(msg.tool_use_id) + runtime.pendingChoiceId = choiceId + out.push({ + event: "requires_choice", + data: { + choice_id: choiceId, + kind: "permission", + payload: { + tool: msg.tool_name, + title: msg.title, + display_name: msg.displayName, + }, + }, + }) + return out + } + if (type === "question" && typeof msg.tool_use_id === "string") { + const choiceId = choiceIdToApi(msg.tool_use_id) + runtime.pendingChoiceId = choiceId + out.push({ + event: "requires_choice", + data: { + choice_id: choiceId, + kind: "question", + payload: { questions: msg.questions }, + }, + }) + return out + } + + if (type === "result") { + const turnId = runtime.currentTurnId ?? genTurnId() + out.push({ event: "done", data: { turn_id: turnId } }) + runtime.currentTurnId = undefined + runtime.currentTurnStartedAt = undefined + runtime.currentAssistantText = "" + return out + } + + if (type === "error") { + out.push({ event: "error", data: { code: "agent_error", message: msg.message ?? "agent error" } }) + return out + } + + // Synthetic control events (emitted by api-v1 itself via session.notifyListeners). + if (type === "choice_resolved") { + return [{ event: "choice_resolved", data: { choice_id: msg.choice_id, source: msg.source ?? "api" } }] + } + if (type === "interrupted") { + return [{ event: "interrupted", data: { turn_id: msg.turn_id ?? runtime.currentTurnId ?? "" } }] + } + + // Everything else (queue_update / provider / goal / viewers / context_usage / etc) + // is web-UI noise — drop. + return out +} + +function summarizeToolInput(input: unknown): string | undefined { + if (!input || typeof input !== "object") return undefined + // Best-effort one-line summary for observability. Don't dump full input. + try { + const obj = input as Record<string, unknown> + if (typeof obj.command === "string") return obj.command + if (typeof obj.file_path === "string") return obj.file_path + if (typeof obj.path === "string") return obj.path + if (typeof obj.query === "string") return obj.query + if (typeof obj.url === "string") return obj.url + return undefined + } catch { + return undefined + } +} + +// ── Idempotency store ──────────────────────────────────────────────────── +// +// In-memory MVP. Single-process loopat means this is fine; restart drops +// records, which is acceptable for a 24h replay window. + +type IdempotencyRecord = { + userId: string + requestHash: string + events: V1Event[] + done: boolean + createdAt: number +} +const idempotencyStore = new Map<string, IdempotencyRecord>() +const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000 + +function idempotencyKey(userId: string, key: string): string { + return `${userId}|${key}` +} + +function sweepIdempotency(): void { + const now = Date.now() + for (const [k, v] of idempotencyStore) { + if (now - v.createdAt > IDEMPOTENCY_TTL_MS) idempotencyStore.delete(k) + } +} + +function hashRequest(content: string): string { + // Cheap content-based hash; collisions on the same userId+key are vanishingly + // unlikely for the dedup use case (caller usually retries with the same body). + let h = 0 + for (let i = 0; i < content.length; i++) h = (h * 31 + content.charCodeAt(i)) | 0 + return h.toString(16) +} + +// ── App ────────────────────────────────────────────────────────────────── + +type Variables = { userId: string } + +export function buildApiV1(): Hono<{ Variables: Variables }> { + const v1 = new Hono<{ Variables: Variables }>() + + // ── Docs ───────────────────────────────────────────────────────────── + // Machine-readable spec + interactive reference. No auth needed. + v1.get("/openapi.json", (c) => c.json(v1OpenApiSpec as any)) + v1.get( + "/docs", + Scalar({ + url: "/api/v1/openapi.json", + pageTitle: "Loopat Loop API v1", + theme: "default", + }), + ) + + // ── Token management (cookie-only — bot frameworks cannot self-issue) ─ + + v1.post("/me/tokens", async (c) => { + const userId = getRequestUserId(c) + if (!userId) return apiError(c, 401, "authentication_error", "missing_credentials", "session required") + const body = await c.req.json().catch(() => ({})) + const label = typeof body.label === "string" ? body.label : "" + const t = await createApiToken(userId, label) + return c.json({ tokenId: t.tokenId, token: t.token, label: t.label, createdAt: t.createdAt }, 201) + }) + + v1.get("/me/tokens", async (c) => { + const userId = getRequestUserId(c) + if (!userId) return apiError(c, 401, "authentication_error", "missing_credentials", "session required") + const tokens = await listApiTokens(userId) + return c.json({ tokens }) + }) + + v1.delete("/me/tokens/:tokenId", async (c) => { + const userId = getRequestUserId(c) + if (!userId) return apiError(c, 401, "authentication_error", "missing_credentials", "session required") + const ok = await revokeApiToken(userId, c.req.param("tokenId") ?? "") + if (!ok) return apiError(c, 404, "not_found_error", "token_not_found", "no such token") + return c.body(null, 204) + }) + + // ── Loop CRUD ─────────────────────────────────────────────────────── + + v1.post("/loops", requireApiAuth, async (c) => { + const userId = c.get("userId") as string + // Onboarding gate: when the active provider requires onboarding, block loop + // creation until it's complete — same gate as the legacy POST /api/loops. + // (Without this, the UI's v1 create path bypasses the onboarding gate.) + const ob = await userOnboarding(userId) + if (ob.gated && !ob.done) { + return apiError(c, 403, "permission_error", "onboarding_incomplete", "onboarding incomplete") + } + const body = await c.req.json().catch(() => ({})) + const title = typeof body.title === "string" ? body.title.trim() : "" + if (title.length > 200) return apiError(c, 400, "invalid_request_error", "title_too_long", "title exceeds 200 chars") + + const profiles = Array.isArray(body.profiles) + ? body.profiles.filter((p: unknown): p is string => typeof p === "string") + : undefined + const vault = typeof body.vault === "string" ? body.vault : undefined + const repo = typeof body.repo === "string" && body.repo ? body.repo : undefined + const metadata = (body.metadata && typeof body.metadata === "object") ? body.metadata as Record<string, unknown> : undefined + if (metadata && JSON.stringify(metadata).length > 16 * 1024) { + return apiError(c, 400, "invalid_request_error", "metadata_too_large", "metadata exceeds 16 KB") + } + + const meta = await internalCreateLoop({ + title: title || "untitled", + createdBy: userId, + profiles, + vault, + repo, + }) + if (metadata) { + const patched = await patchLoopMeta(meta.id, { metadata }) + if (patched) return c.json(metaToApi(patched), 201) + } + return c.json(metaToApi(meta), 201) + }) + + v1.get("/loops", requireApiAuth, async (c) => { + const userId = c.get("userId") as string + const limit = Math.min(Math.max(parseInt(c.req.query("limit") ?? "20", 10) || 20, 1), 100) + const after = c.req.query("after") + const before = c.req.query("before") + const includeArchived = c.req.query("archived") === "true" + + const all = (await listLoops()).filter((m) => m.createdBy === userId && (includeArchived || !m.archived)) + + let filtered = all + if (after) { + const rawAfter = loopIdFromApi(after) + const idx = all.findIndex((m) => m.id === rawAfter) + filtered = idx >= 0 ? all.slice(idx + 1) : [] + } else if (before) { + const rawBefore = loopIdFromApi(before) + const idx = all.findIndex((m) => m.id === rawBefore) + filtered = idx >= 0 ? all.slice(0, idx) : [] + } + + const page = filtered.slice(0, limit) + const hasMore = filtered.length > limit + return c.json({ + data: page.map((m) => metaToApi(m)), + first_id: page[0] ? loopIdToApi(page[0].id) : null, + last_id: page[page.length - 1] ? loopIdToApi(page[page.length - 1].id) : null, + has_more: hasMore, + }) + }) + + v1.get("/loops/:id", requireApiAuth, async (c) => { + const userId = c.get("userId") as string + const id = loopIdFromApi(c.req.param("id") ?? "") + const meta = await getLoop(id) + if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found") + if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop") + return c.json(metaToApi(meta, { withRuntime: true })) + }) + + v1.delete("/loops/:id", requireApiAuth, async (c) => { + const userId = c.get("userId") as string + const id = loopIdFromApi(c.req.param("id") ?? "") + const meta = await getLoop(id) + if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found") + if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop") + if (!meta.archived) { + await patchLoopMeta(id, { archived: true, archivedAt: new Date().toISOString() } as any) + } + return c.body(null, 204) + }) + + // ── Send message (SSE) ────────────────────────────────────────────── + + v1.post("/loops/:id/messages", requireApiAuth, async (c) => { + const userId = c.get("userId") as string + const id = loopIdFromApi(c.req.param("id") ?? "") + if (!(await loopExists(id))) { + return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found") + } + const meta = await getLoop(id) + if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found") + if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop") + if (meta.archived) return apiError(c, 400, "invalid_request_error", "loop_archived", "loop is archived") + + const body = await c.req.json().catch(() => ({})) + const content = typeof body.content === "string" ? body.content : "" + if (!content) return apiError(c, 400, "invalid_request_error", "missing_content", "content required") + if (content.length > 1024 * 1024) return apiError(c, 400, "invalid_request_error", "content_too_large", "content exceeds 1 MB") + const VALID_MODES = new Set(["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"]) + const permissionMode = typeof body.permission_mode === "string" && VALID_MODES.has(body.permission_mode) + ? body.permission_mode as "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk" | "auto" + : undefined + + // Idempotency check. + sweepIdempotency() + const idemKeyHeader = c.req.header("idempotency-key") + const reqHash = hashRequest(content) + let idemRecord: IdempotencyRecord | undefined + if (idemKeyHeader) { + if (idemKeyHeader.length > 256) { + return apiError(c, 400, "invalid_request_error", "idempotency_key_too_long", "Idempotency-Key exceeds 256 chars") + } + const fullKey = idempotencyKey(userId, idemKeyHeader) + idemRecord = idempotencyStore.get(fullKey) + if (idemRecord && idemRecord.requestHash !== reqHash) { + return apiError(c, 409, "conflict_error", "idempotency_key_reused", + "Idempotency-Key was previously used with a different request body") + } + if (!idemRecord) { + idemRecord = { userId, requestHash: reqHash, events: [], done: false, createdAt: Date.now() } + idempotencyStore.set(fullKey, idemRecord) + } + } + + return streamSSE(c, async (stream) => { + const session = getSession(id) + const runtime = rt(id) + const wasBusy = session.isBusy() + + // Emit (and replay-record) one event. + const emit = async (ev: V1Event) => { + idemRecord?.events.push(ev) + await stream.writeSSE({ event: ev.event, data: JSON.stringify(ev.data) }) + } + + const isReplay = !!idemRecord && idemRecord.events.length > 0 + + if (isReplay) { + for (const ev of idemRecord!.events) { + await stream.writeSSE({ event: ev.event, data: JSON.stringify(ev.data) }) + } + if (idemRecord!.done) return + // Still in progress — attach to live stream below; do NOT re-send content. + } else { + if (wasBusy) { + await emit({ event: "queued", data: { position: session.getQueueLength() + 1 } }) + } + const turnId = genTurnId() + runtime.currentTurnId = turnId + runtime.currentTurnStartedAt = new Date().toISOString() + runtime.currentAssistantText = "" + await emit({ event: "started", data: { turn_id: turnId, cold_start: false } }) + } + + let closeFn: () => void = () => {} + const closedPromise = new Promise<void>((resolve) => { closeFn = resolve }) + let closed = false + const finishStream = () => { + if (closed) return + closed = true + if (idemRecord) idemRecord.done = true + closeFn() + } + + const unsubscribe = session.onMessage((msg) => { + if (closed) return + // Gather every emit() promise for this message — when a terminal + // event (done/interrupted/error) arrives, we MUST wait for all + // SSE writes from the SAME batch to flush before calling + // finishStream(). Otherwise streamSSE's finally{stream.close()} + // races the pending writeSSE, and the client never sees `done`. + const pending: Promise<void>[] = [] + const raw = sdkPassthrough(msg) + if (raw) pending.push(emit(raw).catch(() => {})) + let terminal = false + for (const ev of mapSdkMessageToV1(msg, runtime)) { + pending.push(emit(ev).catch(() => {})) + if (ev.event === "done" || ev.event === "interrupted" || ev.event === "error") { + terminal = true + } + } + if (terminal) { + Promise.all(pending).finally(() => finishStream()) + } + }) + + const heartbeat = setInterval(() => { + if (!closed) stream.writeSSE({ event: "ping", data: "{}" }).catch(() => {}) + }, 15_000) + + if (!isReplay) { + session.sendUserText(content, permissionMode).catch(async (e: any) => { + await emit({ event: "error", data: { code: "send_failed", message: e?.message ?? String(e) } }) + finishStream() + }) + } + + stream.onAbort(() => { finishStream() }) + + await closedPromise + unsubscribe() + clearInterval(heartbeat) + }) + }) + + // ── Watch events (read-only SSE) ──────────────────────────────────── + + v1.get("/loops/:id/events", requireApiAuth, async (c) => { + const userId = c.get("userId") as string + const id = loopIdFromApi(c.req.param("id") ?? "") + const meta = await getLoop(id) + if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found") + if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop") + + return streamSSE(c, async (stream) => { + const session = getSession(id) + const runtime = rt(id) + + // Snapshot if a turn is currently running. + if (session.isBusy() && runtime.currentTurnId) { + await stream.writeSSE({ + event: "snapshot", + data: JSON.stringify({ + turn_id: runtime.currentTurnId, + assistant_text_so_far: runtime.currentAssistantText, + pending_choice_id: runtime.pendingChoiceId ?? null, + }), + }) + } + + let closed = false + const unsubscribe = session.onMessage((msg) => { + if (closed) return + const raw = sdkPassthrough(msg) + if (raw) stream.writeSSE({ event: raw.event, data: JSON.stringify(raw.data) }).catch(() => {}) + for (const ev of mapSdkMessageToV1(msg, runtime)) { + stream.writeSSE({ event: ev.event, data: JSON.stringify(ev.data) }).catch(() => {}) + } + }) + + const heartbeat = setInterval(() => { + if (!closed) stream.writeSSE({ event: "ping", data: "{}" }).catch(() => {}) + }, 15_000) + + stream.onAbort(() => { + closed = true + unsubscribe() + clearInterval(heartbeat) + }) + + // Block until client disconnects. + await new Promise<void>((resolve) => { + const check = setInterval(() => { + if (closed) { + clearInterval(check) + clearInterval(heartbeat) + resolve() + } + }, 1000) + }) + }) + }) + + // ── Answer choice (permission / question) ─────────────────────────── + + v1.post("/loops/:id/choices/:choiceId", requireApiAuth, async (c) => { + const userId = c.get("userId") as string + const id = loopIdFromApi(c.req.param("id") ?? "") + const choiceId = c.req.param("choiceId") ?? "" + const toolUseId = choiceIdFromApi(choiceId) + + const meta = await getLoop(id) + if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found") + if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop") + + const body = await c.req.json().catch(() => ({})) + const session = getSession(id) + const runtime = rt(id) + + // Permission path + if (typeof body.allow === "boolean") { + const pending = session.hasPendingPermission(toolUseId) + if (!pending) return apiError(c, 404, "not_found_error", "choice_not_found", "choice not pending") + await session.answerPermission(toolUseId, body.allow) + runtime.pendingChoiceId = undefined + session.notifyListeners({ type: "choice_resolved", choice_id: choiceIdToApi(toolUseId), source: "api" }) + return c.body(null, 202) + } + + // Question path + if (body.answers && typeof body.answers === "object") { + const pending = session.hasPendingQuestion(toolUseId) + if (!pending) return apiError(c, 404, "not_found_error", "choice_not_found", "choice not pending") + await session.answerQuestions(toolUseId, body.answers as Record<string, string>) + runtime.pendingChoiceId = undefined + session.notifyListeners({ type: "choice_resolved", choice_id: choiceIdToApi(toolUseId), source: "api" }) + return c.body(null, 202) + } + + return apiError(c, 400, "invalid_request_error", "invalid_choice_payload", + "expected { allow: bool } for permission or { answers: {...} } for question") + }) + + // ── Interrupt ─────────────────────────────────────────────────────── + + v1.post("/loops/:id/interrupt", requireApiAuth, async (c) => { + const userId = c.get("userId") as string + const id = loopIdFromApi(c.req.param("id") ?? "") + const meta = await getLoop(id) + if (!meta) return apiError(c, 404, "not_found_error", "loop_not_found", "loop not found") + if (meta.createdBy !== userId) return apiError(c, 403, "permission_error", "not_loop_owner", "not your loop") + const session = getSession(id) + const runtime = rt(id) + const turnId = runtime.currentTurnId + await session.interrupt() + if (turnId) { + session.notifyListeners({ type: "interrupted", turn_id: turnId }) + } + return c.body(null, 202) + }) + + return v1 +} diff --git a/server/src/auth.ts b/server/src/auth.ts new file mode 100644 index 00000000..a76498ae --- /dev/null +++ b/server/src/auth.ts @@ -0,0 +1,309 @@ +/** + * Account system — single-workspace MVP. + * + * users.json (at LOOPAT_HOME/users.json): + * { users: [{ id, salt, hash, role, status, personalRepo?, createdAt, activatedAt? }] } + * + * Open registration: anyone can register. New accounts default to + * role:"member", status:"pending" and must be activated by an admin before + * login is allowed. The first account ever to register bootstraps as + * role:"admin", status:"active" so the system isn't unreachable. + * + * Sessions persist to sessions.json so server restarts don't log everyone out. + * Cookie is HttpOnly + SameSite=Lax + maxAge 30d. + */ +import { existsSync } from "node:fs" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { randomBytes, randomUUID, scrypt as scryptCb, timingSafeEqual } from "node:crypto" +import { promisify } from "node:util" +import type { Context, MiddlewareHandler } from "hono" +import { getCookie, setCookie, deleteCookie } from "hono/cookie" +import { join } from "node:path" +import { usersPath, workspaceDir } from "./paths" + +const scrypt = promisify(scryptCb) as ( + password: string | Buffer, + salt: string | Buffer, + keylen: number, +) => Promise<Buffer> + +export const COOKIE_NAME = "loopat_session" +const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 // 30 days +const SCRYPT_KEYLEN = 64 +const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/ + +export type UserRole = "admin" | "member" +export type UserStatus = "active" | "pending" + +export type User = { + id: string + salt: string + hash: string + role: UserRole + status: UserStatus + personalRepo?: string + createdAt: string + activatedAt?: string +} + +export type PublicUser = { + id: string + role: UserRole + status: UserStatus + personalRepo?: string + createdAt: string + activatedAt?: string +} + +function toPublic(u: User): PublicUser { + return { + id: u.id, + role: u.role, + status: u.status, + personalRepo: u.personalRepo, + createdAt: u.createdAt, + activatedAt: u.activatedAt, + } +} + +type UsersFile = { users: User[] } +type SessionsFile = { sessions: Record<string, string> } // token → userId + +let cached: UsersFile | null = null + +async function readUsersFile(): Promise<UsersFile> { + if (cached) return cached + const path = usersPath() + if (!existsSync(path)) { + cached = { users: [] } + return cached + } + const raw = await readFile(path, "utf8") + const parsed = JSON.parse(raw) as UsersFile + if (!Array.isArray(parsed.users)) throw new Error("users.json: missing users array") + cached = parsed + return cached +} + +async function writeUsersFile(data: UsersFile): Promise<void> { + await mkdir(workspaceDir(), { recursive: true }) + await writeFile(usersPath(), JSON.stringify(data, null, 2) + "\n") + cached = data +} + +export async function listUsers(): Promise<PublicUser[]> { + const f = await readUsersFile() + return f.users.map(toPublic) +} + +export async function findUser(id: string): Promise<User | null> { + const f = await readUsersFile() + return f.users.find((u) => u.id === id) ?? null +} + +export async function hashPassword(password: string, salt?: string): Promise<{ salt: string; hash: string }> { + const s = salt ?? randomBytes(16).toString("hex") + const buf = await scrypt(password, s, SCRYPT_KEYLEN) + return { salt: s, hash: buf.toString("hex") } +} + +export async function verifyPassword(password: string, salt: string, hash: string): Promise<boolean> { + const buf = await scrypt(password, salt, SCRYPT_KEYLEN) + const expected = Buffer.from(hash, "hex") + if (buf.length !== expected.length) return false + return timingSafeEqual(buf, expected) +} + +export function isValidUsername(id: string): boolean { + return USERNAME_RE.test(id) +} + +/** + * Open registration. Anyone with a valid username + password can create an + * account. New accounts default to status:"pending" — they cannot log in + * until an admin activates them. The very first account ever created + * bootstraps as role:"admin", status:"active" so the system is reachable + * without manual seeding. + */ +export async function createUser(input: { + id: string + password: string + personalRepo?: string +}): Promise<User> { + if (!isValidUsername(input.id)) throw new Error("invalid username (lowercase a-z0-9_- , 1-32 chars, leading alnum)") + if (!input.password || input.password.length < 1) throw new Error("password required") + const f = await readUsersFile() + if (f.users.some((u) => u.id === input.id)) throw new Error("username taken") + const { salt, hash } = await hashPassword(input.password) + const isFirst = f.users.length === 0 + const now = new Date().toISOString() + const user: User = { + id: input.id, + salt, + hash, + role: isFirst ? "admin" : "member", + status: isFirst ? "active" : "pending", + personalRepo: input.personalRepo?.trim() || undefined, + createdAt: now, + activatedAt: isFirst ? now : undefined, + } + await writeUsersFile({ users: [...f.users, user] }) + return user +} + +export async function activateUser(id: string): Promise<User | null> { + const f = await readUsersFile() + const idx = f.users.findIndex((u) => u.id === id) + if (idx < 0) return null + if (f.users[idx].status === "active") return f.users[idx] + const updated: User = { ...f.users[idx], status: "active", activatedAt: new Date().toISOString() } + const users = f.users.slice() + users[idx] = updated + await writeUsersFile({ users }) + return updated +} + +export async function setUserRole(id: string, role: UserRole): Promise<User | null> { + const f = await readUsersFile() + const idx = f.users.findIndex((u) => u.id === id) + if (idx < 0) return null + const target = f.users[idx] + if (target.role === role) return target + if (target.role === "admin" && role !== "admin") { + const adminCount = f.users.filter((u) => u.role === "admin").length + if (adminCount <= 1) throw new Error("cannot demote the last admin") + } + const updated: User = { ...target, role } + const users = f.users.slice() + users[idx] = updated + await writeUsersFile({ users }) + return updated +} + +/** + * Remove a user from users.json. Does NOT touch personal/<id>/ on disk — + * data is preserved for safety. Caller must guard against self-delete and + * last-admin removal at the route layer (see /api/admin/users/:id). + */ +export async function deleteUser(id: string): Promise<boolean> { + const f = await readUsersFile() + const idx = f.users.findIndex((u) => u.id === id) + if (idx < 0) return false + const target = f.users[idx] + if (target.role === "admin") { + const adminCount = f.users.filter((u) => u.role === "admin").length + if (adminCount <= 1) throw new Error("cannot delete the last admin") + } + const users = f.users.filter((u) => u.id !== id) + await writeUsersFile({ users }) + // Drop any sessions belonging to this user so the deletion is immediate. + for (const [token, uid] of sessions.entries()) { + if (uid === id) sessions.delete(token) + } + await saveSessions() + return true +} + +/** + * Persist a user's personalRepo URL. Used when the user filled it in after + * registration (via the import dialog). Idempotent — no-op if the value is + * unchanged. + */ +export async function setPersonalRepo(userId: string, repoUrl: string): Promise<User | null> { + const f = await readUsersFile() + const idx = f.users.findIndex((u) => u.id === userId) + if (idx < 0) return null + const updated = { ...f.users[idx], personalRepo: repoUrl.trim() || undefined } + const users = f.users.slice() + users[idx] = updated + await writeUsersFile({ users }) + return updated +} + +// ── Persistent sessions (disk-backed, survives restarts) ── + +function sessionsPath(): string { + return join(workspaceDir(), "sessions.json") +} + +const sessions = new Map<string, string>() // token → userId + +async function loadSessions(): Promise<void> { + const path = sessionsPath() + if (!existsSync(path)) return + try { + const raw = await readFile(path, "utf8") + const data = JSON.parse(raw) as SessionsFile + for (const [token, userId] of Object.entries(data.sessions ?? {})) { + sessions.set(token, userId) + } + } catch {} +} + +async function saveSessions(): Promise<void> { + await mkdir(workspaceDir(), { recursive: true }) + const data: SessionsFile = { sessions: Object.fromEntries(sessions) } + await writeFile(sessionsPath(), JSON.stringify(data, null, 2) + "\n").catch(() => {}) +} + +// Load sessions from disk at import time +loadSessions() + +export function createSession(userId: string): string { + const token = randomUUID() + sessions.set(token, userId) + saveSessions() + return token +} + +export function destroySession(token: string): void { + sessions.delete(token) + saveSessions() +} + +export function lookupSession(token: string): string | null { + return sessions.get(token) ?? null +} + +export function setSessionCookie(c: Context, token: string): void { + setCookie(c, COOKIE_NAME, token, { + httpOnly: true, + sameSite: "Lax", + path: "/", + maxAge: COOKIE_MAX_AGE, + }) +} + +export function clearSessionCookie(c: Context): void { + deleteCookie(c, COOKIE_NAME, { path: "/" }) +} + +export function getRequestUserId(c: Context): string | null { + const token = getCookie(c, COOKIE_NAME) + if (!token) return null + return lookupSession(token) +} + +/** + * Hono middleware: requires a valid session cookie. Sets `userId` on context + * for downstream handlers (`c.get("userId")`). + */ +export const requireAuth: MiddlewareHandler = async (c, next) => { + const userId = getRequestUserId(c) + if (!userId) return c.json({ error: "unauthorized" }, 401) + c.set("userId", userId) + await next() +} + +/** + * Hono middleware: requires the session user to be role:"admin". + * Layer this *after* requireAuth (or on its own — it re-checks the cookie). + */ +export const requireAdmin: MiddlewareHandler = async (c, next) => { + const userId = getRequestUserId(c) + if (!userId) return c.json({ error: "unauthorized" }, 401) + const user = await findUser(userId) + if (!user || user.role !== "admin") return c.json({ error: "forbidden" }, 403) + c.set("userId", userId) + await next() +} diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts new file mode 100644 index 00000000..20cbef44 --- /dev/null +++ b/server/src/bootstrap.ts @@ -0,0 +1,178 @@ +/** + * Boot-time pre-flight: verify the host has what loopat needs (podman, claude + * binary, apiKey) and print a checklist. Doesn't exit on failure — UI still + * works, just chat won't function until the user fills in what's missing. + */ +import { existsSync } from "node:fs" +import { execFileSync } from "node:child_process" +import { join } from "node:path" +import { resolveSandboxClaudeBinary } from "./claude-binary" +import { configPath, loadKnowledgeConfig, type WorkspaceConfig } from "./config" +import { + WORKSPACE, + usersPath, + workspaceDir, + workspaceKnowledgeDir, + workspaceNotesDir, + workspaceTeamClaudeMdPath, +} from "./paths" +import { listUsers } from "./auth" + +type Check = { ok: boolean; label: string; hint?: string } + +/** The host to print in the "open …" url. HOST=0.0.0.0/:: means "all + * interfaces" — localhost works locally but isn't reachable from other + * machines, so resolve a real LAN ip instead. */ +function accessHost(): string { + const h = process.env.HOST + if (!h || h === "127.0.0.1" || h === "localhost") return "localhost" + if (h !== "0.0.0.0" && h !== "::") return h + try { + const ip = execFileSync( + "sh", ["-c", "ip route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | awk '{print $2}' || ipconfig getifaddr en0 2>/dev/null"], + { stdio: "pipe" }, + ).toString().trim() + return ip || "localhost" + } catch { return "localhost" } +} + +function checkPodman(): Check { + const isMac = process.platform === "darwin" + let version: string + try { + version = execFileSync("podman", ["--version"], { stdio: "pipe" }).toString().trim() + } catch { + return { + ok: false, + label: "podman (sandbox)", + hint: isMac + ? "brew install podman, then: podman machine init && podman machine start" + : "sudo apt install podman uidmap fuse-overlayfs (Linux)", + } + } + // On macOS podman runs inside a Linux VM ("machine"). `--version` succeeds even + // when the machine is stopped — `podman info` is what actually needs the VM up. + if (isMac) { + try { + execFileSync("podman", ["info"], { stdio: "pipe", timeout: 8000 }) + } catch { + return { + ok: false, + label: `podman (sandbox): ${version}`, + hint: "podman machine isn't running — start it: podman machine start (run `podman machine init` first if you never have)", + } + } + } + return { ok: true, label: `podman (sandbox): ${version}` } +} + +function checkClaudeBinary(): Check { + // The AI runs in the linux sandbox, so what matters is the SANDBOX claude. + try { + const p = resolveSandboxClaudeBinary() + const tag = process.platform === "linux" ? "" : " [linux, for sandbox]" + return { ok: true, label: `claude binary${tag} (${p.split("/").slice(-3).join("/")})` } + } catch { + return { + ok: false, + label: "claude binary (sandbox/linux)", + hint: + process.platform === "linux" + ? "run `bun install` in the loopat repo root — SDK ships the binary as a platform-specific package" + : "the linux claude for the sandbox wasn't fetched (postinstall). Reinstall loopat, or run the `npm install --os=linux ...` command from the resolve error", + } + } +} + +function checkGitCrypt(): Check { + try { + const out = execFileSync("git-crypt", ["--version"], { stdio: "pipe" }).toString().trim() + return { ok: true, label: `git-crypt (personal vault): ${out}` } + } catch { + return { + ok: false, + label: "git-crypt (personal vault)", + hint: process.platform === "darwin" + ? "brew install git-crypt (encrypts your personal vault)" + : "sudo apt install git-crypt (encrypts your personal vault)", + } + } +} + + +function describeRemote(dir: string, url: string | undefined): string { + if (!existsSync(dir)) return "missing" + const isRepo = existsSync(join(dir, ".git")) + if (url && isRepo) return url + if (url && !isRepo) return `${url} (clone failed → local-only)` + return "local-only (no remote)" +} + +async function checkUsers(): Promise<Check> { + const path = usersPath() + if (!existsSync(path)) { + return { ok: true, label: `users: (none yet — register on first visit)` } + } + try { + const users = await listUsers() + const ids = users.map((u) => u.id).join(", ") || "(empty)" + return { ok: true, label: `users: ${users.length} (${ids})` } + } catch (e: any) { + return { ok: false, label: `users: <unreadable>`, hint: `${path}: ${e?.message ?? e}` } + } +} + +export async function printBootstrapBanner(cfg: WorkspaceConfig) { + // notes is declared inside the knowledge repo's .loopat/config.json. The repo + // roster is per-user (personal config), so the workspace banner can't list it. + const kcfg = await loadKnowledgeConfig() + const checks: Check[] = [ + { ok: true, label: `workspace: ${workspaceDir()}` }, + { ok: true, label: `team .claude/CLAUDE.md (${existsSync(workspaceTeamClaudeMdPath()) ? "present" : "absent"})` }, + { ok: existsSync(workspaceKnowledgeDir()), label: `knowledge: ${describeRemote(workspaceKnowledgeDir(), cfg.knowledge?.git || undefined)}` }, + { ok: existsSync(workspaceNotesDir()), label: `notes: ${describeRemote(workspaceNotesDir(), kcfg.notes?.git || undefined)}` }, + { ok: true, label: `repos: (per-user, in personal config)` }, + await checkUsers(), + { ok: existsSync(configPath()), label: `config: ${configPath()}` }, + checkPodman(), + checkClaudeBinary(), + checkGitCrypt(), + ] + + // Colorize only on a TTY (not when piped/redirected) and unless NO_COLOR is set. + const color = !!process.stdout.isTTY && process.env.NO_COLOR === undefined + const wrap = (code: string) => (s: string) => (color ? `\x1b[${code}m${s}\x1b[0m` : s) + const green = wrap("32"), red = wrap("31"), yellow = wrap("33"), dim = wrap("2"), bold = wrap("1"), cyan = wrap("36") + + const bar = dim("─".repeat(60)) + console.log(`\n${bar}`) + console.log(` ${bold(cyan(`loopat bootstrap`))} ${dim("—")} ${bold(WORKSPACE)}`) + console.log(bar) + for (const c of checks) { + const mark = c.ok ? green("✓") : red("✗") + console.log(` ${mark} ${c.ok ? c.label : bold(c.label)}`) + if (!c.ok && c.hint) console.log(` ${yellow("→ " + c.hint)}`) + } + console.log(bar) + const blockers = checks.filter((c) => !c.ok) + if (blockers.length > 0) { + console.log(` ${yellow(`${blockers.length} thing(s) to fix`)} before chat will work — see hints above.\n`) + return false + } + // NB: the "ready. open …" line is intentionally NOT printed here. The banner + // runs up front (so dependency checks are visible immediately), but the port + // isn't listening yet and the sandbox base image may still be pulling. The + // caller prints printReadyLine() only after the port is actually open and the + // image is prepared — otherwise we'd claim readiness mid-boot. + console.log("") + return true +} + +/** The "ready. open <url>" line — printed by the boot sequence AFTER the port + * is listening and the sandbox image is ready (see printBootstrapBanner). */ +export function printReadyLine() { + const color = !!process.stdout.isTTY && process.env.NO_COLOR === undefined + const wrap = (code: string) => (s: string) => (color ? `\x1b[${code}m${s}\x1b[0m` : s) + const green = wrap("32"), cyan = wrap("36") + console.log(` ${green("ready.")} open ${cyan(`http://${accessHost()}:${process.env.PORT ?? 10001}`)}\n`) +} diff --git a/server/src/chat.ts b/server/src/chat.ts new file mode 100644 index 00000000..1c6b45bb --- /dev/null +++ b/server/src/chat.ts @@ -0,0 +1,390 @@ +/** + * Chat module: SQLite-backed channels + 1:1 DMs. + * + * Storage of record is chat.db (bun:sqlite) at LOOPAT_HOME/chat.db. + * AI never reads the DB; when a loop is spawned from a chat conv, the + * relevant messages are dumped to a per-loop jsonl snapshot at + * loops/<id>/context/chat/<convId>.jsonl (last 1024 messages). + * + * Permissions (v0): + * - any auth user reads any channel and posts to any channel + * - any auth user creates channels; only admin deletes + * - DMs are strictly 1:1, visible only to the two parties + */ +import { Database } from "bun:sqlite" +import { randomUUID } from "node:crypto" +import { mkdir, writeFile } from "node:fs/promises" +import { dirname } from "node:path" +import { chatDbPath } from "./paths" + +export type ConvKind = "channel" | "dm" + +export type Conversation = { + id: string + kind: ConvKind + name: string | null + topic: string | null + createdBy: string + createdAt: number + dmUserA: string | null + dmUserB: string | null +} + +export type Message = { + id: number + convId: string + author: string + text: string + ts: number + /** NULL = thread root (a top-level message); otherwise the root msg id this + * reply belongs to. Slack-style single-level threading — replies cannot be + * replied to (we reject parent_id on a message that already has one). */ + parentId: number | null +} + +/** Thread root surfaced in the main feed. Carries denormalized reply stats + * so the UI can render "💬 N replies" without a per-row roundtrip. */ +export type ThreadRoot = Message & { + replyCount: number + lastReplyTs: number | null +} + +export type ConversationWithUnread = Conversation & { + unread: number + lastMessageTs: number | null + /** For DMs, the "display name" is the other party. */ + peerUserId: string | null +} + +let _db: Database | null = null + +function db(): Database { + if (_db) return _db + const path = chatDbPath() + // mkdir parent in case LOOPAT_HOME doesn't exist yet (bootstrap order). + // ensureWorkspaceDirs runs before chat is used, but be defensive. + const d = new Database(path, { create: true }) + d.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('channel','dm')), + name TEXT, + topic TEXT, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL, + deleted_at INTEGER, + dm_user_a TEXT, + dm_user_b TEXT + ); + + CREATE UNIQUE INDEX IF NOT EXISTS conv_channel_name + ON conversations(name) WHERE kind = 'channel' AND deleted_at IS NULL; + CREATE UNIQUE INDEX IF NOT EXISTS conv_dm_pair + ON conversations(dm_user_a, dm_user_b) WHERE kind = 'dm'; + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + author TEXT NOT NULL, + text TEXT NOT NULL, + ts INTEGER NOT NULL, + parent_id INTEGER REFERENCES messages(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS msg_conv_id_idx ON messages(conv_id, id); + + CREATE TABLE IF NOT EXISTS reads ( + user_id TEXT NOT NULL, + conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + last_read_id INTEGER NOT NULL, + PRIMARY KEY (user_id, conv_id) + ); + `) + // Migrate DBs created before parent_id existed. SQLite has no + // IF NOT EXISTS on ADD COLUMN, so swallow the duplicate-column error. + // MUST run before the partial index below — `WHERE parent_id IS NOT NULL` + // fails to parse if the column isn't there yet. + try { + d.exec(`ALTER TABLE messages ADD COLUMN parent_id INTEGER REFERENCES messages(id) ON DELETE CASCADE`) + } catch (e: any) { + if (!/duplicate column/i.test(e?.message ?? "")) throw e + } + d.exec(`CREATE INDEX IF NOT EXISTS msg_parent_idx ON messages(parent_id, id) WHERE parent_id IS NOT NULL`) + _db = d + return d +} + +function rowToConv(r: any): Conversation { + return { + id: r.id, + kind: r.kind, + name: r.name, + topic: r.topic, + createdBy: r.created_by, + createdAt: r.created_at, + dmUserA: r.dm_user_a, + dmUserB: r.dm_user_b, + } +} + +function rowToMessage(r: any): Message { + return { + id: r.id, + convId: r.conv_id, + author: r.author, + text: r.text, + ts: r.ts, + parentId: r.parent_id ?? null, + } +} + +function isValidChannelName(name: string): boolean { + // lowercase letters, digits, dash, underscore. 1–32 chars. Slack-like. + return /^[a-z0-9][a-z0-9_-]{0,31}$/.test(name) +} + +// ── channels ────────────────────────────────────────────────────────────── + +export function listChannels(): Conversation[] { + const rows = db() + .query<any, []>( + `SELECT * FROM conversations + WHERE kind = 'channel' AND deleted_at IS NULL + ORDER BY name ASC`, + ) + .all() + return rows.map(rowToConv) +} + +export function createChannel(opts: { name: string; topic?: string; createdBy: string }): { ok: true; conv: Conversation } | { ok: false; error: string } { + const name = opts.name.trim().toLowerCase().replace(/^#/, "") + if (!isValidChannelName(name)) { + return { ok: false, error: "channel name must be lowercase letters/digits/-/_, 1-32 chars, start with letter/digit" } + } + const existing = db().query<any, [string]>( + `SELECT * FROM conversations WHERE kind = 'channel' AND name = ? AND deleted_at IS NULL`, + ).get(name) + if (existing) return { ok: false, error: "channel exists" } + const id = `c-${randomUUID()}` + const now = Date.now() + db().run( + `INSERT INTO conversations (id, kind, name, topic, created_by, created_at) + VALUES (?, 'channel', ?, ?, ?, ?)`, + [id, name, opts.topic?.trim() || null, opts.createdBy, now], + ) + return { ok: true, conv: rowToConv(db().query<any, [string]>(`SELECT * FROM conversations WHERE id = ?`).get(id)) } +} + +export function deleteChannel(id: string): boolean { + const r = db().run( + `UPDATE conversations SET deleted_at = ? WHERE id = ? AND kind = 'channel' AND deleted_at IS NULL`, + [Date.now(), id], + ) + return r.changes > 0 +} + +// ── DMs ─────────────────────────────────────────────────────────────────── + +/** Normalize the (a, b) tuple so DB lookup is canonical regardless of caller order. */ +function normalizeDmPair(a: string, b: string): [string, string] { + return a < b ? [a, b] : [b, a] +} + +export function getOrCreateDm(userA: string, userB: string, createdBy: string): Conversation { + if (userA === userB) throw new Error("cannot DM yourself") + const [a, b] = normalizeDmPair(userA, userB) + const existing = db() + .query<any, [string, string]>( + `SELECT * FROM conversations WHERE kind = 'dm' AND dm_user_a = ? AND dm_user_b = ?`, + ) + .get(a, b) + if (existing) return rowToConv(existing) + const id = `d-${randomUUID()}` + const now = Date.now() + db().run( + `INSERT INTO conversations (id, kind, created_by, created_at, dm_user_a, dm_user_b) + VALUES (?, 'dm', ?, ?, ?, ?)`, + [id, createdBy, now, a, b], + ) + return rowToConv(db().query<any, [string]>(`SELECT * FROM conversations WHERE id = ?`).get(id)) +} + +export function getConv(id: string): Conversation | null { + const r = db().query<any, [string]>(`SELECT * FROM conversations WHERE id = ?`).get(id) + return r ? rowToConv(r) : null +} + +/** Permission check: can `userId` read/write this conversation? */ +export function userCanAccess(conv: Conversation, userId: string): boolean { + if (conv.kind === "channel") return conv.createdAt > 0 // any auth user + return conv.dmUserA === userId || conv.dmUserB === userId +} + +/** All conversations visible to `userId`: every channel + every DM they're in. */ +export function listConversationsForUser(userId: string): ConversationWithUnread[] { + const rows = db() + .query<any, [string, string, string]>( + `SELECT c.*, + (SELECT MAX(ts) FROM messages WHERE conv_id = c.id) AS last_ts, + (SELECT MAX(id) FROM messages WHERE conv_id = c.id) AS last_msg_id, + COALESCE((SELECT last_read_id FROM reads WHERE user_id = ? AND conv_id = c.id), 0) AS last_read_id + FROM conversations c + WHERE c.deleted_at IS NULL + AND ( + c.kind = 'channel' + OR c.dm_user_a = ? + OR c.dm_user_b = ? + )`, + ) + .all(userId, userId, userId) + return rows.map((r) => { + const conv = rowToConv(r) + const lastId = r.last_msg_id ?? 0 + const lastRead = r.last_read_id ?? 0 + const unread = lastId > lastRead + ? (db().query<{ n: number }, [string, number]>( + `SELECT COUNT(*) AS n FROM messages WHERE conv_id = ? AND id > ?`, + ).get(conv.id, lastRead)?.n ?? 0) + : 0 + let peer: string | null = null + if (conv.kind === "dm") { + peer = conv.dmUserA === userId ? conv.dmUserB : conv.dmUserA + } + return { + ...conv, + unread, + lastMessageTs: r.last_ts ?? null, + peerUserId: peer, + } + }) +} + +// ── messages ────────────────────────────────────────────────────────────── + +/** Main-feed listing: thread roots only. Each row carries denormalized + * reply_count + last_reply_ts via subqueries so the UI can render the + * "💬 N replies" affordance without a second roundtrip. */ +export function listMessages(convId: string, opts: { before?: number; limit?: number } = {}): ThreadRoot[] { + const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500) + const before = opts.before + const select = ` + SELECT m.*, + (SELECT COUNT(*) FROM messages r WHERE r.parent_id = m.id) AS reply_count, + (SELECT MAX(ts) FROM messages r WHERE r.parent_id = m.id) AS last_reply_ts + FROM messages m + WHERE m.conv_id = ? AND m.parent_id IS NULL` + let rows: any[] + if (before && before > 0) { + rows = db() + .query<any, [string, number, number]>(`${select} AND m.id < ? ORDER BY m.id DESC LIMIT ?`) + .all(convId, before, limit) + } else { + rows = db() + .query<any, [string, number]>(`${select} ORDER BY m.id DESC LIMIT ?`) + .all(convId, limit) + } + // Return chronological (oldest → newest) — convenient for UI append. + return rows.reverse().map((r) => ({ + ...rowToMessage(r), + replyCount: r.reply_count ?? 0, + lastReplyTs: r.last_reply_ts ?? null, + })) +} + +/** Return a thread (root + all replies, chronological). null if the root id + * doesn't exist or is itself a reply (we don't surface "half threads"). */ +export function listThread(rootId: number): { root: Message; replies: Message[] } | null { + const rootRow = db().query<any, [number]>(`SELECT * FROM messages WHERE id = ?`).get(rootId) + if (!rootRow || rootRow.parent_id != null) return null + const replyRows = db() + .query<any, [number]>(`SELECT * FROM messages WHERE parent_id = ? ORDER BY id ASC`) + .all(rootId) + return { + root: rowToMessage(rootRow), + replies: replyRows.map(rowToMessage), + } +} + +/** Post a message. If parentId is set, it must reference a top-level message + * in the same conv (no nested threads, no cross-conv replies). Returns the + * new message. */ +export function postMessage(convId: string, author: string, text: string, parentId: number | null = null): Message { + const trimmed = text.replace(/\r\n/g, "\n") + if (!trimmed.trim()) throw new Error("empty message") + if (parentId != null) { + const parent = db().query<any, [number]>(`SELECT conv_id, parent_id FROM messages WHERE id = ?`).get(parentId) + if (!parent) throw new Error("parent message not found") + if (parent.conv_id !== convId) throw new Error("parent message belongs to another conversation") + if (parent.parent_id != null) throw new Error("cannot reply to a reply (threads are single-level)") + } + const ts = Date.now() + const r = db().run( + `INSERT INTO messages (conv_id, author, text, ts, parent_id) VALUES (?, ?, ?, ?, ?)`, + [convId, author, trimmed, ts, parentId], + ) + return { + id: Number(r.lastInsertRowid), + convId, + author, + text: trimmed, + ts, + parentId, + } +} + +export function markRead(userId: string, convId: string, lastReadId: number): void { + db().run( + `INSERT INTO reads (user_id, conv_id, last_read_id) VALUES (?, ?, ?) + ON CONFLICT(user_id, conv_id) DO UPDATE SET last_read_id = MAX(last_read_id, excluded.last_read_id)`, + [userId, convId, lastReadId], + ) +} + +// ── jsonl snapshot (for spawn-loop-from-thread) ─────────────────────────── + +/** + * Dump a thread (root + all replies) to a jsonl file, chronological order: + * {"ts":"2026-05-16T10:42:00.000Z","author":"simpx","text":"..."} + * + * A "thread" is the natural semantic unit for AI seeding — every top-level + * message is a thread of length ≥ 1, so this works whether or not anyone + * actually replied. Returns null if the root doesn't exist or is itself a + * reply. + */ +export async function snapshotThreadToJsonl( + rootId: number, + destPath: string, +): Promise<{ messageCount: number; convId: string } | null> { + const t = listThread(rootId) + if (!t) return null + const all = [t.root, ...t.replies] + const lines = all.map((m) => + JSON.stringify({ + ts: new Date(m.ts).toISOString(), + author: m.author, + text: m.text, + }), + ) + await mkdir(dirname(destPath), { recursive: true }) + await writeFile(destPath, lines.join("\n") + (lines.length ? "\n" : "")) + return { messageCount: all.length, convId: t.root.convId } +} + +// ── bootstrap ───────────────────────────────────────────────────────────── + +/** Run once on server startup. Opens the DB (creating schema) and seeds a + * default `#general` channel if no channels exist. */ +export function initChat(bootstrapUser: string): void { + db() // open + migrate + const count = db() + .query<{ n: number }, []>( + `SELECT COUNT(*) AS n FROM conversations WHERE kind = 'channel' AND deleted_at IS NULL`, + ) + .get()?.n ?? 0 + if (count === 0 && bootstrapUser) { + createChannel({ name: "general", topic: "workspace-wide chatter", createdBy: bootstrapUser }) + } +} diff --git a/server/src/claude-binary.ts b/server/src/claude-binary.ts new file mode 100644 index 00000000..7b4003ac --- /dev/null +++ b/server/src/claude-binary.ts @@ -0,0 +1,119 @@ +import { existsSync, mkdirSync } from "node:fs" +import { execSync, execFile } from "node:child_process" +import { promisify } from "node:util" +import { fileURLToPath } from "node:url" +import { dirname, resolve, join } from "node:path" +import { createRequire } from "node:module" + +const execFileP = promisify(execFile) + +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")}`) +} + +/** Where the fetched linux claude lives on a non-linux host. */ +function sandboxClaudeDir(): string { + const installDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..") + return join(installDir, "sandbox-claude") +} +function sandboxClaudeBinaryPath(): string { + return join(sandboxClaudeDir(), "node_modules", "@anthropic-ai", `claude-agent-sdk-linux-${process.arch}`, "claude") +} + +/** + * The claude binary the SANDBOX runs (the AI executes inside a linux podman + * container). On a linux host that's just the host claude. On a non-linux host + * npm only installed the host (e.g. darwin) binary, so we fetch the linux-<arch> + * one into <loopat>/sandbox-claude — bind THAT into the sandbox, not the host + * binary (otherwise: "Exec format error"). + */ +export function resolveSandboxClaudeBinary(): string { + if (process.platform === "linux") return resolveClaudeBinary() + const candidate = sandboxClaudeBinaryPath() + if (existsSync(candidate)) return candidate + throw new Error(`sandbox (linux) claude not found at ${candidate}; run ensureSandboxClaudeBinary() (loopat fetches it on first boot)`) +} + +/** + * Make sure the sandbox's linux claude exists, fetching it if not. No-op on a + * linux host (host claude IS the sandbox claude). On a non-linux host this runs + * `npm install --force` (the platform binary has os=linux, so --os/--cpu hit + * EBADPLATFORM; --force is what gets it). Pinned to the SDK version. Best-effort + * and idempotent: once fetched, returns immediately. npx does NOT run package + * postinstall scripts, so this boot-time fetch is what actually covers + * `npx loopat`. + */ +export async function ensureSandboxClaudeBinary(onLog?: (m: string) => void): Promise<void> { + if (process.platform === "linux") return + if (existsSync(sandboxClaudeBinaryPath())) return + const arch = process.arch + let version = "" + try { version = createRequire(import.meta.url)("@anthropic-ai/claude-agent-sdk/package.json").version } catch {} + const spec = `@anthropic-ai/claude-agent-sdk-linux-${arch}${version ? `@${version}` : ""}` + const dest = sandboxClaudeDir() + mkdirSync(dest, { recursive: true }) + onLog?.(`host is ${process.platform}/${arch}; fetching linux claude for the sandbox (${spec})…`) + await execFileP("npm", ["install", "--prefix", dest, "--no-save", "--force", spec], { timeout: 180_000 }) + if (!existsSync(sandboxClaudeBinaryPath())) throw new Error(`fetch finished but ${sandboxClaudeBinaryPath()} missing`) + onLog?.(`sandbox claude ready`) +} diff --git a/server/src/compose.ts b/server/src/compose.ts new file mode 100644 index 00000000..5d1faa35 --- /dev/null +++ b/server/src/compose.ts @@ -0,0 +1,474 @@ +/** + * Compose loop's `.claude/` by merging multiple CC-native `.claude/` source + * dirs (team + active profiles + personal) — post-2026-05 CC-native refactor. + * + * Sources (low precedence → high; later wins): + * 1. team: knowledge/.loopat/.claude/ + * 2. profile: knowledge/.loopat/profiles/<name>/.claude/ (per active profile, in order) + * 3. personal: personal/<user>/CLAUDE.md (file) + personal/.loopat/claude/* (skills/agents) + * + * Merge semantics per file: + * - settings.json: deep merge; `enabledPlugins` and `extraKnownMarketplaces` + * are dict unions across sources; other fields take last-wins + * - CLAUDE.md: ordered concat with source markers + * - skills/ + agents/: symlink union (entries from later sources shadow same-name) + * + * The merged dir at loop/.claude/ is the SDK's CLAUDE_CONFIG_DIR — CC reads + * CLAUDE.md, skills, agents from there. Plugins are passed to SDK via the + * `plugins` option (see plugin-installer.ts); cache lookups bypass. + * + * Re-run every spawn; idempotent (nuke + remake). + */ +import { existsSync } from "node:fs" +import { mkdir, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises" +import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path" +import { parse as tomlParse, stringify as tomlStringify } from "smol-toml" +import { + loopClaudeDir, + personalAgentsDir, + personalClaudeDir, + personalClaudeMdPath, + personalSettingsPath, + personalSkillsDir, +} from "./paths" +import { resolveLoopPlan, type LoopPlan } from "./profiles" + +/** Read JSON, return null if missing/malformed. */ +async function readJson<T = unknown>(path: string): Promise<T | null> { + if (!existsSync(path)) return null + try { + return JSON.parse(await readFile(path, "utf8")) as T + } catch { + return null + } +} + +/** + * Fill `mergedSettings.mcpServers` with defaults from each enabled plugin's + * own `settings.json` mcpServers. Plugins are the lowest-priority source: + * a plugin's server is only added if no higher tier (team/profile/personal) + * already defines a server with the same name. + * + * Plugins shipping `.mcp.json` are NOT read here — loopat treats `.mcp.json` + * as deprecated; plugin authors should put mcpServers in `settings.json`. + */ +async function fillPluginMcpDefaults(mergedSettings: Record<string, any>): Promise<void> { + const enabled = Object.entries( + (mergedSettings.enabledPlugins ?? {}) as Record<string, boolean>, + ) + .filter(([_, v]) => v) + .map(([k]) => k) + if (enabled.length === 0) return + + const { lookupPluginInstallPath } = await import("./plugin-installer") + const existing = { ...((mergedSettings.mcpServers ?? {}) as Record<string, any>) } + const merged: Record<string, any> = { ...existing } + + for (const spec of enabled) { + const pluginDir = await lookupPluginInstallPath(spec) + if (!pluginDir) continue + const ps = await readJson<{ mcpServers?: Record<string, any> }>( + join(pluginDir, "settings.json"), + ) + const pluginServers = ps?.mcpServers + if (!pluginServers) continue + for (const [name, srv] of Object.entries(pluginServers)) { + if (merged[name] === undefined) { + merged[name] = srv + } + } + } + + if (Object.keys(merged).length > 0) { + mergedSettings.mcpServers = merged + } +} + +/** Read TOML, return null if missing/malformed. */ +async function readToml<T = Record<string, any>>(path: string): Promise<T | null> { + if (!existsSync(path)) return null + try { + return tomlParse(await readFile(path, "utf8")) as T + } catch (e: any) { + console.warn(`[compose] toml malformed at ${path}: ${e?.message ?? e}`) + return null + } +} + +/** + * Deep-merge TOML-shaped objects (mise.toml / mise.lock semantics): + * - tables (objects): union by key, recursing one level so [tools.node] merges sensibly + * - primitives / arrays: last wins + * Mise's typical tables — [tools], [env], [settings], [hooks], [tasks] — all + * benefit from key-wise union. + */ +function mergeToml( + dst: Record<string, any>, + src: Record<string, any>, +): Record<string, any> { + const out: Record<string, any> = { ...dst } + for (const [k, v] of Object.entries(src)) { + if (v === undefined) continue + if ( + typeof v === "object" && v !== null && !Array.isArray(v) && + typeof out[k] === "object" && out[k] !== null && !Array.isArray(out[k]) + ) { + // For nested tables (e.g. [tools.node] = { version, checksum }), recurse one level + const merged: Record<string, any> = { ...out[k] } + for (const [k2, v2] of Object.entries(v)) { + if ( + typeof v2 === "object" && v2 !== null && !Array.isArray(v2) && + typeof merged[k2] === "object" && merged[k2] !== null && !Array.isArray(merged[k2]) + ) { + merged[k2] = { ...merged[k2], ...v2 } + } else { + merged[k2] = v2 + } + } + out[k] = merged + } else { + out[k] = v + } + } + return out +} + +/** + * Normalize a single extraKnownMarketplaces entry: if its source is + * `{source: "directory", path: <relative>}`, resolve the path against the + * settings file's dir so merged loop settings end up with absolute paths. + * (Loop's merged settings.json is read later by plugin-installer.ts, which + * doesn't know the original source location.) + */ +function normalizeMarketplaceEntry(entry: any, settingsFilePath: string): any { + if (!entry || typeof entry !== "object") return entry + const src = entry.source + if (typeof src === "object" && src.source === "directory" && typeof src.path === "string") { + if (!isAbsolute(src.path)) { + const abs = resolvePath(dirname(settingsFilePath), src.path) + return { ...entry, source: { ...src, path: abs } } + } + } + return entry +} + +/** + * Deep-merge a source settings.json into the accumulator. `enabledPlugins` + + * `extraKnownMarketplaces` union by key; other dict fields shallow-union; + * primitives = last wins. extraKnownMarketplaces paths normalize to absolute. + * + * `srcPath` is the source settings file's host path — needed to resolve + * relative paths in `extraKnownMarketplaces[*].source.path`. + */ +function mergeSettings( + dst: Record<string, any>, + src: Record<string, any>, + srcPath: string, +): Record<string, any> { + const out: Record<string, any> = { ...dst } + for (const [k, v] of Object.entries(src)) { + if (k === "_comment") continue + if (v === undefined) continue + if (k === "extraKnownMarketplaces" && typeof v === "object" && v !== null && !Array.isArray(v)) { + const normalized: Record<string, any> = {} + for (const [name, entry] of Object.entries(v)) { + normalized[name] = normalizeMarketplaceEntry(entry, srcPath) + } + out[k] = { ...(out[k] ?? {}), ...normalized } + } else if ( + k === "enabledPlugins" && + typeof v === "object" && v !== null && !Array.isArray(v) + ) { + out[k] = { ...(out[k] ?? {}), ...v } + } else if ( + typeof v === "object" && v !== null && !Array.isArray(v) && + typeof out[k] === "object" && out[k] !== null && !Array.isArray(out[k]) + ) { + out[k] = { ...out[k], ...v } // shallow union for other dicts + } else { + out[k] = v // primitives / arrays / different types — last wins + } + } + return out +} + +/** + * For each source's `.claude/<subdir>/` (skills or agents), symlink its entries + * into dst. Later sources shadow earlier (same-name → relink). Missing source + * dirs silently skipped. Filter restricts to certain file types (e.g. .md for + * agents). Symlink kind is "dir" for skills (each is a dir), "file" for agents. + */ +async function composeSubdir( + dst: string, + sources: Array<{ source: string; rootDir: string }>, + opts: { kind: "dir" | "file"; filter?: (name: string) => boolean }, +): Promise<void> { + await rm(dst, { recursive: true, force: true }) + await mkdir(dst, { recursive: true }) + for (const src of sources) { + if (!existsSync(src.rootDir)) continue + let entries: string[] + try { + entries = await readdir(src.rootDir) + } catch { + continue + } + for (const name of entries) { + if (name.startsWith(".")) continue + if (opts.filter && !opts.filter(name)) continue + const linkPath = join(dst, name) + await rm(linkPath, { force: true }).catch(() => {}) + await symlink(join(src.rootDir, name), linkPath, opts.kind) + } + } +} + +/** + * Resolve where each LoopPlan source has its .claude/-like dirs. + * For team + profiles, `dir` is the `.claude/` dir itself. + * For personal (`personal:<u>`), everything lives under `.loopat/.claude/` + * (mirrors CC's own `~/.claude/` convention but namespaced under `.loopat/`): + * `CLAUDE.md`, `settings.json`, `skills/`, `agents/`, `mise.toml`, etc. + */ +type ResolvedSource = { + source: string + settings?: string + claudeMd?: string + skillsDir?: string + agentsDir?: string + miseToml?: string + miseLock?: string + /** `.claude/plugins/installed_plugins.json` — CC-native plugin version lock. + * Same shape as host's. We merge across tiers by spec key (last-wins). */ + installedPlugins?: string +} + +function resolveSource(s: { source: string; dir: string }, user: string): ResolvedSource { + if (s.source.startsWith("personal:")) { + // Personal layer uses the CC-native `.claude/` shape — same as team / profile. + return { + source: s.source, + settings: personalSettingsPath(user), + claudeMd: personalClaudeMdPath(user), + skillsDir: personalSkillsDir(user), + agentsDir: personalAgentsDir(user), + miseToml: join(personalClaudeDir(user), "mise.toml"), + miseLock: join(personalClaudeDir(user), "mise.lock"), + installedPlugins: join(personalClaudeDir(user), "plugins", "installed_plugins.json"), + } + } + // team / profile / repo — dir IS the .claude/ dir + return { + source: s.source, + settings: join(s.dir, "settings.json"), + claudeMd: join(s.dir, "CLAUDE.md"), + skillsDir: join(s.dir, "skills"), + agentsDir: join(s.dir, "agents"), + miseToml: join(s.dir, "mise.toml"), + miseLock: join(s.dir, "mise.lock"), + installedPlugins: join(s.dir, "plugins", "installed_plugins.json"), + } +} + +export type ComposeResult = { + claudeMdPath: string + settingsPath: string + sources: string[] + enabledPlugins: string[] // for callers (plugin-installer) to drive install + extraMarketplaces: string[] + /** Path to merged mise.toml in loop's .claude/, or null if no source declared toolchain. */ + miseTomlPath: string | null + /** Path to merged mise.lock in loop's .claude/, or null if no source declared lock. */ + miseLockPath: string | null + /** Path to merged installed_plugins.json in loop's .claude/plugins/, or null if no tier declared one. */ + installedPluginsPath: string | null +} + +/** + * Compose loop .claude/ from the loop's plan. Runs ONCE at loop creation; + * the snapshot is then immutable so subsequent admin pushes to knowledge + * don't change what an existing loop sees (principle 1: loops never change). + * + * Workdir is NOT a tier here — it's read by the SDK as project tier directly + * (settingSources includes 'project'). Compose only merges the user-tier + * sources: workspace + N profiles + personal. + * + * Returns paths for downstream use (plugin-installer reads merged settings; + * spawn reads CLAUDE_CONFIG_DIR). + */ +export async function composeLoopClaudeConfig( + loopId: string, + user: string, + profiles?: string[], +): Promise<ComposeResult> { + const plan: LoopPlan = await resolveLoopPlan({ + user, + overrideProfiles: profiles, + }) + return composeFromPlan(loopId, plan) +} + +export async function composeFromPlan(loopId: string, plan: LoopPlan): Promise<ComposeResult> { + const dst = loopClaudeDir(loopId) + await mkdir(dst, { recursive: true }) + + const resolved = plan.claudeSources.map((s) => resolveSource(s, plan.user)) + + // 1. Merge settings.json + let mergedSettings: Record<string, any> = {} + for (const r of resolved) { + if (!r.settings) continue + const obj = await readJson<Record<string, any>>(r.settings) + if (obj) mergedSettings = mergeSettings(mergedSettings, obj, r.settings) + } + + // 1.5 Fill `mcpServers` defaults from enabled plugins (those plugins' own + // settings.json mcpServers — never `.mcp.json`, which loopat doesn't read). + // Plugins are the lowest priority: their entries only fill keys not + // already defined by team / profile / personal merge above. + await fillPluginMcpDefaults(mergedSettings) + + const settingsPath = join(dst, "settings.json") + // Inject loopat-managed fields that downstream code expects. + mergedSettings.autoMemoryEnabled = mergedSettings.autoMemoryEnabled ?? true + mergedSettings.autoMemoryDirectory = + mergedSettings.autoMemoryDirectory ?? "/loopat/context/personal/memory" + await writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2)) + + // 2. Concat CLAUDE.md + const claudeMdPath = join(dst, "CLAUDE.md") + const parts: string[] = [] + for (const r of resolved) { + if (!r.claudeMd || !existsSync(r.claudeMd)) continue + try { + const content = (await readFile(r.claudeMd, "utf8")).trim() + parts.push( + `<!-- ========== ${r.source} ========== -->\n<!-- from: ${r.claudeMd} -->\n${content}`, + ) + } catch (e: any) { + console.warn(`[compose] ${r.source} CLAUDE.md unreadable: ${e?.message ?? e}`) + } + } + if (parts.length === 0) { + await rm(claudeMdPath, { force: true }) + } else { + await writeFile(claudeMdPath, parts.join("\n\n") + "\n") + } + + // 3. Symlink-merge skills/ (each entry is a dir with SKILL.md) + await composeSubdir( + join(dst, "skills"), + resolved.filter((r) => r.skillsDir).map((r) => ({ source: r.source, rootDir: r.skillsDir! })), + { kind: "dir" }, + ) + + // 4. Symlink-merge agents/ (each entry is a single .md file) + await composeSubdir( + join(dst, "agents"), + resolved.filter((r) => r.agentsDir).map((r) => ({ source: r.source, rootDir: r.agentsDir! })), + { kind: "file", filter: (n) => n.endsWith(".md") }, + ) + + // 5. Merge mise.toml + mise.lock (toolchain layer, loopat-native extension to .claude/) + let mergedMiseToml: Record<string, any> = {} + let anyMiseToml = false + for (const r of resolved) { + if (!r.miseToml) continue + const obj = await readToml<Record<string, any>>(r.miseToml) + if (obj) { + mergedMiseToml = mergeToml(mergedMiseToml, obj) + anyMiseToml = true + } + } + let miseTomlPath: string | null = null + if (anyMiseToml) { + miseTomlPath = join(dst, "mise.toml") + await writeFile(miseTomlPath, tomlStringify(mergedMiseToml)) + } else { + await rm(join(dst, "mise.toml"), { force: true }) + } + + let mergedMiseLock: Record<string, any> = {} + let anyMiseLock = false + for (const r of resolved) { + if (!r.miseLock) continue + const obj = await readToml<Record<string, any>>(r.miseLock) + if (obj) { + mergedMiseLock = mergeToml(mergedMiseLock, obj) + anyMiseLock = true + } + } + let miseLockPath: string | null = null + if (anyMiseLock) { + miseLockPath = join(dst, "mise.lock") + await writeFile(miseLockPath, tomlStringify(mergedMiseLock)) + } else { + await rm(join(dst, "mise.lock"), { force: true }) + } + + // 6. Merge installed_plugins.json (CC-native plugin version lock). + // + // Each tier may publish a .claude/plugins/installed_plugins.json with the + // same shape CC writes to ~/.claude/plugins/. We union by spec key, + // last-wins (personal overrides team). The merged file is the loop's lock — + // bwrap binds it over the sandbox's ~/.claude/plugins/installed_plugins.json + // so the inner SDK resolves each plugin to the pinned version, not whatever + // happens to be on the host right now. + // + // Why include this at all: without a per-loop snapshot, member's + // `claude plugin update` on host would silently change what a previously- + // created loop sees on next spawn. Locking via this file freezes the loop's + // plugin set at creation time (principle 1). + let mergedInstalledPlugins: { version?: number; plugins?: Record<string, any[]> } | null = null + for (const r of resolved) { + if (!r.installedPlugins) continue + const obj = await readJson<{ version?: number; plugins?: Record<string, any[]> }>( + r.installedPlugins, + ) + if (!obj) continue + if (!mergedInstalledPlugins) { + mergedInstalledPlugins = { version: obj.version ?? 1, plugins: {} } + } + for (const [spec, entries] of Object.entries(obj.plugins ?? {})) { + mergedInstalledPlugins.plugins![spec] = entries // per-spec last-wins + } + } + let installedPluginsPath: string | null = null + if (mergedInstalledPlugins) { + const ipDir = join(dst, "plugins") + await mkdir(ipDir, { recursive: true }) + installedPluginsPath = join(ipDir, "installed_plugins.json") + await writeFile(installedPluginsPath, JSON.stringify(mergedInstalledPlugins, null, 2)) + } else { + // No tier declared a lock → ensure no stale lock from a previous compose + await rm(join(dst, "plugins", "installed_plugins.json"), { force: true }) + } + + const enabledPlugins = Object.keys( + (mergedSettings.enabledPlugins ?? {}) as Record<string, boolean>, + ).filter((k) => mergedSettings.enabledPlugins[k]) + + const extraMarketplaces = Object.keys( + (mergedSettings.extraKnownMarketplaces ?? {}) as Record<string, unknown>, + ) + + return { + claudeMdPath, + settingsPath, + sources: resolved.map((r) => r.source), + enabledPlugins, + extraMarketplaces, + miseTomlPath, + miseLockPath, + installedPluginsPath, + } +} + +/** + * Write settings.json under the loop's .claude/. DEPRECATED in the CC-native + * model — settings are written by composeFromPlan. Kept as a no-op for + * backward-compat callers (loops.ts). Use composeLoopClaudeConfig instead. + */ +export async function writeLoopSettings(_loopId: string): Promise<void> { + // no-op +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 00000000..e979fd16 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,857 @@ +import { existsSync, statSync } from "node:fs" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { dirname, join } from "node:path" +import { + personalLoopatConfigPath, + personalLoopatDir, + personalTokenUsagePath, + personalVaultDir, + personalVaultEnvPath, + personalVaultEnvsDir, + workspaceDir, + workspaceLoopatRoot, + personalKnowledgeLoopatRoot, + personalSettingsPath, +} from "./paths" +import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults" + +/** + * MCP server config — shape matches Claude Agent SDK `McpServerConfig`. + * - stdio: spawn a command (binary must be reachable in sandbox PATH) + * - http/sse: connect to URL (network is shared with host, no extra bind needed) + */ +export type McpServerConfig = + | { type?: "stdio"; command: string; args?: string[]; env?: Record<string, string> } + | { type: "http"; url: string; headers?: Record<string, string> } + | { type: "sse"; url: string; headers?: Record<string, string> } + +/** CC-native marketplace source. We support local + git + github in step 3. */ +export type MarketplaceSource = + | { source: "local"; path: string } + | { source: "git"; url: string } + | { source: "github"; repo: string } + +export type WorkspaceClaudeJson = { + mcpServers?: Record<string, McpServerConfig> + /** Marketplaces to register. CC-native shape: keyed by marketplace name. */ + extraKnownMarketplaces?: Record<string, { source: MarketplaceSource }> + /** Plugins to enable. CC-native shape: { "name@market": true }. */ + enabledPlugins?: Record<string, boolean> +} + +/** A model entry within a provider's model list. */ +export type ModelEntry = { + id: string + enabled?: boolean + /** Per-model context-window override (takes precedence over provider-level). */ + maxContextTokens?: number +} + +export type ProviderPreset = { + name: string + baseUrl: string + models: string[] +} + +export type MiseToolPreset = { + name: string + suggestedVersion: string + description?: string + backend?: string +} + +export type PresetsData = { + providerPresets: ProviderPreset[] + miseToolPresets: MiseToolPreset[] +} + +/** + * On-disk shape of a provider. `apiKey` is a plain string that may contain + * `${VAR}` references resolved against vault envs at load time. Empty / unset + * means no key (provider effectively disabled). + */ +export type ProviderConfigDisk = { + model?: string // legacy single-model; migrated to models[] on read + models?: ModelEntry[] // canonical multi-model format + baseUrl: string + apiKey?: string + maxContextTokens?: number + enabled?: boolean // provider-level toggle, default true +} + +/** Runtime/resolved shape — apiKey is the actual string after resolution. */ +export type ProviderConfig = { + /** Canonical model list (at least one entry after migration). */ + models: ModelEntry[] + baseUrl: string + /** Resolved at load time: `${VAR}` references in the disk apiKey are + * expanded against the active vault's envs/. Empty string if the + * referenced env doesn't exist (provider effectively disabled). */ + apiKey: string + /** + * Override cli's context-window detection for this model. cli has a + * hardcoded list (DP / XV8 / coral_reef_sonnet predicates) of claude + * models that get 1M; everything else falls back to DR1=200000. For + * gateway-routed / non-claude models with larger windows, set this so + * auto-compact (92% × window) fires at the right point. Activated via + * env vars DISABLE_COMPACT=1 + CLAUDE_CODE_MAX_CONTEXT_TOKENS=<value>. + */ + maxContextTokens?: number + enabled: boolean +} + +export type RemoteSpec = { + /** clone URL; empty string or omitted = local-only, don't clone */ + git?: string +} + +/** A repo registered for spawn-loop use, cloned to context/repos/<name>/. */ +export type RepoSpec = { + name: string + git: string +} + +/** + * Config living INSIDE the knowledge repo at <knowledge>/.loopat/config.json. + * The knowledge repo is the SoT for the team's notes remote; workspace/personal + * config only hold the `knowledge` entry pointer. To read it the knowledge repo + * must already be cloned (its url comes from personal/workspace config), so this + * is loaded AFTER the knowledge clone. (The repo roster is NO LONGER here — it + * moved to personal config; see PersonalConfig.repos.) + */ +export type KnowledgeConfig = { + notes?: RemoteSpec +} + +const KNOWLEDGE_CONFIG_TEMPLATE: KnowledgeConfig = { notes: { git: "" } } + +/** The .loopat root holding the knowledge config. With a user → that user's + * per-user knowledge repo; without → the workspace-default knowledge repo + * (used only for the bootstrap banner, never to drive a loop). */ +function knowledgeLoopatRoot(user?: string): string { + return user ? personalKnowledgeLoopatRoot(user) : workspaceLoopatRoot() +} + +/** Read the knowledge repo's .loopat/config.json. Missing/malformed → empty. + * Pass `user` for the per-user knowledge repo (what a loop uses); omit for the + * workspace-default (bootstrap display only). */ +export async function loadKnowledgeConfig(user?: string): Promise<KnowledgeConfig> { + const path = join(knowledgeLoopatRoot(user), "config.json") + if (!existsSync(path)) return JSON.parse(JSON.stringify(KNOWLEDGE_CONFIG_TEMPLATE)) + try { + const disk = JSON.parse(await readFile(path, "utf8")) as KnowledgeConfig + return { notes: disk.notes } + } catch (e: any) { + console.warn(`[loopat] knowledge config: ${path} malformed (${e?.message ?? e}), treating as empty`) + return JSON.parse(JSON.stringify(KNOWLEDGE_CONFIG_TEMPLATE)) + } +} + +/** Merge-write a knowledge repo's .loopat/config.json. Caller is responsible + * for committing/promoting the change (knowledge is gated). */ +export async function saveKnowledgeConfig(user: string | undefined, patch: KnowledgeConfig): Promise<void> { + const cur = await loadKnowledgeConfig(user) + const next: KnowledgeConfig = { + notes: patch.notes !== undefined ? patch.notes : cur.notes, + } + const dir = knowledgeLoopatRoot(user) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "config.json"), JSON.stringify(next, null, 2) + "\n") +} + +/** Operator-side mount (workspace config). src is always a literal host path. + * Operator owns the host, so any path under `~/...`, `$HOME/...`, or `/...` + * is allowed (modulo `..` traversal). Used for cross-user shared caches + * (e.g. /etc/pki/ca-trust). */ +export type OperatorMount = { + src: string + dst: string + rw?: boolean +} + +/** + * Workspace config (~/.loopat/config.json): workspace-shared, no per-user content. + * Hand this file to a clean machine and bootstrap can reconstruct the + * workspace: clone knowledge/notes/repos from remotes, seed doctrine. + * + * Per-user pieces (sandbox, providers, default provider) live in + * personal/<user>/.loopat/config.json — see PersonalConfig. + */ +export type WorkspaceConfig = { + /** Workspace-default entry pointer to the knowledge repo. The notes remote + * lives INSIDE the knowledge repo's .loopat/config.json (KnowledgeConfig) — + * see loadKnowledgeConfig. The repo roster is per-user (PersonalConfig.repos). + * A per-user knowledge override lives in personal config. */ + knowledge?: RemoteSpec + providers?: Record<string, ProviderConfig> + default?: string + /** Platform-level git host for personal onboarding. `provider` is a + * GitHostProvider id (= the extension filename, e.g. "code"); empty/absent + * means GitHub. `baseUrl` is the API host for self-hosted/internal. */ + gitHost?: { provider?: string; baseUrl?: string; defaultRepo?: string } + /** Operator-level mounts — any host path. Shared across all loops on this + * workspace. Only the operator (the host shell user) can edit. */ + mounts?: OperatorMount[] + /** Domain suffix for workspace serve (e.g. "nip.io"). Defaults to "nip.io". */ + serveDomain?: string + /** Whether to include port in the share URL. */ + serveWithPort?: boolean + /** Whether to use HTTPS for share URLs. */ + serveHttps?: boolean + /** Custom port to show in share URL (does not affect actual server listen port). */ + serveDisplayPort?: number + /** Enable standard serve (subdomain-based, serve-rs). */ + serveEnabled?: boolean + /** Enable dynamic port forwarding (port-proxy). */ + serveDynamicEnabled?: boolean + /** Domain or IP for dynamic port access URLs (empty = auto-detect IP). */ + serveDynamicDomain?: string + /** Port range for dynamic port forwarding (e.g., "10000-20000"). */ + serveDynamicPortRange?: string + /** Whether to allow UDP protocol in dynamic port forwarding. */ + serveDynamicUdpEnabled?: boolean + /** Whether dynamic ports can serve static files from workdir. */ + serveDynamicStaticEnabled?: boolean + /** Enable ephemeral-port mode: each loop container publishes its share + * port via `podman -p :<internal>`, kernel-assigned host port, fresh + * every container restart. No port-proxy involved. */ + serveEphemeralEnabled?: boolean + /** Domain or IP for ephemeral-port access URLs (empty = auto-detect IP). */ + serveEphemeralDomain?: string + /** Admin-managed presets for quick-add in provider/mise tool configs. */ + presets?: PresetsData +} + +/** + * Personal config (personal/<user>/.loopat/config.json): per-user, kept in + * each driver's personal/ tree. + * + * On-disk layout: + * - `providers` is a heterogeneous map: a special key `"default"` carries + * a string (the active provider name); all other keys map to + * `ProviderConfigDisk`. We accept the slight type wobble in exchange + * for keeping every provider-related field under one section, which + * matches how the Settings UI groups them. No provider is allowed to + * be literally named "default". + * - `apiKey` is a plain string that may contain `${VAR}` references. At + * load time, each `${VAR}` is resolved against the active vault's + * `envs/<VAR>` file. Unset → empty string (provider effectively off). + * - Sandbox env vars and CLI config mounts are conventional, not declared: + * anything in `vault/envs/*` is auto-injected, anything in + * `vault/mounts/home/<rel>/...` is auto-bound at $HOME/<rel>/... + * There is no `envs` or `mounts` field — filesystem layout IS the spec. + */ +export type PersonalConfigDisk = { + /** Mixed: "default" key is a string, all other keys are providers. */ + providers: Record<string, ProviderConfigDisk | string> + /** Per-user entry pointer to the knowledge repo (authoritative over the + * workspace default). The notes remote lives inside the knowledge repo's + * own .loopat/config.json, not here. */ + knowledge?: RemoteSpec + /** Per-user repo roster — clone-on-demand at loop creation. Personal, not + * team-shared (moved here from the knowledge repo). Edited via /context/repos. */ + repos?: RepoSpec[] +} + +export type PersonalConfig = { + /** Active provider name. On disk this lives at `providers.default`. */ + default: string + providers: Record<string, ProviderConfig> + /** + * Resolved env vars from the active vault's `envs/` dir. Filename → value. + * Used to (a) inject into spawn env so spawned binary's `${VAR}` substitution + * in mcpServers works, and (b) substitute `${VAR}` in provider.apiKey. + */ + vaultEnvs: Record<string, string> + /** Per-user entry pointer to the knowledge repo (notes lives in the + * knowledge repo's own .loopat/config.json). */ + knowledge?: RemoteSpec + /** Per-user repo roster — clone-on-demand at loop creation. */ + repos: RepoSpec[] +} + +/** + * Parse a default selector string. Supports two formats: + * - "providerName/modelId" (new) → { providerName, modelId } + * - "providerName" (legacy) → { providerName } + * Backward-compatible: if no "/" is present, the whole string is the provider name. + */ +export function parseDefault(raw: string): { providerName: string; modelId?: string } { + if (!raw) return { providerName: "" } + const slashIdx = raw.indexOf("/") + if (slashIdx <= 0) return { providerName: raw } + return { + providerName: raw.slice(0, slashIdx), + modelId: raw.slice(slashIdx + 1) || undefined, + } +} + +/** Preset providers with Anthropic-compatible endpoints. loopat uses the + * Claude Agent SDK which speaks the Anthropic Messages API — only providers + * that expose an Anthropic-compatible endpoint work directly. + * Each provider is disabled by default; the user supplies an API key. */ +import { PROVIDER_PRESETS } from "./presets" + +function buildPresetProviders(): Record<string, ProviderConfig> { + return Object.fromEntries( + PROVIDER_PRESETS.map(p => [ + p.name, + { + models: p.models.map(id => ({ id, enabled: true })), + baseUrl: p.baseUrl, + apiKey: "", + enabled: false, + } satisfies ProviderConfig, + ]), + ) +} + +const WORKSPACE_TEMPLATE: WorkspaceConfig = { + knowledge: { git: "" }, + providers: buildPresetProviders(), +} + +const PERSONAL_TEMPLATE: PersonalConfig = { + default: PROVIDER_PRESETS[0] ? `${PROVIDER_PRESETS[0].name}/${PROVIDER_PRESETS[0].models[0]}` : "", + providers: buildPresetProviders(), + vaultEnvs: {}, + repos: [], +} + +/** On-disk shape used when a config.json is missing or malformed. Seeded + * with presets so the user has a populated model list immediately. */ +const PERSONAL_DISK_TEMPLATE: PersonalConfigDisk = { + providers: (() => { + const providers: Record<string, ProviderConfigDisk | string> = { + default: PROVIDER_PRESETS[0] ? `${PROVIDER_PRESETS[0].name}/${PROVIDER_PRESETS[0].models[0]}` : "", + } + for (const p of PROVIDER_PRESETS) { + providers[p.name] = { + models: p.models.map(id => ({ id, enabled: true })), + baseUrl: p.baseUrl, + enabled: false, + } + } + return providers + })(), +} + +export const configPath = () => join(workspaceDir(), "config.json") + +let cachedWorkspace: WorkspaceConfig | null = null +let cachedWorkspaceMtimeMs = 0 + +export async function loadConfig(): Promise<WorkspaceConfig> { + const path = configPath() + if (!existsSync(path)) { + await mkdir(workspaceDir(), { recursive: true }) + await writeFile(path, JSON.stringify(WORKSPACE_TEMPLATE, null, 2) + "\n") + console.warn(`[loopat] config: created template at ${path}`) + cachedWorkspace = WORKSPACE_TEMPLATE + cachedWorkspaceMtimeMs = statSync(path).mtimeMs + return cachedWorkspace + } + // Re-read on mtime change so edits take effect on next attach without a + // server restart. + const mtimeMs = statSync(path).mtimeMs + if (cachedWorkspace && mtimeMs === cachedWorkspaceMtimeMs) return cachedWorkspace + const raw = await readFile(path, "utf8") + const parsed = JSON.parse(raw) as WorkspaceConfig + // Normalize legacy single-model providers to canonical models[] format. + if (parsed.providers) { + for (const [name, p] of Object.entries(parsed.providers)) { + const disk = p as any + if (!disk.models && disk.model) { + ;(p as any).models = [{ id: disk.model, enabled: true }] + } + if (p.enabled === undefined) (p as any).enabled = true + } + } + cachedWorkspace = parsed + cachedWorkspaceMtimeMs = mtimeMs + return cachedWorkspace +} + +// Cache key = `${user}|${vault}` so per-vault apiKey/env resolutions don't +// clobber each other. +const personalCache = new Map<string, { + cfg: PersonalConfig + configMtimeMs: number + /** Snapshot of the vault envs dir mtime; if the dir changes (file added / + * removed / value edited) we re-resolve. We don't track per-file mtimes + * because vault envs are small enough to re-walk cheaply on miss. */ + envsDirMtimeMs: number +}>() + +export function clearPersonalCache(user: string): void { + for (const k of personalCache.keys()) { + if (k === user || k.startsWith(`${user}|`)) personalCache.delete(k) + } +} + +const VAR_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g + +/** Substitute every `${VAR}` in a template against the env map. Unknown + * vars resolve to empty string. Literal strings (no $) pass through. */ +export function expandVars(template: string, envs: Record<string, string>): string { + if (!template || !template.includes("${")) return template + return template.replace(VAR_REF_RE, (_, name) => envs[name] ?? "") +} + +/** + * Load personal config from personal/<user>/.loopat/config.json. Resolves + * each provider's apiKey + every env entry against the selected vault. + * + * Missing config.json → in-memory empty template (do NOT lazy-write it; the + * vault may have been intentionally deleted). + */ +export async function loadPersonalConfig( + user: string, + vault: string = DEFAULT_VAULT, +): Promise<PersonalConfig> { + const path = personalLoopatConfigPath(user) + if (!existsSync(path)) { + return JSON.parse(JSON.stringify(PERSONAL_TEMPLATE)) as PersonalConfig + } + const configMtimeMs = statSync(path).mtimeMs + const envsDir = personalVaultEnvsDir(user, vault) + const envsDirMtimeMs = existsSync(envsDir) ? statSync(envsDir).mtimeMs : 0 + const cacheKey = `${user}|${vault}` + const cached = personalCache.get(cacheKey) + if ( + cached && + cached.configMtimeMs === configMtimeMs && + cached.envsDirMtimeMs === envsDirMtimeMs + ) { + return cached.cfg + } + + const raw = await readFile(path, "utf8") + let disk: PersonalConfigDisk + try { + disk = JSON.parse(raw) as PersonalConfigDisk + if (!disk.providers || typeof disk.providers !== "object") { + throw new Error(`missing providers`) + } + } catch (e: any) { + console.warn(`[loopat] personal config: ${path} is malformed (${e?.message ?? e}), rewriting template`) + await writeFile(path, JSON.stringify(PERSONAL_DISK_TEMPLATE, null, 2) + "\n") + disk = JSON.parse(JSON.stringify(PERSONAL_DISK_TEMPLATE)) as PersonalConfigDisk + } + + // Vault envs feed both the spawn env and ${VAR} substitution in apiKey. + const vaultEnvs = await loadVaultEnvs(user, vault) + + // Split the heterogeneous providers map: pull out the special "default" + // string key, leave the rest as provider entries. + const rawDefault = typeof disk.providers.default === "string" ? disk.providers.default : "" + const { providerName: defaultProviderName } = parseDefault(rawDefault) + const providerEntries: Array<[string, ProviderConfigDisk]> = [] + for (const [name, val] of Object.entries(disk.providers)) { + if (name === "default") continue + if (val && typeof val === "object") providerEntries.push([name, val as ProviderConfigDisk]) + } + if (defaultProviderName && !providerEntries.some(([n]) => n === defaultProviderName)) { + console.warn(`[loopat] personal config: default "${rawDefault}" provider "${defaultProviderName}" not in providers (ignored)`) + } + + const providers: Record<string, ProviderConfig> = {} + for (const [name, p] of providerEntries) { + let apiKey = "" + if (typeof p.apiKey === "string") { + apiKey = expandVars(p.apiKey, vaultEnvs) + } else if (p.apiKey && typeof (p.apiKey as any).vault === "string") { + // Resolve { vault: "provider-keys/DeepSeek" } format + const vaultPath = join(personalVaultDir(user, vault), (p.apiKey as any).vault as string) + try { apiKey = (await readFile(vaultPath, "utf8")).trim() } catch {} + } + // Normalize legacy single-model to canonical models[] format. + const models: ModelEntry[] = p.models && p.models.length > 0 + ? p.models.map(m => ({ id: m.id, enabled: m.enabled !== false })) + : (p.model ? [{ id: p.model, enabled: true }] : []) + providers[name] = { + models, + baseUrl: p.baseUrl, + apiKey, + enabled: p.enabled !== false, + ...(p.maxContextTokens ? { maxContextTokens: p.maxContextTokens } : {}), + } + } + + const cfg: PersonalConfig = { + default: defaultProviderName && providers[defaultProviderName] ? rawDefault : "", + providers, + vaultEnvs, + repos: Array.isArray(disk.repos) ? disk.repos : [], + ...(disk.knowledge ? { knowledge: disk.knowledge } : {}), + } + personalCache.set(cacheKey, { cfg, configMtimeMs, envsDirMtimeMs }) + return cfg +} + +// ── Per-user A2A config ─────────────────────────────────────────────────── +// The user's A2A agent: editable card fields + which profiles/vault loops +// created by A2A use. No secrets (the credential is the user's API token), so +// it's plain JSON next to config.json. `$LOOPAT_HOME/personal/<user>/.loopat/a2a.json`. +export type A2AUserConfig = { + card?: { name?: string; description?: string } + profiles?: string[] + vault?: string +} +function a2aConfigPath(user: string): string { + return join(personalLoopatDir(user), "a2a.json") +} +export async function loadA2AConfig(user: string): Promise<A2AUserConfig> { + const p = a2aConfigPath(user) + if (!existsSync(p)) return {} + try { + const j = JSON.parse(await readFile(p, "utf8")) as A2AUserConfig + return j && typeof j === "object" ? j : {} + } catch { return {} } +} +export async function saveA2AConfig(user: string, patch: A2AUserConfig): Promise<void> { + const cur = await loadA2AConfig(user) + const next: A2AUserConfig = { + card: patch.card !== undefined ? patch.card : cur.card, + profiles: patch.profiles !== undefined ? patch.profiles : cur.profiles, + vault: patch.vault !== undefined ? patch.vault : cur.vault, + } + const p = a2aConfigPath(user) + await mkdir(dirname(p), { recursive: true }) + await writeFile(p, JSON.stringify(next, null, 2) + "\n") +} + +export function getActiveProvider(cfg: PersonalConfig): { name: string; provider: ProviderConfig } | null { + const raw = cfg.default + if (!raw) return null + const { providerName } = parseDefault(raw) + if (!providerName || !cfg.providers[providerName]) return null + return { name: providerName, provider: cfg.providers[providerName] } +} + +/** + * Per-user Claude config. Same JSON shape as workspace claude.json. Personal + * `mcpServers[<name>]` entries shadow workspace entries by name (user-tier + * wins over admin-tier — consistent with the skill/plugin compose model). + */ +export async function loadPersonalClaudeJson(user: string): Promise<WorkspaceClaudeJson> { + const p = personalSettingsPath(user) + if (!existsSync(p)) return {} + try { + return JSON.parse(await readFile(p, "utf8")) as WorkspaceClaudeJson + } catch (e: any) { + console.warn(`[loopat] personal claude.json malformed at ${p}: ${e?.message ?? e}`) + return {} + } +} + +// ── token usage ── + +export type TokenUsage = Record<string, { inputTokens: number; outputTokens: number }> + +export async function loadTokenUsage(user: string): Promise<TokenUsage> { + const p = personalTokenUsagePath(user) + if (!existsSync(p)) return {} + try { + return JSON.parse(await readFile(p, "utf8")) as TokenUsage + } catch { + return {} + } +} + +export async function saveTokenUsage(user: string, usage: TokenUsage): Promise<void> { + await mkdir(personalLoopatDir(user), { recursive: true }) + await writeFile(personalTokenUsagePath(user), JSON.stringify(usage, null, 2) + "\n") +} + +export async function addTokenUsage(user: string, model: string, inputTokens: number, outputTokens: number): Promise<void> { + if (!model || (inputTokens === 0 && outputTokens === 0)) return + const usage = await loadTokenUsage(user) + const entry = usage[model] ?? { inputTokens: 0, outputTokens: 0 } + entry.inputTokens += inputTokens + entry.outputTokens += outputTokens + usage[model] = entry + await saveTokenUsage(user, usage) +} + +// ── config persistence ── + +/** + * Read the raw on-disk shape (without resolving any references). Used by + * savers that need to preserve existing apiKey/env reference structure. + */ +export async function readPersonalDiskRaw(user: string): Promise<PersonalConfigDisk> { + return readPersonalDisk(user) +} + +/** + * For an apiKey string that may contain `${VAR}` references, describe the + * shape so the Settings UI can render "✓ exists / ✗ missing" indicators + * without leaking the value. + * + * - "literal" : no `${VAR}` ref; value is the literal text (or empty) + * - "var" : exactly one `${VAR}` ref; reports whether the vault env + * file `envs/<VAR>` exists + * - "mixed" : multiple refs or template+text; existence not surfaced + */ +export function describeApiKeyRef( + apiKey: string | undefined, + user: string, + vault: string = DEFAULT_VAULT, +): { kind: "literal" | "var" | "mixed" | "empty"; varName?: string; path?: string; exists: boolean } { + // Handle { vault: "..." } object format + if (typeof apiKey !== "string") { + if (apiKey && typeof (apiKey as any).vault === "string") { + const vaultPath = join(personalVaultDir(user, vault), (apiKey as any).vault as string) + return { kind: "var", varName: (apiKey as any).vault, path: vaultPath, exists: existsSync(vaultPath) } + } + return { kind: "empty", exists: false } + } + if (!apiKey) return { kind: "empty", exists: false } + const matches = [...apiKey.matchAll(VAR_REF_RE)] + if (matches.length === 0) return { kind: "literal", exists: true } + if (matches.length === 1 && matches[0][0] === apiKey) { + const name = matches[0][1] + const path = personalVaultEnvPath(user, vault, name) + return { kind: "var", varName: name, path, exists: existsSync(path) } + } + return { kind: "mixed", exists: false } +} + +/** + * Apply a structural patch to personal/<user>/.loopat/config.json. Accepts + * partial fields from `PersonalConfigDisk`; only fields present on the + * patch are touched. Does NOT write any secret values — apiKey values + * referenced as `${VAR}` are managed via `writeVaultEnv()`. + */ +export async function savePersonalDisk( + user: string, + patch: Partial<PersonalConfigDisk>, +): Promise<{ ok: true } | { ok: false; error: string }> { + const disk = await readPersonalDisk(user) + if (patch.providers !== undefined) { + for (const [name, val] of Object.entries(patch.providers)) { + if (name === "default") { + if (typeof val !== "string") return { ok: false, error: `providers.default must be a string` } + continue + } + if (!val || typeof val !== "object" || Array.isArray(val)) { + return { ok: false, error: `provider "${name}" must be an object` } + } + const p = val as ProviderConfigDisk + const hasModels = Array.isArray(p.models) && p.models.length > 0 + const hasModel = typeof p.model === "string" + if (!hasModels && !hasModel) { + return { ok: false, error: `provider "${name}" missing models (or legacy model)` } + } + if (typeof p.baseUrl !== "string") { + return { ok: false, error: `provider "${name}" missing baseUrl` } + } + if (p.apiKey !== undefined && typeof p.apiKey !== "string" && !(typeof p.apiKey === "object" && typeof (p.apiKey as any).vault === "string")) { + return { ok: false, error: `provider "${name}" apiKey must be a string or { vault }` } + } + } + const defName = patch.providers.default + if (typeof defName === "string" && defName) { + const { providerName } = parseDefault(defName) + const exists = Object.entries(patch.providers).some(([n, v]) => n !== "default" && n === providerName && typeof v === "object") + if (!exists) return { ok: false, error: `default "${defName}" provider "${providerName}" not in providers` } + } + // Force enabled: false for providers without an apiKey reference. + for (const [name, val] of Object.entries(patch.providers)) { + if (name === "default" || !val || typeof val !== "object") continue + const p = val as ProviderConfigDisk + if (p.enabled !== false) { + const hasNewKey = (typeof p.apiKey === "string" && p.apiKey.length > 0) || (p.apiKey && typeof (p.apiKey as any).vault === "string") + const existingEntry = disk.providers[name] + const existingKey = (existingEntry && typeof existingEntry === "object") ? (existingEntry as ProviderConfigDisk).apiKey : undefined + if (!hasNewKey && !existingKey) { + p.enabled = false + } + } + } + disk.providers = patch.providers + } + + if (patch.repos !== undefined) { + disk.repos = patch.repos + .filter((r) => r && typeof r.name === "string" && typeof r.git === "string") + .map((r) => ({ name: r.name.trim(), git: r.git.trim() })) + .filter((r) => r.name && r.git) + } + + await mkdir(personalLoopatDir(user), { recursive: true }) + await writeFile(personalLoopatConfigPath(user), JSON.stringify(disk, null, 2) + "\n") + clearPersonalCache(user) + return { ok: true } +} + +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Write a value to the vault's `envs/<NAME>` file. Used when the Settings UI + * stores a fresh apiKey / token value. Caller chooses the variable name; we + * just validate and write. Re-reading the personal config picks up the value + * automatically via `${VAR}` substitution. + */ +export async function writeVaultEnv( + user: string, + vault: string, + name: string, + value: string, +): Promise<{ ok: true; path: string } | { ok: false; error: string }> { + if (!ENV_NAME_RE.test(name)) return { ok: false, error: `invalid env name "${name}"` } + const writeAt = personalVaultEnvPath(user, vault, name) + await mkdir(dirname(writeAt), { recursive: true }) + await writeFile(writeAt, value.replace(/\r?\n+$/, "") + "\n") + clearPersonalCache(user) + return { ok: true, path: writeAt } +} + +/** Delete a vault env file. No-op if missing. */ +export async function deleteVaultEnv(user: string, vault: string, name: string): Promise<void> { + if (!ENV_NAME_RE.test(name)) return + const p = personalVaultEnvPath(user, vault, name) + if (existsSync(p)) { + const { rm } = await import("node:fs/promises") + await rm(p, { force: true }) + } + clearPersonalCache(user) +} + +async function readPersonalDisk(user: string): Promise<PersonalConfigDisk> { + const path = personalLoopatConfigPath(user) + if (!existsSync(path)) { + return JSON.parse(JSON.stringify(PERSONAL_DISK_TEMPLATE)) as PersonalConfigDisk + } + try { + const parsed = JSON.parse(await readFile(path, "utf8")) as PersonalConfigDisk + if (!parsed.providers || typeof parsed.providers !== "object") parsed.providers = {} + return parsed + } catch { + return JSON.parse(JSON.stringify(PERSONAL_DISK_TEMPLATE)) as PersonalConfigDisk + } +} + +/** + * Save personal config to disk. Provider apiKey values are stored in vault + * envs/<NAME>_API_KEY (NAME = uppercase provider name); config.json carries + * a `${VAR}` reference. `default` lives at `providers.default` inside the + * providers map. + */ +export async function savePersonalConfig(user: string, cfg: { + default?: string + providers?: Record<string, { model?: string; models?: ModelEntry[]; baseUrl: string; apiKey?: string; maxContextTokens?: number; enabled?: boolean }> +}): Promise<void> { + const disk = await readPersonalDisk(user) + const existingDefault = typeof disk.providers.default === "string" ? disk.providers.default : "" + + if (cfg.providers !== undefined) { + const rebuilt: Record<string, ProviderConfigDisk | string> = {} + const nextDefault = cfg.default !== undefined ? cfg.default : existingDefault + if (nextDefault) rebuilt.default = nextDefault + for (const [name, p] of Object.entries(cfg.providers)) { + if (name === "default") { + console.warn(`[loopat] savePersonalConfig: ignored provider named "default" (reserved key)`) + continue + } + const existingEntry = disk.providers[name] + const existingKey = (existingEntry && typeof existingEntry === "object") ? existingEntry.apiKey : undefined + // Decide the apiKey field for disk: + // - If the user passed a new value, derive the env var name and stash + // the literal value into vault envs/<VAR>, then write a `${VAR}` ref. + // - Else keep whatever was there. + const defaultVar = providerEnvVarName(name) + let apiKeyField: string | undefined = existingKey + const hasNewKey = p.apiKey !== undefined && p.apiKey.trim() !== "" + if (hasNewKey) { + // If existing ref is a `${VAR}` template, reuse its var name; otherwise + // pick a deterministic default like ANTHROPIC_API_KEY for "Anthropic". + const targetVar = (existingKey && extractSingleVarName(existingKey)) ?? defaultVar + await writeVaultEnv(user, DEFAULT_VAULT, targetVar, p.apiKey!.trim()) + apiKeyField = `\${${targetVar}}` + } else if (!apiKeyField) { + // No new key, no existing key → leave field unset (provider disabled). + apiKeyField = undefined + } + const models: ModelEntry[] = p.models && p.models.length > 0 + ? p.models.map(m => ({ id: m.id, ...(m.enabled === false ? { enabled: false } : {}) })) + : (p.model ? [{ id: p.model, enabled: true }] : []) + rebuilt[name] = { + baseUrl: p.baseUrl, + ...(apiKeyField !== undefined ? { apiKey: apiKeyField } : {}), + ...(models.length > 0 ? { models } : {}), + ...(p.maxContextTokens ? { maxContextTokens: p.maxContextTokens } : {}), + ...(p.enabled === false ? { enabled: false } : {}), + } + } + disk.providers = rebuilt + } else if (cfg.default !== undefined) { + const rebuilt: Record<string, ProviderConfigDisk | string> = {} + if (cfg.default) rebuilt.default = cfg.default + for (const [name, val] of Object.entries(disk.providers)) { + if (name === "default") continue + rebuilt[name] = val + } + disk.providers = rebuilt + } + + await mkdir(personalLoopatDir(user), { recursive: true }) + await writeFile(personalLoopatConfigPath(user), JSON.stringify(disk, null, 2) + "\n") + clearPersonalCache(user) +} + +/** Derive a default vault env var name from a provider name. + * "Anthropic" → "ANTHROPIC_API_KEY"; "DeepSeek" → "DEEPSEEK_API_KEY". */ +export function providerEnvVarName(providerName: string): string { + const sanitized = providerName.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase() + return `${sanitized || "PROVIDER"}_API_KEY` +} + +/** If `template` is exactly `${X}` (one ref, nothing else), return X. Else null. */ +function extractSingleVarName(template: string): string | null { + const m = template.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/) + return m ? m[1] : null +} + +/** Save workspace config to disk. Only provided fields are overwritten. + * Preserves existing apiKeys unless explicitly replaced. */ +export async function saveWorkspaceConfig(cfg: Partial<WorkspaceConfig>): Promise<void> { + const existing = await loadConfig() + const merged: WorkspaceConfig = { ...existing } + if (cfg.providers !== undefined) { + merged.providers = merged.providers ?? {} + for (const [name, p] of Object.entries(cfg.providers)) { + const existingProv = merged.providers[name] + const incoming = p as any + // Normalize to canonical models[] format. + const models: ModelEntry[] = incoming.models?.length > 0 + ? incoming.models.map((m: any) => ({ id: m.id, ...(m.enabled === false ? { enabled: false } : {}) })) + : existingProv?.models ?? (incoming.model ? [{ id: incoming.model, enabled: true }] : []) + merged.providers[name] = { + models, + baseUrl: incoming.baseUrl ?? existingProv?.baseUrl ?? "", + ...(incoming.maxContextTokens ? { maxContextTokens: incoming.maxContextTokens } : {}), + apiKey: incoming.apiKey || existingProv?.apiKey || "", + enabled: incoming.enabled !== undefined ? incoming.enabled : (existingProv?.enabled ?? true), + } as any + } + } + if (cfg.default !== undefined) merged.default = cfg.default + if (cfg.knowledge !== undefined) merged.knowledge = cfg.knowledge + if (cfg.serveDomain !== undefined) merged.serveDomain = cfg.serveDomain + if (cfg.serveWithPort !== undefined) merged.serveWithPort = cfg.serveWithPort + if (cfg.serveHttps !== undefined) merged.serveHttps = cfg.serveHttps + if (cfg.serveDisplayPort !== undefined) merged.serveDisplayPort = cfg.serveDisplayPort + if (cfg.serveEnabled !== undefined) merged.serveEnabled = cfg.serveEnabled + if (cfg.serveDynamicEnabled !== undefined) merged.serveDynamicEnabled = cfg.serveDynamicEnabled + if (cfg.serveDynamicDomain !== undefined) merged.serveDynamicDomain = cfg.serveDynamicDomain + if (cfg.serveDynamicPortRange !== undefined) merged.serveDynamicPortRange = cfg.serveDynamicPortRange + if (cfg.serveDynamicUdpEnabled !== undefined) merged.serveDynamicUdpEnabled = cfg.serveDynamicUdpEnabled + if (cfg.serveDynamicStaticEnabled !== undefined) merged.serveDynamicStaticEnabled = cfg.serveDynamicStaticEnabled + if (cfg.serveEphemeralEnabled !== undefined) merged.serveEphemeralEnabled = cfg.serveEphemeralEnabled + if (cfg.serveEphemeralDomain !== undefined) merged.serveEphemeralDomain = cfg.serveEphemeralDomain + if (cfg.presets !== undefined) merged.presets = cfg.presets + await writeFile(configPath(), JSON.stringify(merged, null, 2) + "\n") + cachedWorkspace = null +} diff --git a/server/src/files.ts b/server/src/files.ts new file mode 100644 index 00000000..2012df74 --- /dev/null +++ b/server/src/files.ts @@ -0,0 +1,173 @@ +import { readdir, readFile, writeFile, stat, mkdir, rm, unlink } from "node:fs/promises" +import { join, normalize, relative, sep, dirname } from "node:path" +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<FileEntry[]> { + 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<boolean> { + 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 + } +} + +export async function deleteWorkdirFile(loopId: string, relPath: string): Promise<boolean> { + const root = loopDir(loopId) + const abs = safeJoin(root, relPath) + if (!abs) return false + try { + const s = await stat(abs) + if (s.isDirectory()) { + await rm(abs, { recursive: true, force: true }) + } else { + await unlink(abs) + } + return true + } catch { + return false + } +} + +export async function createWorkdirFolder(loopId: string, relPath: string): Promise<boolean> { + const root = loopDir(loopId) + const abs = safeJoin(root, relPath) + if (!abs) return false + try { + await mkdir(abs, { recursive: true }) + return true + } catch { + return false + } +} + +const MAX_RECURSIVE_ENTRIES = 5000 +const MAX_RECURSIVE_DEPTH = 20 + +/** + * Recursively list all files and directories under a root path within a loop. + * Returns a flat array sorted dirs-first then alpha. One HTTP call replaces + * the frontend's recursive fetchAllFiles waterfall. + */ +export async function listDirRecursive( + loopId: string, + relPath: string, +): Promise<FileEntry[]> { + const root = loopDir(loopId) + const abs = safeJoin(root, relPath) + if (!abs) return [] + + const result: FileEntry[] = [] + const skip = new Set(SKIP_DIRS) + skip.add(".git").add(".DS_Store") + + async function walk(absPath: string, prefix: string, depth: number) { + if (result.length >= MAX_RECURSIVE_ENTRIES) return + if (depth > MAX_RECURSIVE_DEPTH) return + + let names: string[] + try { + names = await readdir(absPath) + } catch { + return + } + + const entries: Array<{ name: string; isDir: boolean }> = [] + for (const name of names) { + if (skip.has(name)) continue + try { + const s = await stat(join(absPath, name)) + entries.push({ name, isDir: s.isDirectory() }) + } catch { + continue + } + } + entries.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1 + return a.name.localeCompare(b.name) + }) + + for (const { name, isDir } of entries) { + if (result.length >= MAX_RECURSIVE_ENTRIES) break + const childRel = prefix ? `${prefix}/${name}` : name + result.push({ name, path: childRel, type: isDir ? "dir" : "file" }) + if (isDir) { + await walk(join(absPath, name), childRel, depth + 1) + } + } + } + + await walk(abs, relPath, 0) + return result +} diff --git a/server/src/git-crypt-key.ts b/server/src/git-crypt-key.ts new file mode 100644 index 00000000..947e2270 --- /dev/null +++ b/server/src/git-crypt-key.ts @@ -0,0 +1,36 @@ +/** + * Per-user git-crypt symmetric key. Decrypts the user's personal repo + * worktree (specifically `.loopat/vaults/**`). + * + * Storage: host-secrets/<user>/git-crypt.key — host-only, NOT bound into the + * sandbox, NOT in any git repo. Mode 0600. + * + * Phase A (now): plain file on disk. Anyone with host access can read it and + * decrypt the repo. Trade-off documented — see design discussion notes. + * + * Phase B (future, optional upgrade): replace this module's read path with an + * in-memory map populated at server start via passphrase prompt. Callers stay + * the same — `getGitCryptKey(userId)` interface is the migration boundary. + */ +import { existsSync } from "node:fs" +import { mkdir, readFile, writeFile, chmod } from "node:fs/promises" +import { dirname } from "node:path" +import { hostSecretsDir, personalGitCryptKeyPath } from "./paths" + +export async function getGitCryptKey(userId: string): Promise<Buffer> { + return await readFile(personalGitCryptKeyPath(userId)) +} + +export async function gitCryptKeyExists(userId: string): Promise<boolean> { + return existsSync(personalGitCryptKeyPath(userId)) +} + +export async function saveGitCryptKey(userId: string, keyData: Buffer): Promise<void> { + const dir = hostSecretsDir(userId) + await mkdir(dir, { recursive: true }) + await chmod(dir, 0o700).catch(() => {}) + const path = personalGitCryptKeyPath(userId) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, keyData, { mode: 0o600 }) + await chmod(path, 0o600).catch(() => {}) +} diff --git a/server/src/git-host.ts b/server/src/git-host.ts new file mode 100644 index 00000000..592a5d41 --- /dev/null +++ b/server/src/git-host.ts @@ -0,0 +1,199 @@ +/** + * Git host provider abstraction — docs/identity.md's five-capability contract + * as a pluggable interface. A GitHostProvider adapts ONE git platform (GitHub, + * GitLab, an internal host, …) onto the five operations loopat needs to onboard + * a user. loopat core stays platform-agnostic. + * + * To add a platform: implement this interface and `registerProvider()` it — + * see server/src/providers.ts (the explicit registry). Nothing in core changes. + */ + +export type HostCred = { token: string; baseUrl?: string } +export type RepoRef = { owner: string; name: string } + +/** + * Onboarding is fully implemented by the provider (see GitHostProvider.onboarding). + * The provider is a state machine: given the current context it either reports + * `done`, or returns the next FORM to render. loopat knows nothing about the + * flow — it only renders the generic form and, on submit, runs each field's + * `action` (the only two primitives it provides). + */ +export type OnboardingField = { + /** Field key. For action "vault-env" this is ALSO the vault-env name written. */ + name: string + label: string + type?: "password" | "text" + help?: string + placeholder?: string + /** + * - "vault-env": store the submitted value in the vault under `name`. + * - "personal-repo-token": use the submitted value as the git token to + * provision + import the user's personal repo. + */ + action: "vault-env" | "personal-repo-token" +} + +export type OnboardingForm = { + title: string + description?: string + submitLabel?: string + /** "all" (default) = every field required; "any" = at least one. */ + require?: "all" | "any" + fields: OnboardingField[] +} + +/** + * What the provider's onboarding() returns: either done, or the next thing to + * show. The provider runs its OWN checks (file exists? api/git ok?) and, on + * failure, returns a remediation for loopat to display: + * - "form": a generic form loopat renders + runs (writes vault env / sets up repo). + * - "route": an existing loopat page to send the user to (e.g. the personal-repo + * settings page, or an MCP auth page). loopat shows the real page and + * re-asks onboarding() once the user is done — the provider's next + * check decides whether to advance. + * - "info": instructions the user must act on OUTSIDE loopat (e.g. register an + * ssh key, request repo access). loopat shows the text + any + * copyable values + help links + a "re-check" button that re-runs + * onboarding() — the provider's next check decides whether to advance. + */ +export type OnboardingView = + | { done: true } + | { + done: false + show: + | ({ kind: "form" } & OnboardingForm) + | { kind: "route"; path: string; title?: string; description?: string } + | { + kind: "info" + title: string + description?: string + /** Copyable key/values to show (e.g. the user's ssh public key). */ + values?: { label: string; value: string }[] + /** External help links (e.g. where to register the key). */ + help?: { label: string; url: string }[] + } + } + +export interface GitHostProvider { + readonly id: string + readonly label: string + + /** Optional: where/how the user gets a token, shown in the onboarding token + * step. A URL or short hint. Platform-specific, so the provider supplies it + * (core stays platform-agnostic). */ + readonly tokenHelp?: string + + /** Optional defaults the provider declares so loopat needs no config.json: + * the git host base URL and the default personal-repo name. A request may + * still override either; absent both, baseUrl falls back to the provider's + * own internal default and defaultRepo to "loopat-personal". */ + readonly baseUrl?: string + readonly defaultRepo?: string + + /** + * Optional onboarding, FULLY implemented by the provider. When present, loopat + * treats onboarding as MANDATORY: until it reports `done`, loop creation is + * blocked and the UI shows the provider's current form. + * + * loopat owns NONE of the flow — it calls onboarding(ctx), renders the + * returned form, collects the values, runs each field's action, then calls + * onboarding() again with fresh context, repeating until `done`. The provider + * decides everything: how many forms, their order, what to ask, when it's + * complete. A provider without onboarding() imposes no gate. + */ + onboarding?(ctx: { + userId: string + login?: string + vaultEnvs: Record<string, string> + config: Record<string, unknown> + /** Has the user's personal repo been imported yet? */ + personalRepoImported: boolean + /** Path to the user's personal repo working tree (null until imported), so + * the provider can run its own "does this file/dir exist?" checks against + * e.g. `${repoDir}/.loopat/...`. The provider may import node:fs itself. */ + repoDir: string | null + }): Promise<OnboardingView> + + + /** + * How git authenticates clone/push on this platform: + * - "ssh-deploy-key": loopat generates an ssh key, the provider registers it + * (registerDeployKey), and git uses ssh. (GitHub.) + * - "https-token": git uses `https://oauth2:<token>@…`; no key is registered. + * (GitLab / internal — one token does API + git.) + */ + readonly gitAuthMode: "ssh-deploy-key" | "https-token" + + /** ① authenticate — turn a credential into the user's login (+ email for + * commit authorship, where the platform enforces a valid address). */ + authenticate(cred: HostCred): Promise<{ login: string; email?: string }> + + /** ② create a private repo in the user's namespace if missing. */ + ensureRepo( + cred: HostCred, + name: string, + opts?: { private?: boolean }, + ): Promise<{ url: string; created: boolean }> + + /** ③ register a deploy key on a repo (only for "ssh-deploy-key" mode). */ + registerDeployKey?( + cred: HostCred, + repo: RepoRef, + title: string, + pubkey: string, + readOnly: boolean, + ): Promise<void> + + /** ④ register an account-level key (only for "ssh-deploy-key" mode). */ + registerUserKey?(cred: HostCred, title: string, pubkey: string): Promise<void> + + /** ⑤ grant a member access to a repo (usually admin-gated). */ + grantAccess( + cred: HostCred, + repo: RepoRef, + login: string, + level: "read" | "write", + ): Promise<void> + + /** List the user's repos (names) for an onboarding picker. Optional. */ + listRepos?(cred: HostCred): Promise<{ name: string; path: string }[]> + + /** + * Optional internal-setup hook. Runs once during personal-repo init, right + * after `git-crypt init` (so `.gitattributes` already encrypts + * `.loopat/vaults/**`) and before the scaffold is committed + pushed. Use it + * to seed default files into the working tree — provider configs, ssh keys, + * jumpbox configs, … This is where a team bakes its internal defaults. + * + * Encryption boundary: files under `.loopat/vaults/**` are git-crypt + * encrypted; everything else (e.g. `.loopat/config.json`) is committed in + * PLAINTEXT — so never write real secrets outside the vault (use env-var + * refs like `"$FOO_API_KEY"` in config.json instead). + * + * loopat stages (`git add .loopat memory`), commits and pushes whatever you + * write. Throwing only logs a warning — setup still succeeds. + * + * ctx.repoDir — cloned working tree (write paths relative to this) + * ctx.vaultDir — `${repoDir}/.loopat/vaults/default` (encrypted) + * ctx.userId — the loopat user being set up + * ctx.login — their login on this platform + */ + seedDefaults?(ctx: { + repoDir: string + vaultDir: string + userId: string + login: string + }): Promise<void> +} + +const providers = new Map<string, GitHostProvider>() + +export function registerProvider(p: GitHostProvider): void { + providers.set(p.id, p) +} +export function getProvider(id: string): GitHostProvider | undefined { + return providers.get(id) +} +export function listProviders(): { id: string; label: string }[] { + return [...providers.values()].map((p) => ({ id: p.id, label: p.label })) +} diff --git a/server/src/github.ts b/server/src/github.ts new file mode 100644 index 00000000..f8a455d3 --- /dev/null +++ b/server/src/github.ts @@ -0,0 +1,161 @@ +/** + * GitHub integration — the five-capability contract from docs/identity.md, + * implemented against the GitHub REST API. It only ever consumes a *token*; + * how that token was obtained (a user-pasted PAT today, an OAuth grant later) + * is not its concern. Swapping the token source never touches this client. + * + * `baseUrl` is configurable so the same client works against github.com + * (https://api.github.com) or a GitHub Enterprise / internal host + * (https://<host>/api/v3). + */ +import { registerProvider, type GitHostProvider } from "./git-host" + +export type GithubClient = { + baseUrl: string + token: string +} + +export function githubClient(token: string, baseUrl = "https://api.github.com"): GithubClient { + return { token, baseUrl: baseUrl.replace(/\/+$/, "") } +} + +async function gh<T = any>( + c: GithubClient, + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; data: T }> { + const res = await fetch(`${c.baseUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${c.token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }) + let data: any = null + const text = await res.text() + if (text) { + try { data = JSON.parse(text) } catch { data = text } + } + return { status: res.status, data } +} + +function fail(op: string, r: { status: number; data: any }): never { + const msg = r.data?.message ?? (typeof r.data === "string" ? r.data : "") + throw new Error(`github ${op} failed (${r.status})${msg ? `: ${msg}` : ""}`) +} + +/** Capability 1 — authenticate: turn a token into the user's login. */ +export async function getViewer(c: GithubClient): Promise<{ login: string; id: number }> { + const r = await gh(c, "GET", "/user") + if (r.status !== 200) fail("authenticate", r) + return { login: r.data.login, id: r.data.id } +} + +/** + * Capability 2 — create a private repo in the viewer's namespace if missing. + * Returns the clone URLs; idempotent (existing repo → returned as-is). + */ +export async function ensureUserRepo( + c: GithubClient, + name: string, + opts: { private?: boolean; description?: string } = {}, +): Promise<{ created: boolean; sshUrl: string; httpUrl: string; fullName: string }> { + const me = await getViewer(c) + const existing = await gh(c, "GET", `/repos/${me.login}/${name}`) + if (existing.status === 200) { + return { + created: false, + sshUrl: existing.data.ssh_url, + httpUrl: existing.data.clone_url, + fullName: existing.data.full_name, + } + } + if (existing.status !== 404) fail("get repo", existing) + const r = await gh(c, "POST", "/user/repos", { + name, + private: opts.private ?? true, + description: opts.description ?? "loopat", + auto_init: false, + }) + if (r.status !== 201) fail("create repo", r) + return { created: true, sshUrl: r.data.ssh_url, httpUrl: r.data.clone_url, fullName: r.data.full_name } +} + +/** Capability 3 — register a deploy key on a repo (bootstrap clone of personal). */ +export async function ensureDeployKey( + c: GithubClient, + owner: string, + repo: string, + title: string, + publicKey: string, + readOnly = true, +): Promise<void> { + const list = await gh(c, "GET", `/repos/${owner}/${repo}/keys`) + if (list.status === 200 && Array.isArray(list.data) && list.data.some((k: any) => k.key?.trim() === publicKey.trim())) { + return + } + const r = await gh(c, "POST", `/repos/${owner}/${repo}/keys`, { title, key: publicKey, read_only: readOnly }) + if (r.status !== 201 && r.status !== 422 /* already exists */) fail("add deploy key", r) +} + +/** Capability 4 — register an account-level key (the runtime key in the vault). */ +export async function ensureUserKey(c: GithubClient, title: string, publicKey: string): Promise<void> { + const list = await gh(c, "GET", "/user/keys") + if (list.status === 200 && Array.isArray(list.data) && list.data.some((k: any) => k.key?.trim() === publicKey.trim())) { + return + } + const r = await gh(c, "POST", "/user/keys", { title, key: publicKey }) + if (r.status !== 201 && r.status !== 422) fail("add user key", r) +} + +/** + * Capability 5 — grant a member access to a repo. Admin-gated by GitHub: + * `c` must be a token with admin on `owner/repo` (e.g. an org-admin token at + * team-setup time), not the joining user's own token. + */ +export async function ensureCollaborator( + c: GithubClient, + owner: string, + repo: string, + username: string, + permission: "pull" | "push" | "admin" = "push", +): Promise<void> { + const r = await gh(c, "PUT", `/repos/${owner}/${repo}/collaborators/${username}`, { permission }) + // 201 = invitation created, 204 = already a collaborator + if (r.status !== 201 && r.status !== 204) fail("add collaborator", r) +} + +/** The built-in GitHub provider — adapts the functions above onto GitHostProvider. */ +export const githubProvider: GitHostProvider = { + id: "github", + label: "GitHub", + gitAuthMode: "ssh-deploy-key", + async authenticate(cred) { + return await getViewer(githubClient(cred.token, cred.baseUrl)) + }, + async ensureRepo(cred, name, opts) { + const r = await ensureUserRepo(githubClient(cred.token, cred.baseUrl), name, { private: opts?.private }) + return { url: r.sshUrl, created: r.created } + }, + async registerDeployKey(cred, repo, title, pubkey, readOnly) { + await ensureDeployKey(githubClient(cred.token, cred.baseUrl), repo.owner, repo.name, title, pubkey, readOnly) + }, + async registerUserKey(cred, title, pubkey) { + await ensureUserKey(githubClient(cred.token, cred.baseUrl), title, pubkey) + }, + async grantAccess(cred, repo, login, level) { + await ensureCollaborator( + githubClient(cred.token, cred.baseUrl), + repo.owner, + repo.name, + login, + level === "write" ? "push" : "pull", + ) + }, +} + +registerProvider(githubProvider) diff --git a/server/src/host-exec.ts b/server/src/host-exec.ts new file mode 100644 index 00000000..a8d2e668 --- /dev/null +++ b/server/src/host-exec.ts @@ -0,0 +1,129 @@ +/** + * host-cli proxy (POC). Some CLIs can only run on the host — macOS-only tools, + * or company tools bound to a specific machine. The sandbox can't run them, so + * we run them on the host *on behalf of* a loop: + * + * sandbox: `aone foo` → shim → loopat-host (forwarder) → + * server : POST /api/host-exec → execFile("aone", ["foo"]) on the host + * + * Trust model (deliberately simple): mounting the socket into a sandbox IS the + * trust decision — a host that turns on host-cli for a loop already trusts that + * loop, so there is NO whitelist. The loop may run any host cli (it can call the + * forwarder directly with a hand-built command). + * + * The mise-generated shims aren't a whitelist either — they're the declarative + * ENTRY POINT. "Which clis did the loop declare in `[host].clis`" shows up as + * "which shim binaries exist on PATH"; that's a UX convention (what the AI can + * conveniently reach), not a security boundary. The only boundary is whether + * the socket is mounted at all. + * + * - runs with HOST user permissions + * - cwd is a per-loop host workdir (mirrors the loop's workdir); the cli + * cannot see inside the sandbox + * - execFile, never a shell — argv is an array + */ +import { execFile } from "node:child_process" +import { mkdir, writeFile, chmod } from "node:fs/promises" +import { existsSync, rmSync, mkdirSync } from "node:fs" +import { join } from "node:path" +import { loopDir, LOOPAT_HOME } from "./paths" + +/** A per-loop host workdir — the host-side mirror of the loop's own workdir. */ +export function hostWorkdir(loopId: string): string { + return join(loopDir(loopId), "host-workdir") +} + +/** The dir that holds the host-exec unix socket. We mount the DIR (not the + * socket file) into sandboxes so a server restart — which recreates the + * socket inode — stays visible to already-running containers. */ +export function hostExecDir(): string { + return join(LOOPAT_HOME, "host-exec") +} +export function hostExecSocketPath(): string { + return join(hostExecDir(), "host-exec.sock") +} + +export type HostExecResult = + | { ok: true; exitCode: number; stdout: string; stderr: string } + | { ok: false; error: string } + +/** Run a host-cli in the loop's host workdir, with host permissions. No + * whitelist — mounting the socket into the sandbox is the trust decision. */ +export async function runHostCli(opts: { + cli: string + args: string[] + cwd: string + stdin?: string + timeoutMs?: number +}): Promise<HostExecResult> { + await mkdir(opts.cwd, { recursive: true }) + return new Promise((resolve) => { + const child = execFile( + opts.cli, + opts.args, + { cwd: opts.cwd, timeout: opts.timeoutMs ?? 120_000, maxBuffer: 16 * 1024 * 1024 }, + (err: any, stdout, stderr) => { + if (err && err.code === "ENOENT") { + resolve({ ok: false, error: `host has no '${opts.cli}'` }) + return + } + const exitCode = typeof err?.code === "number" ? err.code : err ? 1 : 0 + resolve({ ok: true, exitCode, stdout: String(stdout), stderr: String(stderr) }) + }, + ) + if (opts.stdin !== undefined && child.stdin) { + child.stdin.write(opts.stdin) + child.stdin.end() + } + }) +} + +/** + * Write a shim per declared host-cli into `binDir` (which the sandbox puts on + * PATH ahead of everything). Each shim just hands off to the forwarder. + */ +export async function writeHostShims(binDir: string, clis: string[]): Promise<void> { + await mkdir(binDir, { recursive: true }) + // The `loopat-host` forwarder is baked into the sandbox image; here we only + // emit the per-cli shims — each just hands off to it. + for (const cli of clis) { + const p = join(binDir, cli) + await writeFile(p, `#!/bin/sh\n# loopat host-cli shim — forwards "${cli}" to the host\nexec loopat-host "${cli}" "$@"\n`) + await chmod(p, 0o755) + } +} + +/** + * Unix-socket server that runs host-clis for loops. The sandbox's + * forwarder connects over the *mounted* socket — no TCP, no exposed port, and + * only a container that has the socket mounted can reach it (the mount itself + * is a layer of isolation). Reuses runHostCli. stdout/stderr come back base64 + * so the sh forwarder can pull them out of the JSON without escaping pain. + */ +export function serveHostExec(socketPath: string, deps: { loopExists: (id: string) => Promise<boolean> }) { + try { mkdirSync(hostExecDir(), { recursive: true }) } catch {} + try { if (existsSync(socketPath)) rmSync(socketPath) } catch {} + return Bun.serve({ + unix: socketPath, + async fetch(req) { + const url = new URL(req.url) + if (url.pathname !== "/host-exec" || req.method !== "POST") return new Response("not found", { status: 404 }) + const b: any = await req.json().catch(() => ({})) + const loopId = typeof b.loopId === "string" ? b.loopId : "" + const cli = typeof b.cli === "string" ? b.cli : "" + const args = Array.isArray(b.args) ? b.args.map(String) : [] + if (!loopId || !cli) return Response.json({ error: "loopId + cli required" }) + if (!(await deps.loopExists(loopId))) return Response.json({ error: "unknown loop" }) + const r = await runHostCli({ + cli, args, cwd: hostWorkdir(loopId), + stdin: typeof b.stdin === "string" ? b.stdin : undefined, + }) + if (!r.ok) return Response.json({ error: r.error }) + return Response.json({ + exitCode: r.exitCode, + stdout_b64: Buffer.from(r.stdout).toString("base64"), + stderr_b64: Buffer.from(r.stderr).toString("base64"), + }) + }, + }) +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 00000000..46377cfb --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,3431 @@ +import { Hono } from "hono" +import { cors } from "hono/cors" +import { createBunWebSocket } from "hono/bun" +import { existsSync } from "node:fs" +import { execFile, execFileSync } from "node:child_process" +import { promisify } from "node:util" +import { listLoops, createLoop, getLoop, loopExists, patchLoopMeta, backfillAllMounts, ensureWorkspaceDirs, provisionUserPersonal, importPersonalFromRepo, setupPersonalViaProvider, listPersonalReposViaProvider, authenticateViaProvider, isPersonalFresh, ensureUiNotesWorktree, syncUiNotes, ffUpdateUiNotes, discardUiNotes, notesBehind, inspectPersonalDirty, syncPersonalToRemote, deletePersonalVault, pullPersonalFromRemote, pushPersonalToRemote, ensureContextMounts, effectiveDriver, isDriver, distillLoop, inspectRepoSync, pullRepoFromRemote, pushRepoToRemote, listVaultPublicKeys, userOnboarding, submitOnboarding } from "./loops" +import { getEphemeralHostPort, probePodman, stopAllWorkspaceContainers, ensureServeContainer, ensurePortProxyContainer, ensureSandboxImage } from "./podman" +import { startMcpAuth, completeMcpAuth, probeOAuthSupport, evictOAuthProbe, parseBearerEnvName, mcpRequiredEnvs, parseTemplateVars, type OAuthSupport } from "./mcp-oauth" +import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults" +import { + initChat, + listChannels, + createChannel, + deleteChannel, + getOrCreateDm, + getConv, + userCanAccess, + listConversationsForUser, + listMessages, + listThread, + postMessage, + markRead, + snapshotThreadToJsonl, +} from "./chat" +import { loopContextChatDir } from "./paths" +import { join as pathJoin, dirname } from "node:path" +import { ensurePersonalKeypair, getPublicKey } from "./personal-keys" +// `destroySession` here clashes with auth's session-token destroyer; alias to +// keep both callable without import-order-dependent shadowing. +import { getSession, destroySession as destroyLoopSession, restartSession, getActivitySnapshot } from "./session" +import { listDir, listDirRecursive, readWorkdirFile, writeWorkdirFile, deleteWorkdirFile, createWorkdirFolder } from "./files" +import { vaultList, vaultFlatList, vaultRead, vaultWrite, vaultCreateFile, vaultCreateFolder, vaultDelete, vaultBacklinks, listTopics, type VaultId } from "./workspace" +// sandboxes module removed — no /api/sandboxes/* routes in the profile model. +// Use /api/profiles + /api/personal/default-profiles instead. +import { attachTerm, detachTerm, writeTerm, resizeTerm, killTerm } from "./term" +import { + LOOPAT_HOME, + LOOPAT_INSTALL_DIR, + WORKSPACE, + loopContextKnowledge, + loopContextNotes, + loopContextPersonal, + loopContextRepos, + loopWorkdir, + loopHistoryPath, + loopChatHistoryPath, + workspaceKnowledgeDir, + workspaceNotesDir, + workspaceRepoDir, + workspaceReposDir, + personalKnowledgeDir, + personalNotesDir, + personalRepoDir, + personalReposDir, + loopsDir, +} from "./paths" +import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadA2AConfig, saveA2AConfig, type ProviderConfig, type ModelEntry } from "./config" +import { createApiToken, listApiTokens, revokeApiToken } from "./api-tokens" +import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard, kanbanUserCtx } from "./kanban" +import { printBootstrapBanner, printReadyLine } from "./bootstrap" +import { resolveProvider } from "./providers" +import { ensureSandboxClaudeBinary } from "./claude-binary" +import { serveHostExec, hostExecSocketPath } from "./host-exec" +import { + createUser, + findUser, + setPersonalRepo, + verifyPassword, + createSession, + destroySession, + setSessionCookie, + clearSessionCookie, + getRequestUserId, + requireAuth, + requireAdmin, + COOKIE_NAME, + isValidUsername, + listUsers, + activateUser, + setUserRole, + deleteUser, +} from "./auth" +import { getCookie } from "hono/cookie" + +const execFileP = promisify(execFile) + +const { upgradeWebSocket, websocket } = createBunWebSocket() + +// ── Kanban real-time hub ── + +type KanbanSubscriber = { ws: any; userId: string } +const kanbanSubscribers = new Set<KanbanSubscriber>() + +function kanbanBroadcast(msg: object) { + const payload = JSON.stringify(msg) + for (const sub of kanbanSubscribers) { + try { sub.ws.send(payload) } catch {} + } +} + +function kanbanNotify() { + kanbanBroadcast({ type: "kanban_update" }) +} + +type Variables = { userId: string } +export const app = new Hono<{ Variables: Variables }>() + +app.use("/api/*", cors({ origin: (o) => o ?? "*", credentials: true })) + +// public routes +app.get("/api/health", (c) => c.json({ ok: true, loopatHome: LOOPAT_HOME, workspace: WORKSPACE })) + +app.get("/api/version", (c) => { + let branch = "unknown", commit = "unknown" + // stdio stderr=ignore: under npx the install dir isn't a git repo, so git + // prints "fatal: not a git repository" to stderr. Without this it inherits + // the parent stderr and leaks that line to the console on every poll of this + // endpoint — suppress it (the catch already handles the non-git case). + const gitStdio: ["ignore", "pipe", "ignore"] = ["ignore", "pipe", "ignore"] + try { branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: gitStdio }).trim() } catch {} + try { commit = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", stdio: gitStdio }).trim() } catch {} + return c.json({ branch, commit }) +}) + +// Loop API v1 — see docs/api-v1.md. Public surface for bot frameworks + the +// web app's chat experience. All other web features stay on internal WS/REST. +import { buildApiV1 } from "./api-v1" +app.route("/api/v1", buildApiV1()) + +// A2A (Agent-to-Agent) adapter — per-user Agent Card + JSON-RPC under /a2a/<user>. +// Mounted before the SPA catch-all so those routes resolve. +import { buildA2A } from "./a2a" +app.route("/", buildA2A()) + +// ── workspace serve config ── + +function getLocalIp(): string { + const nets = networkInterfaces() + for (const name of Object.keys(nets)) { + for (const net of nets[name] ?? []) { + if (!net.internal && net.family === "IPv4") return net.address + } + } + return "127.0.0.1" +} + +app.get("/api/serve/config", requireAdmin, async (c) => { + const cfg = await loadConfig() + const domain = cfg.serveDomain ?? "nip.io" + const ip = getLocalIp() + const isNip = domain === "nip.io" + return c.json({ + // Standard serve + serveEnabled: cfg.serveEnabled ?? true, + domain, + ip, + baseUrl: isNip ? `.${ip}.${domain}` : `.${domain}`, + withPort: cfg.serveWithPort ?? false, + https: cfg.serveHttps ?? false, + displayPort: cfg.serveDisplayPort ?? 7788, + // Dynamic port + serveDynamicEnabled: cfg.serveDynamicEnabled ?? false, + serveDynamicDomain: cfg.serveDynamicDomain ?? "", + serveDynamicPortRange: cfg.serveDynamicPortRange ?? "10000-20000", + serveDynamicUdpEnabled: cfg.serveDynamicUdpEnabled ?? false, + serveDynamicStaticEnabled: cfg.serveDynamicStaticEnabled ?? false, + // Ephemeral port: kernel-assigned host port per loop, changes on + // every loop restart. No port-proxy involved. + serveEphemeralEnabled: cfg.serveEphemeralEnabled ?? false, + serveEphemeralDomain: cfg.serveEphemeralDomain ?? "", + }) +}) + +app.put("/api/serve/config", requireAdmin, async (c) => { + const body = await c.req.json().catch(() => ({})) + const patch: Record<string, unknown> = {} + if (typeof body.domain === "string" && body.domain.trim()) patch.serveDomain = body.domain.trim() + if (typeof body.withPort === "boolean") patch.serveWithPort = body.withPort + if (typeof body.https === "boolean") patch.serveHttps = body.https + if (typeof body.displayPort === "number") patch.serveDisplayPort = body.displayPort + if (typeof body.serveEnabled === "boolean") patch.serveEnabled = body.serveEnabled + if (typeof body.serveDynamicEnabled === "boolean") patch.serveDynamicEnabled = body.serveDynamicEnabled + if (typeof body.serveDynamicDomain === "string") patch.serveDynamicDomain = body.serveDynamicDomain.trim() + if (typeof body.serveDynamicPortRange === "string") patch.serveDynamicPortRange = body.serveDynamicPortRange.trim() + if (typeof body.serveDynamicUdpEnabled === "boolean") patch.serveDynamicUdpEnabled = body.serveDynamicUdpEnabled + if (typeof body.serveDynamicStaticEnabled === "boolean") patch.serveDynamicStaticEnabled = body.serveDynamicStaticEnabled + if (typeof body.serveEphemeralEnabled === "boolean") patch.serveEphemeralEnabled = body.serveEphemeralEnabled + if (typeof body.serveEphemeralDomain === "string") patch.serveEphemeralDomain = body.serveEphemeralDomain.trim() + if (Object.keys(patch).length === 0) return c.json({ error: "no fields to update" }, 400) + await saveWorkspaceConfig(patch) + return c.json({ ok: true }) +}) + +app.get("/api/serve/alias-check", requireAuth, async (c) => { + const alias = (c.req.query("alias") ?? "").trim().toLowerCase() + const loopId = (c.req.query("loopId") ?? "").trim() + if (!alias) return c.json({ available: false, reason: "alias required" }) + if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(alias)) { + return c.json({ available: false, reason: "Only lowercase letters, numbers, and hyphens allowed" }) + } + const allLoops = await listLoops() + for (const loop of allLoops) { + if (loop.id === loopId) continue + if (loop.id.slice(0, 8) === alias || loop.shareAlias === alias) { + return c.json({ available: false, reason: "Already in use" }) + } + } + return c.json({ available: true }) +}) + +// Return a random unused port from the dynamic port range, or null if none available. +app.get("/api/serve/available-port", requireAuth, async (c) => { + const cfg = await loadConfig() + const range = cfg.serveDynamicPortRange || "10000-20000" + const [lo, hi] = range.split("-").map(Number) + if (!lo || !hi || lo >= hi) return c.json({ port: null, error: "invalid port range" }) + + // Port-proxy maps the entire range — binding test would always fail. + // Just pick a port not already claimed by another ENABLED loop. + const used = new Set<number>() + try { + const all = await listLoops() + for (const loop of all) { + if (loop.shareEnabled && loop.shareExternalPort) used.add(loop.shareExternalPort) + } + } catch {} + + for (let i = 0; i < 100; i++) { + const port = lo + Math.floor(Math.random() * (hi - lo + 1)) + if (!used.has(port)) return c.json({ port }) + } + return c.json({ port: null, error: "no available port in range" }) +}) + +// Check if a specific port is available for use by a loop. +app.get("/api/serve/check-port", requireAuth, async (c) => { + const port = parseInt(c.req.query("port") ?? "") + const loopId = (c.req.query("loopId") ?? "").trim() + if (!port || port < 1 || port > 65535) return c.json({ available: false, reason: "Invalid port" }) + + const cfg = await loadConfig() + const range = cfg.serveDynamicPortRange || "10000-20000" + const [lo, hi] = range.split("-").map(Number) + if (port < lo || port > hi) return c.json({ available: false, reason: `Port outside configured range (${range})` }) + + // Check if another ENABLED loop already claims this port + try { + const all = await listLoops() + for (const loop of all) { + if (loop.id === loopId) continue + if (loop.shareEnabled && loop.shareExternalPort === port) { + return c.json({ available: false, reason: `Port ${port} is already used by another loop` }) + } + } + } catch {} + + return c.json({ available: true }) +}) + +// ── providers (auth required) ── +// Merges personal + workspace configs. Personal providers take precedence +// (they carry per-user apiKeys via secrets/). Source field indicates origin. +app.get("/api/providers", requireAuth, async (c) => { + const wCfg = await loadConfig() + const providers: Record<string, { models: ModelEntry[]; baseUrl: string; source: "personal" | "workspace"; enabled: boolean; hasKey: boolean }> = {} + if (wCfg.providers) { + for (const [name, p] of Object.entries(wCfg.providers)) { + const hasKey = typeof p.apiKey === "string" && p.apiKey.length > 0 + providers[name] = { models: p.models, baseUrl: p.baseUrl, source: "workspace", enabled: hasKey ? p.enabled : false, hasKey } + } + } + // Overlay personal providers (they take precedence) + let active = wCfg.default ?? "" + const userId = c.get("userId") as string + try { + const pCfg = await loadPersonalConfig(userId) + for (const [name, p] of Object.entries(pCfg.providers)) { + const hasKey = typeof p.apiKey === "string" && p.apiKey.length > 0 + // Only overlay if the user actually configured this provider (has a key). + // Template/preset providers without a key should not shadow workspace config. + if (hasKey) { + providers[name] = { models: p.models, baseUrl: p.baseUrl, source: "personal", enabled: p.enabled !== false, hasKey } + } + } + active = pCfg.default || active + } catch {} + return c.json({ providers, default: active }) +}) + +// Test a provider + model connection by making a minimal Messages API call. +// Accepts either a plain apiKey, or a provider name + source to resolve the +// key server-side (so tests work for stored/encrypted keys without re-typing). +app.post("/api/providers/test", requireAuth, async (c) => { + const body = await c.req.json().catch(() => ({})) + const { baseUrl, apiKey: rawApiKey, model, provider, source } = body + if (typeof baseUrl !== "string" || !baseUrl) return c.json({ ok: false, error: "baseUrl required" }, 400) + if (typeof model !== "string" || !model) return c.json({ ok: false, error: "model required" }, 400) + + let apiKey = typeof rawApiKey === "string" ? rawApiKey.trim() : "" + // Resolve key server-side when a stored (encrypted) key is being tested + if (!apiKey && typeof provider === "string" && provider) { + if (source === "personal") { + const userId = c.get("userId") as string + try { + const pCfg = await loadPersonalConfig(userId) + apiKey = pCfg.providers[provider]?.apiKey ?? "" + } catch {} + } else if (source === "workspace") { + try { + const wCfg = await loadConfig() + apiKey = (wCfg.providers?.[provider] as any)?.apiKey ?? "" + } catch {} + } + } + if (!apiKey) return c.json({ ok: false, error: "no API key — enter one or store it first" }, 400) + + try { + const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model, + max_tokens: 1, + messages: [{ role: "user", content: "." }], + }), + }) + if (!response.ok) { + const text = await response.text().catch(() => "") + return c.json({ ok: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }) + } + return c.json({ ok: true }) + } catch (e: any) { + return c.json({ ok: false, error: e?.message ?? "connection failed" }) + } +}) + +// ── auth (public) ── +app.post("/api/auth/register", async (c) => { + const body = await c.req.json().catch(() => ({})) + const username = typeof body.username === "string" ? body.username.trim().toLowerCase() : "" + const password = typeof body.password === "string" ? body.password : "" + const personalRepo = typeof body.personalRepo === "string" && body.personalRepo.trim() + ? body.personalRepo.trim() + : undefined + if (!isValidUsername(username)) return c.json({ error: "invalid username" }, 400) + if (!password) return c.json({ error: "password required" }, 400) + try { + const user = await createUser({ id: username, password, personalRepo }) + // Scaffold personal/<user>/ (empty git init + memory stub) and generate a + // loopat-managed deploy keypair. NO clone here — server has no creds to + // pull a private repo. The UI shows publicKey + asks user to register it + // as a deploy key on `personalRepo`, then calls /api/personal/import. + const { publicKey } = await provisionUserPersonal(user.id) + // Only auto-login active accounts (the first-ever user). Pending accounts + // must wait for an admin to activate before they can log in. + if (user.status === "active") { + const token = createSession(user.id) + setSessionCookie(c, token) + } + return c.json({ + user: { id: user.id, role: user.role, status: user.status }, + publicKey, + personalRepo: user.personalRepo ?? null, + needsImport: user.status === "active" && !!user.personalRepo && !!publicKey, + }) + } catch (e: any) { + return c.json({ error: e?.message ?? "register failed" }, 400) + } +}) + +app.post("/api/auth/login", async (c) => { + const body = await c.req.json().catch(() => ({})) + const username = typeof body.username === "string" ? body.username.trim().toLowerCase() : "" + const password = typeof body.password === "string" ? body.password : "" + if (!username || !password) return c.json({ error: "username + password required" }, 400) + const user = await findUser(username) + if (!user) return c.json({ error: "invalid credentials" }, 401) + const ok = await verifyPassword(password, user.salt, user.hash) + if (!ok) return c.json({ error: "invalid credentials" }, 401) + if (user.status !== "active") { + return c.json({ error: "account pending activation by an admin", status: user.status }, 403) + } + const token = createSession(user.id) + setSessionCookie(c, token) + return c.json({ user: { id: user.id, role: user.role, status: user.status } }) +}) + +app.post("/api/auth/logout", async (c) => { + const token = getCookie(c, COOKIE_NAME) + if (token) destroySession(token) + clearSessionCookie(c) + return c.json({ ok: true }) +}) + +app.get("/api/auth/me", async (c) => { + const userId = getRequestUserId(c) + if (!userId) return c.json({ error: "unauthorized" }, 401) + const user = await findUser(userId) + if (!user) return c.json({ error: "unauthorized" }, 401) + return c.json({ user: { id: user.id, role: user.role, status: user.status } }) +}) + +// ── admin (requireAdmin) ── + +app.get("/api/admin/users", requireAdmin, async (c) => { + const users = await listUsers() + return c.json({ users }) +}) + +app.post("/api/admin/users/:id/activate", requireAdmin, async (c) => { + const id = c.req.param("id") ?? "" + const updated = await activateUser(id) + if (!updated) return c.json({ error: "not found" }, 404) + return c.json({ user: { id: updated.id, role: updated.role, status: updated.status } }) +}) + +app.post("/api/admin/users/:id/role", requireAdmin, async (c) => { + const id = c.req.param("id") ?? "" + const body = await c.req.json().catch(() => ({})) + const role = body.role + if (role !== "admin" && role !== "member") return c.json({ error: "role must be admin or member" }, 400) + try { + const updated = await setUserRole(id, role) + if (!updated) return c.json({ error: "not found" }, 404) + return c.json({ user: { id: updated.id, role: updated.role, status: updated.status } }) + } catch (e: any) { + return c.json({ error: e?.message ?? "role change failed" }, 400) + } +}) + +app.delete("/api/admin/users/:id", requireAdmin, async (c) => { + const id = c.req.param("id") ?? "" + const me = c.get("userId") as string + if (id === me) return c.json({ error: "cannot delete yourself" }, 400) + try { + const ok = await deleteUser(id) + if (!ok) return c.json({ error: "not found" }, 404) + return c.json({ ok: true }) + } catch (e: any) { + return c.json({ error: e?.message ?? "delete failed" }, 400) + } +}) + +// ── profile CRUD (admin) ── + +app.get("/api/admin/profiles", requireAdmin, async (c) => { + const { listProfilesRich } = await import("./tiers") + return c.json({ profiles: await listProfilesRich() }) +}) + +app.post("/api/admin/profiles", requireAdmin, async (c) => { + const body = await c.req.json().catch(() => ({})) + const name = typeof body.name === "string" ? body.name.trim() : "" + if (!name) return c.json({ error: "name required" }, 400) + const { createProfile } = await import("./tiers") + const r = await createProfile(name) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json({ ok: true }) +}) + +app.get("/api/admin/profiles/:name", requireAdmin, async (c) => { + const name = c.req.param("name") ?? "" + const { getProfile } = await import("./tiers") + const p = await getProfile(name) + if (!p) return c.json({ error: "not found" }, 404) + return c.json(p) +}) + +app.put("/api/admin/profiles/:name", requireAdmin, async (c) => { + const name = c.req.param("name") ?? "" + const body = await c.req.json().catch(() => ({})) + const { updateProfile } = await import("./tiers") + const r = await updateProfile(name, { settings: body.settings, claudeMd: body.claudeMd }) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json({ ok: true }) +}) + +app.delete("/api/admin/profiles/:name", requireAdmin, async (c) => { + const name = c.req.param("name") ?? "" + const { deleteProfile } = await import("./tiers") + const r = await deleteProfile(name) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json({ ok: true }) +}) + +// ── admin presets ── + +import { DEFAULT_PROVIDER_PRESETS, DEFAULT_MISE_TOOL_PRESETS } from "./presets" + +app.get("/api/admin/presets", requireAdmin, async (c) => { + const cfg = await loadConfig() + const presets = cfg.presets ?? { + providerPresets: DEFAULT_PROVIDER_PRESETS, + miseToolPresets: DEFAULT_MISE_TOOL_PRESETS, + } + return c.json(presets) +}) + +app.put("/api/admin/presets", requireAdmin, async (c) => { + const body = await c.req.json().catch(() => ({})) + if (body.providerPresets !== undefined && !Array.isArray(body.providerPresets)) { + return c.json({ error: "providerPresets must be an array" }, 400) + } + if (body.miseToolPresets !== undefined && !Array.isArray(body.miseToolPresets)) { + return c.json({ error: "miseToolPresets must be an array" }, 400) + } + try { + await saveWorkspaceConfig({ presets: body }) + return c.json({ ok: true }) + } catch (e: any) { + return c.json({ error: e?.message ?? "save failed" }, 500) + } +}) + +// ── admin platform (system info + git pull) ── + +/** + * Snapshot of server state for the admin dashboard at /admin/system. Polled + * every few seconds while the page is open. Active = WS attached OR SDK + * streaming a reply OR a user message landed in the last 60s. "Active users" + * is the unique-driver count across those loops. + */ +app.get("/api/admin/system", requireAdmin, async (c) => { + // version + how far behind origin we are + let branch = "unknown", commit = "unknown" + try { branch = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim() } catch {} + try { commit = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "rev-parse", "HEAD"])).stdout.trim() } catch {} + let behindBy = 0, latestCommit: string | null = null, latestMessage: string | null = null + try { + // Don't `fetch` here — too slow for a 5s poll. Use whatever the local + // origin/main ref already knows; admin clicks "Check" to refresh it. + const remote = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "rev-parse", "@{u}"])).stdout.trim() + if (commit && remote && commit !== remote) { + const log = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "log", "--oneline", `${commit}..${remote}`])).stdout.trim() + behindBy = log ? log.split("\n").length : 0 + latestCommit = remote + latestMessage = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "log", "-1", "--pretty=%s", remote])).stdout.trim() + } + } catch {} + + const { stat: fsStat } = await import("node:fs/promises") + const snap = getActivitySnapshot() + const now = Date.now() + const activeLoops: Array<{ id: string; title: string; driver: string; wsCount: number; generating: boolean; lastMsgAgeSec: number }> = [] + let totalWs = 0 + let totalGenerating = 0 + for (const s of snap) { + totalWs += s.wsCount + if (s.generating) totalGenerating++ + let lastMsgAgeSec = Number.POSITIVE_INFINITY + try { + const st = await fsStat(loopHistoryPath(s.id)) + lastMsgAgeSec = Math.floor((now - st.mtimeMs) / 1000) + } catch {} + const active = s.wsCount > 0 || s.generating || lastMsgAgeSec < 60 + if (!active) continue + const meta = await getLoop(s.id) + if (!meta) continue + activeLoops.push({ + id: s.id, + title: meta.title, + driver: meta.driver ?? meta.createdBy, + wsCount: s.wsCount, + generating: s.generating, + lastMsgAgeSec: Number.isFinite(lastMsgAgeSec) ? lastMsgAgeSec : -1, + }) + } + const activeUsers = new Set(activeLoops.map((l) => l.driver)).size + return c.json({ + version: { branch, commit, behindBy, latestCommit, latestMessage }, + activity: { + activeLoops: activeLoops.length, + activeUsers, + totalWs, + totalGenerating, + loops: activeLoops, + }, + }) +}) + +/** Run git fetch — refreshes origin/main so the dashboard's behindBy is current. */ +app.post("/api/admin/system/check", requireAdmin, async (c) => { + try { + const r = await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "fetch", "--quiet"]) + return c.json({ ok: true, message: r.stderr.trim() || "ok" }) + } catch (e: any) { + return c.json({ ok: false, error: e?.stderr?.toString().trim() || e?.message || "fetch failed" }, 500) + } +}) + +/** + * git pull --ff-only. Does NOT restart the server — bun --hot is expected to + * pick up code changes. Schema/dep changes need a real restart (ssh in, run + * scripts/stop.sh && scripts/start.sh). Failures surface stderr verbatim so + * the admin can see exactly what to fix. + */ +app.post("/api/admin/system/pull", requireAdmin, async (c) => { + let oldHead = "", newHead = "", message = "" + try { + oldHead = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "rev-parse", "HEAD"])).stdout.trim() + const r = await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "pull", "--ff-only"]) + newHead = (await execFileP("git", ["-C", LOOPAT_INSTALL_DIR, "rev-parse", "HEAD"])).stdout.trim() + message = (r.stdout || r.stderr || "").trim() || "ok" + } catch (e: any) { + return c.json({ + ok: false, + error: e?.stderr?.toString().trim() || e?.stdout?.toString().trim() || e?.message || "pull failed", + oldHead, newHead, + }, 500) + } + return c.json({ ok: true, pulled: oldHead !== newHead, oldHead, newHead, message }) +}) + +// ── settings (auth required) ── + +app.get("/api/settings/personal", requireAuth, async (c) => { + const userId = c.get("userId") as string + const cfg = await loadPersonalConfig(userId) + // Recompute token usage from persisted message histories (modelUsage in result messages) + const tokenUsage = await recomputeTokenUsage(userId) + const providers: Record<string, { models: ModelEntry[]; baseUrl: string; hasKey: boolean; enabled: boolean; maxContextTokens?: number }> = {} + for (const [name, p] of Object.entries(cfg.providers)) { + providers[name] = { + models: p.models, + baseUrl: p.baseUrl, + hasKey: !!p.apiKey, + enabled: p.enabled, + ...(p.maxContextTokens ? { maxContextTokens: p.maxContextTokens } : {}), + } + } + return c.json({ + providers, + default: cfg.default, + tokenUsage, + }) +}) + +app.put("/api/settings/personal", requireAuth, async (c) => { + const userId = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + try { + await savePersonalConfig(userId, { + default: typeof body.default === "string" ? body.default : undefined, + providers: body.providers, + }) + return c.json({ ok: true }) + } catch (e: any) { + return c.json({ error: e?.message ?? "save failed" }, 500) + } +}) + +// ── disk-shape settings (for the rich Settings page) ── +// `/api/settings/personal/disk` returns the raw personal/<user>/.loopat/ +// config.json. Provider apiKey strings may contain `${VAR}` references; for +// each provider we report whether the referenced vault env file exists. +// The resolved (secret) values are NEVER included — the UI sees structure +// and existence only. + +app.get("/api/settings/personal/disk", requireAuth, async (c) => { + const userId = c.get("userId") as string + const disk = await readPersonalDiskRaw(userId) + const refExists: Record<string, { kind: string; exists: boolean; varName?: string }> = {} + if (disk.providers) { + for (const [name, val] of Object.entries(disk.providers)) { + if (name === "default" || !val || typeof val !== "object") continue + const apiKey = (val as any).apiKey + if (apiKey !== undefined) { + const d = describeApiKeyRef(apiKey, userId) + refExists[`providers.${name}.apiKey`] = { + kind: d.kind, + exists: d.exists, + ...(d.varName ? { varName: d.varName } : {}), + } + } + } + } + return c.json({ disk, refExists }) +}) + +app.put("/api/settings/personal/disk", requireAuth, async (c) => { + const userId = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + const r = await savePersonalDisk(userId, body) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json({ ok: true }) +}) + +// A vault edit (api key, mcp secret, …) is committed locally but pushing is a +// SEPARATE step — without it the secret never reaches origin, so re-importing +// the repo (or another machine) won't have it. Push after every vault write so +// secrets persist. Best-effort: the local write already succeeded. +async function persistPersonalAfterVaultWrite(userId: string) { + try { + const r = await pushPersonalToRemote(userId) + if (!r.ok) console.warn(`[loopat] personal push after vault write (${userId}): ${r.error}`) + } catch (e: any) { + console.warn(`[loopat] personal push after vault write (${userId}): ${e?.message ?? e}`) + } +} + +// Write a value to a vault env file. Used by the Settings UI when the user +// types a new apiKey / token. Body: `{ name, value, vault? }` — `name` is +// the env var name (i.e. the contents of `${...}` in config.json apiKey ref); +// `vault` defaults to "default". +app.post("/api/settings/personal/value", requireAuth, async (c) => { + const userId = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + const name = typeof body.name === "string" ? body.name : "" + const value = typeof body.value === "string" ? body.value : "" + const vault = typeof body.vault === "string" && body.vault ? body.vault : "default" + if (!VAULT_RE.test(vault)) return c.json({ error: "invalid vault" }, 400) + if (!name) return c.json({ error: "name required" }, 400) + const r = await writeVaultEnv(userId, vault, name, value) + if (!r.ok) return c.json({ error: r.error }, 400) + await persistPersonalAfterVaultWrite(userId) + return c.json({ ok: true }) +}) + +// ── MCP OAuth (auth required) ── +// loopat owns the OAuth dance entirely: discovery + DCR + auth code + PKCE +// + token exchange happen server-side. The resulting access token is written +// to the user's personal default vault as a plain env file named by the +// server's `Authorization: Bearer ${VAR}` header — i.e. the same env every +// CC spawn already substitutes into request headers. MCP tokens are thus +// indistinguishable from any other vault env (git-crypt encrypted on disk, +// auto-injected at sandbox spawn). + +const VAULT_RE = /^[a-zA-Z0-9_-]+$/ +const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/ +// Server names can have spaces, dots etc. ("Google Drive", "Solve Intelligence"). +// Allow common printable chars; reject path/shell metas. +const SERVER_NAME_RE = /^[A-Za-z0-9 ._-]{1,64}$/ + +/** Best-effort recovery of the public URL the user's browser is hitting us + * on. Order: LOOPAT_PUBLIC_URL env → X-Forwarded-* headers → request Host + * header. The OAuth `redirect_uri` is built from this and must match what + * we register with DCR. */ +function publicBaseUrl(c: any): string { + if (process.env.LOOPAT_PUBLIC_URL) return process.env.LOOPAT_PUBLIC_URL.replace(/\/+$/, "") + const xfHost = c.req.header("x-forwarded-host") + const xfProto = c.req.header("x-forwarded-proto") + const host = xfHost ?? c.req.header("host") + const proto = xfProto ?? (c.req.url.startsWith("https") ? "https" : "http") + return `${proto}://${host}` +} + +// List the MCP servers visible to a loop. Single source = the loop's merged +// `.claude/settings.json` (composed by compose.ts from team/profile/personal +// settings + plugin-shipped defaults). Each server reports whether its +// `Authorization: Bearer ${VAR}` env is set in the user's personal default +// vault — that's the `authed` flag. `authed` does NOT validate the token +// (no expiry check, no probe), it only means "the env file exists & non-empty". +app.get("/api/mcp-servers", requireAuth, async (c) => { + const userId = c.get("userId") as string + const loopId = c.req.query("loopId") + + let mcpServers: Record<string, any> = {} + if (loopId) { + const { loopClaudeDir } = await import("./paths") + const { readFile: rf } = await import("node:fs/promises") + const settingsPath = pathJoin(loopClaudeDir(loopId), "settings.json") + if (existsSync(settingsPath)) { + try { + const j = JSON.parse(await rf(settingsPath, "utf8")) + mcpServers = (j?.mcpServers ?? {}) as Record<string, any> + } catch (e: any) { + console.warn(`[mcp] loop ${loopId} settings unreadable: ${e?.message ?? e}`) + } + } + } + + const envs = await loadVaultEnvs(userId, DEFAULT_VAULT) + + const servers = await Promise.all( + Object.entries(mcpServers).map(async ([name, srv]) => { + const type = (srv?.type ?? "stdio") as string + const url = (srv as any)?.url as string | undefined + const authTokenEnv = parseBearerEnvName(srv) + // Generalized: a server is authed when EVERY ${VAR} it references (in url + // or headers) is set in the vault — not just a Bearer header token. + const requiredEnvs = mcpRequiredEnvs(srv) + const authed = requiredEnvs.length > 0 + ? requiredEnvs.every((e) => !!envs[e]) + : (authTokenEnv ? !!envs[authTokenEnv] : false) + // Inline loopat metadata (CC never sees it — stripped before the SDK): + // a setup page where the user gets their credentials, for the auth flow. + const setupResource = typeof (srv as any)?.["x-loopat-resource"] === "string" + ? (srv as any)["x-loopat-resource"] as string : undefined + let oauthSupport: OAuthSupport | undefined + if (url && (type === "http" || type === "sse")) { + try { + oauthSupport = await probeOAuthSupport(url) + } catch { + oauthSupport = "unreachable" + } + } + return { name, type, url, authTokenEnv, requiredEnvs, authed, setupResource, oauthSupport } + }), + ) + + return c.json({ servers }) +}) + +// "I copied my MCP URL from the provider's page" setup flow: reverse the +// server's ${VAR} url template against the pasted concrete URL and store the +// extracted secrets in the vault. The template is read server-side (from the +// loop's merged settings, falling back to team settings) so only the vars the +// server actually declares can be written. +app.post("/api/mcp-setup/parse", requireAuth, async (c) => { + const userId = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + const serverName = typeof body.server === "string" ? body.server : "" + const pasted = typeof body.pastedUrl === "string" ? body.pastedUrl.trim() : "" + const loopId = typeof body.loopId === "string" && body.loopId ? body.loopId : undefined + if (!serverName || !pasted) return c.json({ error: "server and pastedUrl required" }, 400) + + // Locate the server's url TEMPLATE (with ${VAR}s). + const { readFile: rf } = await import("node:fs/promises") + const readMcp = async (p: string): Promise<Record<string, any>> => { + if (!existsSync(p)) return {} + try { return (JSON.parse(await rf(p, "utf8"))?.mcpServers ?? {}) as Record<string, any> } catch { return {} } + } + let mcp: Record<string, any> = {} + if (loopId) { + const { loopClaudeDir } = await import("./paths") + mcp = await readMcp(pathJoin(loopClaudeDir(loopId), "settings.json")) + } + if (!mcp[serverName]) { + const { workspaceTeamSettingsPath } = await import("./paths") + mcp = { ...(await readMcp(workspaceTeamSettingsPath())), ...mcp } + } + const template = typeof mcp[serverName]?.url === "string" ? (mcp[serverName].url as string) : "" + if (!template) return c.json({ error: `no url template for server "${serverName}"` }, 404) + + const vars = parseTemplateVars(template, pasted) + if (!vars || Object.keys(vars).length === 0) { + return c.json({ error: "pasted URL doesn't match this server's URL shape" }, 400) + } + for (const [name, value] of Object.entries(vars)) { + const r = await writeVaultEnv(userId, DEFAULT_VAULT, name, value) + if (!r.ok) return c.json({ error: `couldn't save ${name}: ${r.error}` }, 400) + } + await persistPersonalAfterVaultWrite(userId) + return c.json({ ok: true, set: Object.keys(vars) }) +}) + +// Force re-probe of OAuth support. POST with no body clears entire cache; +// body {url: ...} evicts just that URL. Useful after admin fixes a server +// URL or adds a previously-unreachable server. +app.post("/api/mcp-servers/reprobe", requireAuth, async (c) => { + const body = await c.req.json().catch(() => ({})) + const url = typeof body.url === "string" ? body.url : undefined + evictOAuthProbe(url) + return c.json({ ok: true }) +}) + +app.post("/api/mcp-auth/start", requireAuth, async (c) => { + const userId = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + const serverName = typeof body.serverName === "string" ? body.serverName.trim() : "" + const loopId = typeof body.loopId === "string" ? body.loopId.trim() : "" + if (!serverName || !SERVER_NAME_RE.test(serverName)) return c.json({ error: "invalid serverName" }, 400) + if (!loopId) return c.json({ error: "loopId required" }, 400) + const r = await startMcpAuth({ + user: userId, + serverName, + loopId, + publicBaseUrl: publicBaseUrl(c), + }) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json({ authorizationUrl: r.authorizationUrl }) +}) + +// OAuth provider redirects the browser here after the user authorizes. We +// finish the token exchange and bounce back to the Settings UI with the +// outcome in the query string — no JSON, browser-friendly redirect. +app.get("/api/mcp-auth/callback", async (c) => { + const state = c.req.query("state") ?? "" + const code = c.req.query("code") ?? "" + const errParam = c.req.query("error") + if (errParam) { + return c.redirect(`/?mcp_auth=error&reason=${encodeURIComponent(errParam)}`) + } + if (!state || !code) { + return c.redirect(`/?mcp_auth=error&reason=missing_state_or_code`) + } + const r = await completeMcpAuth({ state, code }) + if (!r.ok) { + return c.redirect(`/?mcp_auth=error&reason=${encodeURIComponent(r.error)}`) + } + return c.redirect(`/?mcp_auth=ok&server=${encodeURIComponent(r.serverName)}`) +}) + +// Restart the in-memory LoopSession for a loop (interrupt the running +// query(), so the next user message re-spawns CC and re-injects mcpServers +// + vault tokens + provider env). Conversation history is preserved via +// the SDK's --continue. Auth: must be the loop's createdBy. +app.post("/api/loops/:id/restart-session", requireAuth, async (c) => { + const userId = c.get("userId") as string + const id = c.req.param("id") + const meta = await getLoop(id) + if (!meta) return c.json({ error: "loop not found" }, 404) + if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409) + if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403) + const restarted = restartSession(id) + return c.json({ ok: true, restarted }) +}) + +// "Forget" an MCP token = delete the env file in the user's personal default +// vault. The UI passes the env name from /api/mcp-servers' `authTokenEnv`. +// This endpoint deliberately accepts any valid env name (not MCP-specific): +// MCP tokens are indistinguishable from other vault envs in the new design. +app.delete("/api/envs/:name", requireAuth, async (c) => { + const userId = c.get("userId") as string + const name = c.req.param("name") + if (!ENV_NAME_RE.test(name)) return c.json({ error: "invalid env name" }, 400) + await deleteVaultEnv(userId, DEFAULT_VAULT, name) + await persistPersonalAfterVaultWrite(userId) + return c.json({ ok: true }) +}) + +// ── tier settings API (composition model) ── + +app.get("/api/tiers", requireAuth, async (c) => { + const userId = c.get("userId") as string + const me = await findUser(userId) + const isAdmin = me?.role === "admin" + const { getTiers } = await import("./tiers") + return c.json(await getTiers(userId, isAdmin)) +}) + +app.get("/api/tiers/:tier/settings", requireAuth, async (c) => { + const userId = c.get("userId") as string + const tierId = c.req.param("tier") ?? "" + const { getTierSettings } = await import("./tiers") + return c.json(await getTierSettings(tierId, userId)) +}) + +app.put("/api/tiers/:tier/settings", requireAuth, async (c) => { + const userId = c.get("userId") as string + const tierId = c.req.param("tier") ?? "" + const me = await findUser(userId) + const isAdmin = me?.role === "admin" + // Only admin can write team/profile tiers + if ((tierId === "team" || tierId.startsWith("profile:")) && !isAdmin) { + return c.json({ error: "admin required for team/profile tiers" }, 403) + } + const body = await c.req.json().catch(() => ({})) + const { saveTierSettings } = await import("./tiers") + const r = await saveTierSettings(tierId, body, userId) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json({ ok: true }) +}) + +app.get("/api/tiers/:tier/mise-config", requireAuth, async (c) => { + const userId = c.get("userId") as string + const tierId = c.req.param("tier") ?? "" + const { getTierMiseConfig } = await import("./tiers") + return c.json(await getTierMiseConfig(tierId, userId)) +}) + +app.put("/api/tiers/:tier/mise-config", requireAuth, async (c) => { + const userId = c.get("userId") as string + const tierId = c.req.param("tier") ?? "" + const me = await findUser(userId) + const isAdmin = me?.role === "admin" + if ((tierId === "team" || tierId.startsWith("profile:")) && !isAdmin) { + return c.json({ error: "admin required for team/profile tiers" }, 403) + } + const body = await c.req.json().catch(() => ({})) + const content = typeof body.content === "string" ? body.content : "" + const { saveTierMiseConfig } = await import("./tiers") + const r = await saveTierMiseConfig(tierId, content, userId) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json({ ok: true }) +}) + +app.get("/api/plugins/available", requireAuth, async (c) => { + const { listAvailablePlugins } = await import("./tiers") + return c.json({ plugins: await listAvailablePlugins() }) +}) + +app.get("/api/plugins/browse", requireAuth, async (c) => { + const { browseMarketplacePlugins } = await import("./tiers") + return c.json({ plugins: await browseMarketplacePlugins() }) +}) + +app.get("/api/marketplaces", requireAuth, async (c) => { + const { listMarketplaces } = await import("./tiers") + return c.json({ marketplaces: await listMarketplaces() }) +}) + +app.post("/api/plugins/refresh", requireAuth, async (c) => { + const userId = c.get("userId") as string + const { refreshMarketplaces } = await import("./tiers") + const r = await refreshMarketplaces(userId) + if (!r.ok) return c.json({ error: r.error }, 500) + return c.json({ ok: true, added: r.added }) +}) + +app.get("/api/settings/workspace", requireAuth, requireAdmin, async (c) => { + const cfg = await loadConfig() + const providers: Record<string, { models: ModelEntry[]; baseUrl: string; hasKey: boolean; enabled: boolean }> = {} + if (cfg.providers) { + for (const [name, p] of Object.entries(cfg.providers)) { + providers[name] = { models: p.models, baseUrl: p.baseUrl, hasKey: !!(p as any).apiKey, enabled: p.enabled } + } + } + const tokenUsage = await recomputeWorkspaceTokenUsage() + return c.json({ + providers, + default: cfg.default ?? "", + tokenUsage, + }) +}) + +app.put("/api/settings/workspace", requireAuth, requireAdmin, async (c) => { + const body = await c.req.json().catch(() => ({})) + try { + await saveWorkspaceConfig({ + providers: body.providers, + default: typeof body.default === "string" ? body.default : undefined, + }) + return c.json({ ok: true }) + } catch (e: any) { + return c.json({ error: e?.message ?? "save failed" }, 500) + } +}) + +app.get("/api/settings/token-usage/daily", requireAuth, async (c) => { + const userId = c.get("userId") as string + const daily = await recomputeDailyTokenUsage(userId) + return c.json(daily) +}) + +app.get("/api/settings/token-usage/loops", requireAuth, async (c) => { + const userId = c.get("userId") as string + const loops = await recomputeLoopTokenUsage(userId) + return c.json(loops) +}) + +// ── token usage recompute helpers ── + +import { readFile, writeFile, appendFile, mkdir } from "node:fs/promises" + +type TokenUsageEntry = { inputTokens: number; outputTokens: number; cacheReadInputTokens: number; cacheCreationInputTokens: number } + +function newEntry(): TokenUsageEntry { + return { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 } +} + +function addUsage(e: TokenUsageEntry, mu: any) { + e.inputTokens += mu.inputTokens ?? 0 + e.outputTokens += mu.outputTokens ?? 0 + e.cacheReadInputTokens += mu.cacheReadInputTokens ?? 0 + e.cacheCreationInputTokens += mu.cacheCreationInputTokens ?? 0 +} + +async function recomputeTokenUsage(userId: string): Promise<Record<string, TokenUsageEntry>> { + const usage: Record<string, TokenUsageEntry> = {} + try { + const allLoops = await listLoops() + const userLoops = allLoops.filter((l) => l.createdBy === userId) + for (const loop of userLoops) { + const hp = loopHistoryPath(loop.id) + if (!existsSync(hp)) continue + let raw: string + try { raw = await readFile(hp, "utf8") } catch { continue } + for (const line of raw.split("\n")) { + if (!line) continue + try { + const msg = JSON.parse(line) + if (msg.type === "result" && msg.modelUsage) { + for (const [model, u] of Object.entries(msg.modelUsage)) { + const mu = u as any + const entry = usage[model] ?? newEntry() + addUsage(entry, mu) + usage[model] = entry + } + } + } catch {} + } + } + } catch {} + return usage +} + +async function recomputeWorkspaceTokenUsage(): Promise<Record<string, TokenUsageEntry>> { + const usage: Record<string, TokenUsageEntry> = {} + try { + const allLoops = await listLoops() + for (const loop of allLoops) { + const hp = loopHistoryPath(loop.id) + if (!existsSync(hp)) continue + let raw: string + try { raw = await readFile(hp, "utf8") } catch { continue } + for (const line of raw.split("\n")) { + if (!line) continue + try { + const msg = JSON.parse(line) + if (msg.type === "result" && msg.modelUsage) { + for (const [model, u] of Object.entries(msg.modelUsage)) { + const mu = u as any + const entry = usage[model] ?? newEntry() + addUsage(entry, mu) + usage[model] = entry + } + } + } catch {} + } + } + } catch {} + return usage +} + +async function recomputeDailyTokenUsage(userId: string): Promise<Record<string, Record<string, TokenUsageEntry>>> { + // daily[model][date] = { inputTokens, outputTokens, ... } + const daily: Record<string, Record<string, TokenUsageEntry>> = {} + try { + const allLoops = await listLoops() + const userLoops = allLoops.filter((l) => l.createdBy === userId) + for (const loop of userLoops) { + const hp = loopHistoryPath(loop.id) + if (!existsSync(hp)) continue + let raw: string + try { raw = await readFile(hp, "utf8") } catch { continue } + // Fallback date for historical messages without _ts: loop creation date + const fallbackDate = (loop.createdAt ?? new Date().toISOString()).slice(0, 10) + let currentDate = fallbackDate + for (const line of raw.split("\n")) { + if (!line) continue + try { + const msg = JSON.parse(line) + // Track date: explicit _ts wins, clear-boundary ts updates the sliding window + if (msg.type === "clear-boundary" && typeof msg.ts === "string") { + currentDate = msg.ts.slice(0, 10) + } + const ts = typeof msg._ts === "string" ? msg._ts : null + const date = ts ? ts.slice(0, 10) : currentDate + if (msg.type === "result" && msg.modelUsage) { + for (const [model, u] of Object.entries(msg.modelUsage)) { + const mu = u as any + daily[model] ??= {} + const entry = daily[model][date] ?? newEntry() + addUsage(entry, mu) + daily[model][date] = entry + } + } + } catch {} + } + } + } catch {} + return daily +} + +async function recomputeLoopTokenUsage(userId: string): Promise<Array<{ + loopId: string + title: string + model: string + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + lastActivity: string +}>> { + const loops: Array<{ + loopId: string + title: string + model: string + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + lastActivity: string + }> = [] + try { + const allLoops = await listLoops() + const userLoops = allLoops.filter((l) => l.createdBy === userId) + for (const loop of userLoops) { + const hp = loopHistoryPath(loop.id) + if (!existsSync(hp)) continue + let raw: string + try { raw = await readFile(hp, "utf8") } catch { continue } + let inputTokens = 0 + let outputTokens = 0 + let cacheReadInputTokens = 0 + let cacheCreationInputTokens = 0 + const models = new Set<string>() + let lastActivity = loop.createdAt ?? "" + for (const line of raw.split("\n")) { + if (!line) continue + try { + const msg = JSON.parse(line) + if (msg._ts) lastActivity = msg._ts + if (msg.type === "result" && msg.modelUsage) { + for (const [, mu] of Object.entries(msg.modelUsage as Record<string, any>)) { + const m = mu as any + inputTokens += m.inputTokens ?? 0 + outputTokens += m.outputTokens ?? 0 + cacheReadInputTokens += m.cacheReadInputTokens ?? 0 + cacheCreationInputTokens += m.cacheCreationInputTokens ?? 0 + } + for (const model of Object.keys(msg.modelUsage)) { + models.add(model) + } + } + } catch {} + } + if (inputTokens > 0 || outputTokens > 0) { + loops.push({ + loopId: loop.id, + title: loop.title || "Untitled", + model: Array.from(models).join(", "), + inputTokens, + outputTokens, + cacheReadInputTokens, + cacheCreationInputTokens, + lastActivity, + }) + } + } + } catch {} + // Sort by last activity descending + loops.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)) + return loops +} + +// ── personal repo bootstrap (deploy-key flow) ── +// +// Two-step: +// 1. POST /api/auth/register → user created, personal/<id>/ scaffolded with +// `git init` + ed25519 deploy keypair. Response carries `publicKey`. +// 2. User registers publicKey as a deploy key (write access) on their +// personalRepo, then calls POST /api/personal/import to clone the repo +// using the managed private key. Cloned content replaces the empty +// scaffold; the keypair is preserved. +// +// GET /api/personal/status reports current state so the UI can render +// "needs import" banner + retry button. + +app.get("/api/personal/status", requireAuth, async (c) => { + const userId = c.get("userId") as string + const user = await findUser(userId) + if (!user) return c.json({ error: "user missing" }, 500) + // If the user never went through register-with-personalRepo (or ssh-keygen + // was unavailable then), the keypair may be missing — try once now so this + // endpoint can serve as the lazy-init for the deploy-key flow. + let publicKey = await getPublicKey(userId) + if (!publicKey) { + const r = await ensurePersonalKeypair(userId) + publicKey = r.publicKey + } + const imported = !(await isPersonalFresh(userId)) + // Per-vault SSH public keys — what the user registers on the TEAM git host + // for knowledge/notes/repos. Distinct from the deploy key (publicKey above). + const vaultKeys = await listVaultPublicKeys(userId) + // The active provider comes from the extensions dir (no config.json needed), + // and supplies its own baseUrl / defaultRepo / tokenHelp defaults. + const provider = await resolveProvider() + return c.json({ + userId, + personalRepo: user.personalRepo ?? null, + publicKey, + vaultKeys, + imported, + gitHost: { + provider: provider?.id ?? "github", + baseUrl: provider?.baseUrl ?? null, + defaultRepo: provider?.defaultRepo ?? "loopat-personal", + tokenHelp: provider?.tokenHelp ?? null, + }, + }) +}) + +// Onboarding gate status for the active provider (see userOnboarding). The web +// app blocks the main UI on this until `done`. `gated:false` → no provider gate. +app.get("/api/onboarding", requireAuth, async (c) => { + const userId = c.get("userId") as string + return c.json(await userOnboarding(userId)) +}) + +// Submit one onboarding form. loopat runs each field's action (store in vault / +// provision the personal repo) and returns the provider's next view. +app.post("/api/onboarding/submit", requireAuth, async (c) => { + const userId = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + const values = (body && typeof body.values === "object" && body.values) ? body.values as Record<string, string> : {} + const r = await submitOnboarding(userId, values) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json(r.next) +}) + +// Export the user's git-crypt key (base64). Behind a fresh password check +// to prevent walk-up attacks on an unattended browser. The key decrypts +// .loopat/vaults/** on any host that holds it, so we don't want a stolen +// session cookie to be enough to lift it. +app.post("/api/personal/crypt-key", requireAuth, async (c) => { + const userId = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + const password = typeof body.password === "string" ? body.password : "" + if (!password) return c.json({ error: "password required" }, 400) + const user = await findUser(userId) + if (!user) return c.json({ error: "user missing" }, 500) + const ok = await verifyPassword(password, user.salt, user.hash) + if (!ok) return c.json({ error: "wrong password" }, 403) + const { gitCryptKeyExists, getGitCryptKey } = await import("./git-crypt-key") + if (!(await gitCryptKeyExists(userId))) { + return c.json({ error: "no crypt key on this host" }, 404) + } + try { + const buf = await getGitCryptKey(userId) + return c.json({ cryptKey: buf.toString("base64") }) + } catch (e: any) { + return c.json({ error: `failed to read key: ${e?.message ?? e}` }, 500) + } +}) + +app.post("/api/personal/import", requireAuth, async (c) => { + const userId = c.get("userId") as string + const user = await findUser(userId) + if (!user) return c.json({ error: "user missing" }, 500) + const body = await c.req.json().catch(() => ({})) + const provided = typeof body.repoUrl === "string" && body.repoUrl.trim() ? body.repoUrl.trim() : "" + const repoUrl = provided || user.personalRepo + if (!repoUrl) return c.json({ error: "no personalRepo on file and none provided" }, 400) + const cryptKey = typeof body.cryptKey === "string" && body.cryptKey.trim() ? body.cryptKey.trim() : undefined + // If the user typed a fresh URL (had none on file, or changed it), persist + // before attempting clone — keeps users.json + personal/ consistent. + if (provided && provided !== user.personalRepo) { + await setPersonalRepo(userId, provided) + } + const r = await importPersonalFromRepo(userId, repoUrl, cryptKey) + if (!r.ok) { + // 422 = data condition prevents proceeding (secrets leaked — user must + // fix locally first, no amount of input here helps). + if (r.secretsExposed) { + return c.json({ error: r.error, secretsExposed: true, exposedFiles: r.exposedFiles ?? [] }, 422) + } + // 422 = repo isn't a clean slate; user must point at a fresh repo or use + // Recovery (BYOK). UI surfaces the Recovery hint in this case. + if (r.notClean) { + return c.json({ error: r.error, notClean: true }, 422) + } + if (r.needsCryptKey) return c.json({ error: r.error, needsCryptKey: true }, 409) + return c.json({ error: r.error }, 400) + } + // On auto-init, `cryptKey` is returned exactly once for the user to back + // up. Subsequent /api/personal/status calls do NOT expose it. + return c.json({ ok: true, autoInitialized: !!r.autoInitialized, cryptKey: r.cryptKey ?? null }) +}) + +// POST /api/personal/github — onboard personal via a GitHub PAT (host-side +// only): create the repo, register the deploy key, clone + git-crypt. The PAT +// never enters a sandbox; runtime git uses the deploy key / vault. See +// docs/identity.md (integration contract). +app.post("/api/personal/github", requireAuth, async (c) => { + const userId = c.get("userId") as string + const user = await findUser(userId) + if (!user) return c.json({ error: "user missing" }, 500) + const body = await c.req.json().catch(() => ({})) + const token = typeof body.token === "string" ? body.token.trim() : "" + if (!token) return c.json({ error: "github token required" }, 400) + // Provider + its baseUrl/defaultRepo come from the extensions dir, not + // config.json. A request body may still override any of them. + const p = await resolveProvider(typeof body.provider === "string" && body.provider.trim() ? body.provider.trim() : undefined) + const repoName = (typeof body.repoName === "string" && body.repoName.trim()) || p?.defaultRepo || "loopat-personal" + const baseUrl = (typeof body.baseUrl === "string" && body.baseUrl.trim() ? body.baseUrl.trim() : undefined) ?? p?.baseUrl + const cryptKey = typeof body.cryptKey === "string" && body.cryptKey.trim() ? body.cryptKey.trim() : undefined + const provider = p?.id ?? "github" + const r = await setupPersonalViaProvider({ userId, provider, token, baseUrl, repoName, cryptKey }) + if (!r.ok) { + if (r.needsCryptKey) return c.json({ error: r.error, needsCryptKey: true }, 409) + return c.json({ error: r.error }, 400) + } + await setPersonalRepo(userId, r.repoUrl) + return c.json({ ok: true, repo: r.repo, created: r.created, autoInitialized: !!r.autoInitialized, cryptKey: r.cryptKey ?? null }) +}) + +// POST /api/personal/repos — list the user's repos for the onboarding picker. +// "personal"-named repos come first. Empty when the provider can't list. +app.post("/api/personal/repos", requireAuth, async (c) => { + const body = await c.req.json().catch(() => ({})) + const token = typeof body.token === "string" ? body.token.trim() : "" + if (!token) return c.json({ ok: false, repos: [], error: "token required" }) + // Provider + baseUrl from the extensions dir (no config.json), request may override. + const p = await resolveProvider(typeof body.provider === "string" && body.provider.trim() ? body.provider.trim() : undefined) + const provider = p?.id ?? "github" + const baseUrl = (typeof body.baseUrl === "string" && body.baseUrl.trim() ? body.baseUrl.trim() : undefined) ?? p?.baseUrl + try { + // Validate the token first so a bad token surfaces as an error in the token + // step, instead of an empty (misleading "no repos") picker. + const auth = await authenticateViaProvider({ provider, token, baseUrl }) + if (!auth.ok) return c.json({ ok: false, repos: [], error: auth.error }) + const repos = await listPersonalReposViaProvider({ provider, token, baseUrl }) + return c.json({ ok: true, repos, login: auth.login }) + } catch (e: any) { + return c.json({ ok: false, repos: [], error: e?.message ?? String(e) }) + } +}) + +// Destroy personal/<user>/ AND the saved git-crypt key. Two-step from the +// client's POV: first call (no `force`) verifies the password, inspects the +// repo, attempts a sync if dirty, and either deletes (clean / sync ok) or +// returns 409 with a data-loss preview. Second call (force=true, same +// password) skips the sync and just deletes. +app.post("/api/personal/delete", requireAuth, async (c) => { + const userId = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + const password = typeof body.password === "string" ? body.password : "" + const force = body.force === true + if (!password) return c.json({ error: "password required" }, 400) + const user = await findUser(userId) + if (!user) return c.json({ error: "user missing" }, 500) + const ok = await verifyPassword(password, user.salt, user.hash) + if (!ok) return c.json({ error: "wrong password" }, 403) + + const status = await inspectPersonalDirty(userId) + const dirty = status.uncommitted > 0 || status.unpushed > 0 + + if (!force && dirty) { + // Try to sync first. If it works, we can delete with no data loss. + const sync = await syncPersonalToRemote(userId) + if (!sync.ok) { + return c.json( + { + error: "personal/ has unsynced changes and sync failed", + syncFailed: true, + syncError: sync.error, + uncommitted: status.uncommitted, + unpushed: status.unpushed, + hasRemote: status.hasRemote, + }, + 409, + ) + } + } + + const del = await deletePersonalVault(userId) + if (!del.ok) return c.json({ error: del.error }, 500) + return c.json({ + ok: true, + synced: !force && dirty, + dataLost: force && dirty, + }) +}) + +// Pull from remote. Stashes local changes, fetches, merges, then pops stash. +// If `force: true` is passed in the body, discards all local changes instead +// of stashing (for recovering from stash failures). +app.post("/api/personal/pull", requireAuth, async (c) => { + const userId = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + const r = await pullPersonalFromRemote(userId, { force: !!body.force }) + if (!r.ok) { + const status: Record<string, unknown> = { error: r.error } + if (r.conflict) { status.conflict = true; status.files = r.files } + if (r.needsStash) status.needsStash = true + return c.json(status, r.conflict ? 409 : 400) + } + return c.json({ ok: true, message: r.message }) +}) + +// Push to remote. Commits, rebases onto origin (held back on real conflict), +// then ff-pushes. +app.post("/api/personal/push", requireAuth, async (c) => { + const userId = c.get("userId") as string + const r = await pushPersonalToRemote(userId) + if (!r.ok) { + const status: Record<string, unknown> = { error: r.error } + if (r.conflict) { status.conflict = true; status.files = r.files } + if (r.needsPull) status.needsPull = true + return c.json(status, (r.conflict || r.needsPull) ? 409 : 400) + } + return c.json({ ok: true, message: r.message }) +}) + +// ── Workspace repo sync (knowledge / notes / repos/<name>) ── +// All ff-only. Any authenticated user may sync. Push to repos/<name> is +// not supported — code flows through PRs upstream, never from primary. + +function syncDirFor(user: string, resource: string, name?: string): string | null { + if (resource === "knowledge") return personalKnowledgeDir(user) + if (resource === "notes") return personalNotesDir(user) + if (resource === "repos" && name) { + const dir = personalRepoDir(user, name) + return existsSync(dir) ? dir : null + } + return null +} + +app.get("/api/sync/knowledge/status", requireAuth, async (c) => { const u = c.get("userId") as string; return c.json(await inspectRepoSync(personalKnowledgeDir(u), u)) }) +app.post("/api/sync/knowledge/pull", requireAuth, async (c) => { + const u = c.get("userId") as string + const r = await pullRepoFromRemote(personalKnowledgeDir(u), u) + return r.ok ? c.json(r) : c.json({ error: r.error }, 400) +}) +app.post("/api/sync/knowledge/push", requireAuth, async (c) => { + const u = c.get("userId") as string + const r = await pushRepoToRemote(personalKnowledgeDir(u), u) + return r.ok ? c.json(r) : c.json({ error: r.error }, 400) +}) + +app.get("/api/sync/notes/status", requireAuth, async (c) => { const u = c.get("userId") as string; return c.json(await inspectRepoSync(personalNotesDir(u), u)) }) +app.post("/api/sync/notes/pull", requireAuth, async (c) => { + const u = c.get("userId") as string + const r = await pullRepoFromRemote(personalNotesDir(u), u) + return r.ok ? c.json(r) : c.json({ error: r.error }, 400) +}) +app.post("/api/sync/notes/push", requireAuth, async (c) => { + const u = c.get("userId") as string + const r = await pushRepoToRemote(personalNotesDir(u), u) + return r.ok ? c.json(r) : c.json({ error: r.error }, 400) +}) + +app.get("/api/sync/repos", requireAuth, async (c) => { + // List the user's per-user repos available for sync (subdirs of their repos dir). + const u = c.get("userId") as string + try { + const { readdir } = await import("node:fs/promises") + const entries = await readdir(personalReposDir(u)) + const repos: string[] = [] + for (const e of entries) { + if (e.startsWith(".")) continue + if (existsSync(personalRepoDir(u, e) + "/.git")) repos.push(e) + } + return c.json({ repos }) + } catch { + return c.json({ repos: [] }) + } +}) +app.get("/api/sync/repos/:name/status", requireAuth, async (c) => { + const u = c.get("userId") as string + const dir = syncDirFor(u, "repos", c.req.param("name")) + if (!dir) return c.json({ error: "repo not found" }, 404) + return c.json(await inspectRepoSync(dir, u)) +}) +app.post("/api/sync/repos/:name/pull", requireAuth, async (c) => { + const u = c.get("userId") as string + const dir = syncDirFor(u, "repos", c.req.param("name")) + if (!dir) return c.json({ error: "repo not found" }, 404) + const r = await pullRepoFromRemote(dir, u) + return r.ok ? c.json(r) : c.json({ error: r.error }, 400) +}) + +// All /api/* routes below require auth, EXCEPT the two endpoints used by the +// public share view (GET /api/loops/:id and WS /ws/loop/:id), which allow +// anonymous read iff meta.public === true. There is no anonymous workspace +// access at all. + +app.get("/api/loops", requireAuth, async (c) => { + // ?archived=true → only archived; ?archived=all → both; default → hide archived + const filter = c.req.query("archived") ?? "" + const all = await listLoops() + let loops = all + if (filter === "true") loops = all.filter((m) => m.archived === true) + else if (filter === "all") loops = all + else loops = all.filter((m) => m.archived !== true) + return c.json({ loops }) +}) + +// List vaults this user has on disk. Each entry is the name a loop can put +// in `meta.config.vault` to bind that vault's contents into the sandbox. +// When the user hasn't created any vaults yet, the legacy `secrets/` dir +// shows up as the implicit "default" vault. +app.get("/api/vaults", requireAuth, async (c) => { + const userId = c.get("userId") as string + const { listVaults } = await import("./vaults") + return c.json({ vaults: listVaults(userId) }) +}) + +// ── A2A settings (per-user agent card + default profiles/vault + key) ── +const A2A_KEY_LABEL = "a2a" +function a2aPublicBase(c: any): string { + const configured = process.env.LOOPAT_A2A_PUBLIC_URL + if (configured) return configured.replace(/\/+$/, "") + const proto = c.req.header("x-forwarded-proto") ?? "http" + const host = c.req.header("host") ?? `127.0.0.1:${process.env.PORT ?? 10001}` + return `${proto}://${host}` +} +app.get("/api/a2a", requireAuth, async (c) => { + const userId = c.get("userId") as string + const cfg = await loadA2AConfig(userId) + const { listVaults } = await import("./vaults") + const { listProfiles } = await import("./profiles") + const tokens = await listApiTokens(userId) + const base = a2aPublicBase(c) + return c.json({ + card: { name: cfg.card?.name ?? "", description: cfg.card?.description ?? "" }, + profiles: cfg.profiles ?? [], + vault: cfg.vault ?? "", + cardUrl: `${base}/a2a/${encodeURIComponent(userId)}/agent-card.json`, + endpoint: `${base}/a2a/${encodeURIComponent(userId)}`, + hasKey: tokens.some((t) => t.label === A2A_KEY_LABEL), + availableProfiles: await listProfiles(userId), + availableVaults: listVaults(userId), + }) +}) +app.put("/api/a2a", requireAuth, async (c) => { + const userId = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + const patch: { card?: { name?: string; description?: string }; profiles?: string[]; vault?: string } = {} + if (body.card && typeof body.card === "object") { + patch.card = { + name: typeof body.card.name === "string" ? body.card.name.slice(0, 200) : undefined, + description: typeof body.card.description === "string" ? body.card.description.slice(0, 2000) : undefined, + } + } + if (Array.isArray(body.profiles)) patch.profiles = body.profiles.filter((p: unknown): p is string => typeof p === "string") + if (typeof body.vault === "string") patch.vault = body.vault + await saveA2AConfig(userId, patch) + return c.json({ ok: true }) +}) +// Rotate the A2A key: revoke any prior a2a-labelled token, mint a fresh one +// (returned once — store it in the orchestrator). +app.post("/api/a2a/key", requireAuth, async (c) => { + const userId = c.get("userId") as string + const existing = await listApiTokens(userId) + for (const t of existing) if (t.label === A2A_KEY_LABEL) await revokeApiToken(userId, t.tokenId) + const created = await createApiToken(userId, A2A_KEY_LABEL) + return c.json({ token: created.token }) +}) + +// Return the current user's `default_profiles` from +// personal/<u>/.loopat/config.json. Used by NewLoopDialog to pre-check the +// user's typical setup. Returns [] if config absent / field missing. +app.get("/api/personal/default-profiles", requireAuth, async (c) => { + const userId = c.get("userId") as string + const { existsSync } = await import("node:fs") + const { readFile } = await import("node:fs/promises") + const { personalLoopatConfigPath } = await import("./paths") + const path = personalLoopatConfigPath(userId) + if (!existsSync(path)) return c.json({ default_profiles: [] }) + try { + const j = JSON.parse(await readFile(path, "utf8")) + const arr = Array.isArray(j?.default_profiles) + ? j.default_profiles.filter((x: unknown): x is string => typeof x === "string") + : [] + return c.json({ default_profiles: arr }) + } catch { + return c.json({ default_profiles: [] }) + } +}) + +app.put("/api/personal/default-profiles", requireAuth, async (c) => { + const userId = c.get("userId") as string + const { existsSync } = await import("node:fs") + const { readFile, writeFile, mkdir: mk } = await import("node:fs/promises") + const { dirname } = await import("node:path") + const { personalLoopatConfigPath } = await import("./paths") + const body = await c.req.json().catch(() => ({})) + if (!Array.isArray(body.default_profiles)) return c.json({ error: "default_profiles must be an array" }, 400) + const path = personalLoopatConfigPath(userId) + let cfg: Record<string, any> = {} + if (existsSync(path)) { + try { cfg = JSON.parse(await readFile(path, "utf8")) } catch { cfg = {} } + } + cfg.default_profiles = body.default_profiles.filter((x: unknown): x is string => typeof x === "string") + try { + await mk(dirname(path), { recursive: true }) + await writeFile(path, JSON.stringify(cfg, null, 2) + "\n") + return c.json({ ok: true }) + } catch (e: any) { + return c.json({ error: e?.message ?? "save failed" }, 500) + } +}) + +// List available profiles in `<LOOPAT_HOME>/context/profiles/`. Each profile +// is a directory with a profile.json. Returns name + description so the UI +// can render a multi-select. Base profile is included if present — UI may +// choose to render it as "always on" / non-toggleable. +/** + * Compute totals for a hypothetical loop with the given profile selection. + * Team layer is always implicit. Used by NewLoopDialog to show "23 plugins · + * 7 skills · ..." preview before the user creates the loop. + * + * Reads .claude/ dirs of team + selected profiles + each enabled plugin + * (host-installed cache OR local marketplace source). + */ +app.get("/api/loop-stats", requireAuth, async (c) => { + const userId = c.get("userId") as string + const { computeLoopStats } = await import("./loop-stats") + const profilesParam = c.req.query("profiles") ?? "" + const profiles = profilesParam.split(",").map((s) => s.trim()).filter(Boolean) + const stats = await computeLoopStats(userId, profiles) + return c.json(stats) +}) + +app.get("/api/profiles", requireAuth, async (c) => { + // Profile = a subdir of `.loopat/profiles/` with a `.claude/` inside. + // No loopat-invented metadata file: `description` is pulled from the + // profile's CLAUDE.md frontmatter `description:` field (preferred) or + // its first heading (legacy fallback) — see extractProfileDescription. + const userId = c.get("userId") as string + const { listProfiles } = await import("./profiles") + const { extractProfileDescription } = await import("./tiers") + const { personalKnowledgeProfileClaudeMdPath } = await import("./paths") + const { existsSync } = await import("node:fs") + const { readFile } = await import("node:fs/promises") + const names = await listProfiles(userId) + const profiles = await Promise.all(names.map(async (name) => { + const mdPath = personalKnowledgeProfileClaudeMdPath(userId, name) + const md = existsSync(mdPath) ? await readFile(mdPath, "utf8").catch(() => null) : null + return { name, description: extractProfileDescription(md) ?? undefined } + })) + return c.json({ profiles }) +}) + +app.post("/api/loops", requireAuth, async (c) => { + const userId = c.get("userId") as string + // Onboarding gate: when the active provider requires onboarding, block loop + // creation until it's complete (see userOnboarding / GitHostProvider.isOnboarded). + const ob = await userOnboarding(userId) + if (ob.gated && !ob.done) return c.json({ error: "onboarding incomplete", onboarding: ob }, 403) + 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 + // Profile model: `profiles` body field is an array of profile names (loaded + // from `<LOOPAT_HOME>/context/profiles/<name>/`). The old `sandbox` body + // field is silently ignored — UI's NewLoopDialog still sends it for + // backward compat but it has no effect on the spawn flow. + const profiles: string[] | undefined = Array.isArray(body.profiles) + ? body.profiles.filter((s: unknown): s is string => typeof s === "string" && s.trim().length > 0) + : undefined + const vault = typeof body.vault === "string" && body.vault.trim() ? body.vault.trim() : undefined + const knowledgeRw = body.knowledge_rw === true + const mountAllLoops = body.mount_all_loops === true + if (mountAllLoops) { + // Cross-loop view exposes every other loop's chats / workdir / meta. + // Admin-only — checked server-side so a non-admin can't just POST the + // flag by hand. (UI never offers the toggle outside the admin menu.) + const me = await findUser(userId) + if (!me || me.role !== "admin") { + return c.json({ error: "mount_all_loops requires admin role" }, 403) + } + } + try { + const meta = await createLoop({ title, repo, createdBy: userId, profiles, vault, knowledgeRw, mountAllLoops }) + return c.json(meta) + } catch (e: any) { + return c.json({ error: e?.message ?? "create failed" }, 400) + } +}) + +// Distill: spawn a child loop seeded with the source's conversation snapshot +// and a distill-kind project-tier CLAUDE.md. Any authenticated user may call; +// the source is read-only here. +app.post("/api/loops/:id/distill", requireAuth, async (c) => { + const sourceId = c.req.param("id") ?? "" + const userId = c.get("userId") as string + try { + const child = await distillLoop(sourceId, userId) + return c.json(child) + } catch (e: any) { + const msg = e?.message ?? "distill failed" + const code = /not found/i.test(msg) ? 404 : 400 + return c.json({ error: msg }, code) + } +}) + +app.post("/api/loops/:id/viewed", requireAuth, async (c) => { + const id = c.req.param("id") + markLoopViewed(id) + // Broadcast immediately so UI updates without refresh + const entry = getLoopStatus()[id] + if (entry) { + const update = { [id]: entry } + for (const [ws, ids] of statusWatchers) { + if (ids.has(id)) { + try { ws.send(JSON.stringify({ type: "update", data: update })) } catch {} + } + } + } + return c.json({ ok: true }) +}) + +// Public-or-auth: anonymous visitors get meta only when the loop is public. +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) + if (!meta.public && !getRequestUserId(c)) { + return c.json({ error: "unauthorized" }, 401) + } + return c.json(meta) +}) + +// Archive / unarchive. Only the loop owner (createdBy) may flip the flag. +app.patch("/api/loops/:id", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (meta.createdBy !== userId) return c.json({ error: "forbidden" }, 403) + const body = await c.req.json().catch(() => ({})) + const patch: Partial<typeof meta> = {} + if (typeof body.archived === "boolean") { + patch.archived = body.archived + patch.archivedAt = body.archived ? new Date().toISOString() : undefined + } + if (typeof body.public === "boolean") { + patch.public = body.public + patch.publicAt = body.public ? new Date().toISOString() : undefined + } + if (typeof body.title === "string") { + const t = body.title.trim() + if (!t) return c.json({ error: "title cannot be empty" }, 400) + if (t.length > 200) return c.json({ error: "title too long (max 200)" }, 400) + patch.title = t + } + // Share config fields + if (typeof body.shareEnabled === "boolean") patch.shareEnabled = body.shareEnabled + if (body.shareMode === "static" || body.shareMode === "port" || body.shareMode === "ephemeral") patch.shareMode = body.shareMode + if (typeof body.shareAlias === "string") patch.shareAlias = body.shareAlias.trim() || undefined + if (typeof body.sharePort === "number") patch.sharePort = body.sharePort + if (typeof body.shareExternalPort === "number") patch.shareExternalPort = body.shareExternalPort + if (body.shareProtocol === "tcp" || body.shareProtocol === "udp" || body.shareProtocol === "static") patch.shareProtocol = body.shareProtocol + if (Object.keys(patch).length === 0) return c.json({ error: "no allowed fields" }, 400) + const previous = await getLoop(id) // before patch (for old mode comparison) + const updated = await patchLoopMeta(id, patch) + const shareTouched = + "shareEnabled" in patch || "shareExternalPort" in patch || "sharePort" in patch || + "shareProtocol" in patch || "shareMode" in patch + if (shareTouched) { + // Touch trigger for port-proxy (direct mode hot-reloads from inotify). + try { + const trigger = pathJoin(loopsDir(), ".port-proxy-trigger") + await writeFile(trigger, String(Date.now())) // write ts to guarantee inotify Modify event + } catch {} + // Ephemeral mode: -p flags live on the loop container's create-args, + // so the container itself must be recreated. Kill attached SDK + PTY; + // the next attach calls ensureContainer, sees config-hash drift, and + // recreates with the new `-p :<sharePort>` (kernel picks new host port). + const wasEphemeral = previous && previous.shareEnabled && previous.shareMode === "ephemeral" + const isEphemeral = updated && updated.shareEnabled && updated.shareMode === "ephemeral" + const portChanged = previous && updated && previous.sharePort !== updated.sharePort + const protoChanged = previous && updated && previous.shareProtocol !== updated.shareProtocol + if (wasEphemeral || isEphemeral || (isEphemeral && (portChanged || protoChanged))) { + destroyLoopSession(id) + killTerm(id) + } + } + // On archive: tear down the Claude SDK process and terminal PTY so no + // orphaned processes linger. Un-archive is fine — next connect re-spawns. + if (body.archived === true) { + destroyLoopSession(id) + killTerm(id) + } + return c.json(updated) +}) + +// Request For Drive: current driver releases control. Sandbox + terminal are +// torn down (on-disk history kept), and `rfdRequestedAt` is set so any other +// authenticated user can claim the loop via POST /:id/drive. +app.post("/api/loops/:id/request-drive", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (!isDriver(meta, userId)) return c.json({ error: "forbidden" }, 403) + if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409) + if (meta.rfdRequestedAt) return c.json({ error: "already requested for drive" }, 409) + const updated = await patchLoopMeta(id, { + rfdRequestedAt: new Date().toISOString(), + rfdRequestedBy: userId, + }) + // Tear down what's running. .claude/, messages.jsonl, sandbox snapshot all + // stay — the next driver resumes via --continue when they send a message. + destroyLoopSession(id) + killTerm(id) + return c.json(updated) +}) + +// Drive: any authenticated user takes over an RFD'd loop. Lazy spawn — the +// sandbox respawns under the new driver's personal config on the next user +// message (ensureStarted picks up effectiveDriver). `pendingDriverNote` lets +// the model know about the handoff on the first message. +app.post("/api/loops/:id/drive", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (!meta.rfdRequestedAt) return c.json({ error: "not up for drive" }, 409) + if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409) + const now = new Date().toISOString() + const previous = effectiveDriver(meta) + const history = [...(meta.driverHistory ?? []), { driver: userId, since: now }] + const updated = await patchLoopMeta(id, { + driver: userId, + driverHistory: history, + rfdRequestedAt: undefined, + rfdRequestedBy: undefined, + pendingDriverNote: { from: previous, to: userId, at: now }, + }) + // Repoint the personal mount before the next spawn. Idempotent. + try { await ensureContextMounts(id, userId) } catch (e: any) { + console.warn(`[loopat] /drive: ensureContextMounts failed for ${id}: ${e?.message ?? e}`) + } + return c.json(updated) +}) + +// Strip thinking blocks from the SDK jsonl history (used before switching +// to a provider that can't validate the existing thinking signatures). +app.post("/api/loops/:id/strip-thinking", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409) + if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409) + if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403) + const session = getSession(id) + const r = await session.stripThinkingBlocks() + return c.json(r) +}) + +/** + * Read the live host port for an ephemeral-mode share. Returns null when + * the container is down, not in ephemeral mode, or the mapping hasn't + * been observed yet (e.g. container is still starting). The UI polls + * this endpoint while the dialog is open so a fresh restart's new port + * appears without the user reloading. + */ +app.get("/api/loops/:id/share/current-port", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (!meta.shareEnabled || meta.shareMode !== "ephemeral" || !meta.sharePort) { + return c.json({ port: null }) + } + const proto: "tcp" | "udp" = meta.shareProtocol === "udp" ? "udp" : "tcp" + const port = await getEphemeralHostPort(id, meta.sharePort, proto) + return c.json({ port, internalPort: meta.sharePort, protocol: proto }) +}) + +app.get("/api/loops/:id/context", requireAuth, 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" }) + if (existsSync(loopContextRepos(id))) mounts.push({ name: "repos", path: "context/repos" }) + return c.json({ mounts }) +}) + +app.get("/api/loops/:id/files", requireAuth, 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) }) +}) + +// Recursive file tree — single call returns all files under a path. +// The frontend FilePicker uses this instead of recursively calling /files. +app.get("/api/loops/:id/files/tree", requireAuth, 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 listDirRecursive(id, path) }) +}) + +app.get("/api/loops/:id/file", requireAuth, 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", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409) + if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409) + if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403) + 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 }) +}) + +app.post("/api/loops/:id/upload", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409) + if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409) + if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403) + const formData = await c.req.formData() + const file = formData.get("file") + if (!(file instanceof File)) return c.json({ error: "file required" }, 400) + const dir = loopWorkdir(id) + const filePath = join(dir, file.name) + try { + const buf = await file.arrayBuffer() + await Bun.write(filePath, new Uint8Array(buf)) + return c.json({ ok: true, path: file.name }) + } catch (e: any) { + return c.json({ error: e?.message ?? "upload failed" }, 500) + } +}) + +app.delete("/api/loops/:id/file", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409) + if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409) + if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403) + const path = c.req.query("path") ?? "" + if (!path) return c.json({ error: "path required" }, 400) + const ok = await deleteWorkdirFile(id, path) + if (!ok) return c.json({ error: "delete failed" }, 500) + return c.json({ ok: true }) +}) + +app.post("/api/loops/:id/folder", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409) + if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409) + if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403) + const body = await c.req.json().catch(() => ({})) + if (typeof body.path !== "string" || !body.path) return c.json({ error: "path required" }, 400) + const ok = await createWorkdirFolder(id, body.path) + if (!ok) return c.json({ error: "mkdir failed" }, 500) + return c.json({ ok: true }) +}) + +// ── chat history ── +app.get("/api/loops/:id/chat-history", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + const path = loopChatHistoryPath(id) + if (!existsSync(path)) return c.json([]) + const raw = await Bun.file(path).text() + const lines = raw.split("\n").filter(Boolean) + const entries = lines.map((l) => { + try { return JSON.parse(l) } catch { return null } + }).filter((e): e is NonNullable<typeof e> => e !== null) + return c.json(entries) +}) + +app.post("/api/loops/:id/chat-history", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409) + if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409) + if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403) + const body = await c.req.json().catch(() => ({})) + if (typeof body.text !== "string" || !body.text.trim()) return c.json({ error: "text required" }, 400) + const path = loopChatHistoryPath(id) + await mkdir(dirname(path), { recursive: true }) + await appendFile(path, JSON.stringify({ text: body.text.trim(), ts: Date.now() }) + "\n") + return c.json({ ok: true }) +}) + +// ── git operations (workdir) ── + +type GitFileInfo = { + path: string + status: "A" | "M" | "D" | "R" | "?" + additions: number + deletions: number + isBinary: boolean +} + +type GitCommit = { + hash: string + shortHash: string + subject: string + author: string + date: string + parentHashes: string[] + branch: string | null + branches: string[] + tags: string[] +} + +async function getGitStatus(loopId: string): Promise<{ unstaged: GitFileInfo[]; staged: GitFileInfo[] }> { + const dir = loopWorkdir(loopId) + if (!existsSync(join(dir, ".git"))) return { unstaged: [], staged: [] } + + const execOpts = { encoding: "utf8" as const, timeout: 10_000 } + const unstaged: GitFileInfo[] = [] + const staged: GitFileInfo[] = [] + + // Parse git status --porcelain for file statuses + let porcelain = "" + try { + porcelain = (await execFileP("git", ["-C", dir, "status", "--porcelain"], execOpts)).stdout.trim() + } catch { return { unstaged: [], staged: [] } } + + // Get numstat for unstaged and staged changes + let unstagedNumstat = "" + let stagedNumstat = "" + try { unstagedNumstat = (await execFileP("git", ["-C", dir, "diff", "--numstat"], execOpts)).stdout.trim() } catch {} + try { stagedNumstat = (await execFileP("git", ["-C", dir, "diff", "--cached", "--numstat"], execOpts)).stdout.trim() } catch {} + + // Parse numstat into map: path -> { additions, deletions, isBinary } + const numstatMap = new Map<string, { additions: number; deletions: number; isBinary: boolean }>() + for (const line of [...stagedNumstat.split("\n"), ...unstagedNumstat.split("\n")]) { + const parts = line.split("\t") + if (parts.length < 3) continue + const adds = parseInt(parts[0], 10) + const dels = parseInt(parts[1], 10) + const isBinary = isNaN(adds) || isNaN(dels) + const p = parts[2] + // Only set if not already present or if the new one has more info + if (!numstatMap.has(p) || (!isBinary && numstatMap.get(p)!.isBinary)) { + numstatMap.set(p, { additions: isNaN(adds) ? 0 : adds, deletions: isNaN(dels) ? 0 : dels, isBinary }) + } + } + + for (const line of porcelain.split("\n")) { + if (!line || line.length < 4) continue + const xy = line.slice(0, 2) + // Robust path extraction: skip 2-char status, then trim leading whitespace + // Handles both ` M README.md` and `?? hello.html` formats reliably + let rest = line.slice(2).trimStart() + if (!rest) continue + // git quotes paths containing spaces/special chars: ` M "my file.txt"` + if (rest.startsWith('"') && rest.endsWith('"')) { + rest = rest.slice(1, -1) + } + // Handle renamed files: `R old.txt -> new.txt` — take the new name after `-> ` + if (xy[0] === 'R' || xy[1] === 'R') { + const arrowIdx = rest.indexOf(' -> ') + if (arrowIdx >= 0) rest = rest.slice(arrowIdx + 4) + } + const p = rest + + const stat = numstatMap.get(p) ?? { additions: 0, deletions: 0, isBinary: false } + + // Index status (staged) + if (xy[0] !== " " && xy[0] !== "?") { + const code = xy[0] as GitFileInfo["status"] + staged.push({ path: p, status: code, ...stat }) + } + // Worktree status (unstaged) + if (xy[1] !== " " && xy[1] !== "!") { + const code = xy[1] === "?" ? "?" : xy[1] as GitFileInfo["status"] + unstaged.push({ path: p, status: code, ...stat }) + } + } + + return { unstaged, staged } +} + +app.get("/api/loops/:id/git-status", async (c) => { + const id = c.req.param("id") ?? "" + if (!(await loopExists(id))) return c.json({ error: "not found" }, 404) + return c.json(await getGitStatus(id)) +}) + +app.get("/api/loops/:id/git-diff", 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 staged = c.req.query("staged") === "1" + const dir = loopWorkdir(id) + if (!existsSync(join(dir, ".git"))) return c.json({ error: "not a git repo" }, 400) + try { + const args = ["-C", dir, "diff", "--", path] + if (staged) args.splice(3, 0, "--cached") + let diff = (await execFileP("git", args, { encoding: "utf8", timeout: 10_000 })).stdout.trim() + // Untracked files have nothing in the index to diff against — fall back to + // --no-index /dev/null to show the full file content as additions. + if (!diff && !staged) { + diff = (await execFileP("git", ["-C", dir, "diff", "--no-index", "/dev/null", path], { encoding: "utf8", timeout: 10_000 })).stdout.trim() + } + return c.json({ diff }) + } catch (e: any) { + return c.json({ error: e?.message ?? "diff failed" }, 500) + } +}) + +app.post("/api/loops/:id/git-stage", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409) + if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409) + if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403) + const body = await c.req.json().catch(() => ({})) + const files: string[] = Array.isArray(body.files) ? body.files : [] + const unstage = body.unstage === true + if (files.length === 0) return c.json({ error: "files required" }, 400) + const dir = loopWorkdir(id) + if (!existsSync(join(dir, ".git"))) return c.json({ error: "not a git repo" }, 400) + try { + const args = unstage ? ["-C", dir, "reset", "HEAD", "--", ...files] : ["-C", dir, "add", "--", ...files] + await execFileP("git", args, { encoding: "utf8", timeout: 10_000 }) + return c.json({ ok: true }) + } catch (e: any) { + return c.json({ error: e?.message ?? "stage failed" }, 500) + } +}) + +app.post("/api/loops/:id/git-commit", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409) + if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409) + if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403) + const body = await c.req.json().catch(() => ({})) + const message = typeof body.message === "string" && body.message.trim() ? body.message.trim() : "" + if (!message) return c.json({ error: "commit message required" }, 400) + const dir = loopWorkdir(id) + if (!existsSync(join(dir, ".git"))) return c.json({ error: "not a git repo" }, 400) + try { + await execFileP("git", ["-C", dir, "commit", "-m", message], { encoding: "utf8", timeout: 10_000 }) + return c.json({ ok: true }) + } catch (e: any) { + return c.json({ error: e?.message ?? "commit failed" }, 500) + } +}) + +app.get("/api/loops/:id/git-log", async (c) => { + const id = c.req.param("id") ?? "" + if (!(await loopExists(id))) return c.json({ error: "not found" }, 404) + const limit = parseInt(c.req.query("limit") ?? "50", 10) + const dir = loopWorkdir(id) + if (!existsSync(join(dir, ".git"))) return c.json({ commits: [] }) + try { + const format = "%H%n%h%n%s%n%an%n%ai%n%P%n%D" + const raw = (await execFileP("git", ["-C", dir, "log", `--format=${format}`, "-n", String(Math.min(limit, 200))], { encoding: "utf8", timeout: 10_000 })).stdout.trim() + const commits: GitCommit[] = [] + const lines = raw.split("\n") + for (let i = 0; i + 6 < lines.length || i < lines.length; i += 7) { + if (i + 6 >= lines.length) break + const refs = lines[i + 6] + const branchMatch = refs.match(/HEAD -> ([^,\]]+)/) + const branches = refs.split(",").map(s => s.trim()).filter(s => s && !s.startsWith("HEAD") && !s.startsWith("tag:")) + const tagMatches = refs.match(/tag: ([^,\)]+)/g) + const tags = tagMatches ? tagMatches.map(t => t.replace("tag: ", "").trim()) : [] + commits.push({ + hash: lines[i], + shortHash: lines[i + 1], + subject: lines[i + 2], + author: lines[i + 3], + date: lines[i + 4], + parentHashes: lines[i + 5].split(" ").filter(Boolean), + branch: branchMatch?.[1] ?? null, + branches, + tags, + }) + } + return c.json({ commits }) + } catch { + return c.json({ commits: [] }) + } +}) + +app.post("/api/loops/:id/git-discard", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const meta = await getLoop(id) + if (!meta) return c.json({ error: "not found" }, 404) + if (meta.archived) return c.json({ error: "loop is archived (read-only)" }, 409) + if (meta.rfdRequestedAt) return c.json({ error: "loop is in RFD state — click Drive to take over" }, 409) + if (!isDriver(meta, userId)) return c.json({ error: `only driver (${effectiveDriver(meta)}) can write` }, 403) + const body = await c.req.json().catch(() => ({})) + const file: string = typeof body.file === "string" ? body.file : "" + if (!file) return c.json({ error: "file required" }, 400) + const dir = loopWorkdir(id) + if (!existsSync(join(dir, ".git"))) return c.json({ error: "not a git repo" }, 400) + try { + // First check if the file is tracked. Untracked files can't be + // checked out — remove them instead. + const tracked = (await execFileP("git", ["-C", dir, "ls-files", "--error-unmatch", file], { encoding: "utf8", timeout: 5_000 }).catch(() => null)) !== null + if (tracked) { + await execFileP("git", ["-C", dir, "checkout", "--", file], { encoding: "utf8", timeout: 10_000 }) + } else { + await execFileP("rm", ["-f", join(dir, file)], { encoding: "utf8", timeout: 5_000 }) + } + return c.json({ ok: true }) + } catch (e: any) { + return c.json({ error: e?.message ?? "discard failed" }, 500) + } +}) + +// Workspace vault APIs (Context tab) +const VAULTS = new Set(["knowledge", "notes", "personal", "repos"]) + +// notes is edited through a per-user UI-loop worktree (a no-AI UI loop); make +// sure it exists (opened from origin/main) before any read/write resolves it. +async function ensureVaultReady(vault: string, user: string): Promise<void> { + if (vault === "notes") await ensureUiNotesWorktree(user) +} + +app.get("/api/workspace/files", requireAuth, async (c) => { + const vault = c.req.query("vault") ?? "" + if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400) + const userId = c.get("userId") as string + const path = c.req.query("path") ?? "" + await ensureVaultReady(vault, userId) + if (c.req.query("flat") === "1") { + return c.json({ entries: await vaultFlatList(vault as VaultId, userId) }) + } + return c.json({ entries: await vaultList(vault as VaultId, path, userId) }) +}) + +app.get("/api/workspace/file", requireAuth, async (c) => { + const vault = c.req.query("vault") ?? "" + if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400) + const userId = c.get("userId") as string + const path = c.req.query("path") ?? "" + if (!path) return c.json({ error: "path required" }, 400) + await ensureVaultReady(vault, userId) + const r = await vaultRead(vault as VaultId, path, userId) + if (!r) return c.json({ error: "not a file" }, 404) + return c.json(r) +}) + +app.put("/api/workspace/file", requireAuth, async (c) => { + const userId = c.get("userId") as string + 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) + await ensureVaultReady(vault, userId) + const r = await vaultWrite(vault as VaultId, path, body.content, userId) + if (!r.ok) return c.json({ error: r.error }, 500) + return c.json(r) +}) + +app.post("/api/workspace/file", requireAuth, async (c) => { + const userId = c.get("userId") as string + 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) + await ensureVaultReady(vault, userId) + const r = await vaultCreateFile(vault as VaultId, body.path, userId) + if (!r.ok) return c.json({ error: r.error }, r.error === "exists" ? 409 : 500) + return c.json({ ok: true }) +}) + +// Save = land this user's notes edits on origin/main (the no-AI UI loop). +// Explicit: the user clicks save. ff-only + rebase; a real conflict is held back. +app.post("/api/notes/save", requireAuth, async (c) => { + const userId = c.get("userId") as string + const r = await syncUiNotes(userId) + if (!r.ok) { + const status: Record<string, unknown> = { error: r.error } + if (r.conflict) { status.conflict = true; status.files = r.files } + if (r.needsPull) status.needsPull = true + return c.json(status, (r.conflict || r.needsPull) ? 409 : 400) + } + return c.json({ ok: true, message: r.message }) +}) + +// How many commits the user's notes are behind origin (drives the "remote +// updated" hint). Polled lightly by the client; no push. +app.get("/api/notes/behind", requireAuth, async (c) => { + const userId = c.get("userId") as string + return c.json({ behind: await notesBehind(userId) }) +}) + +// Refresh = ff-pull origin/main into the user's notes worktree. Diverged (local +// unsaved edits) is not an error — the client just keeps its draft. +app.post("/api/notes/refresh", requireAuth, async (c) => { + const userId = c.get("userId") as string + const r = await ffUpdateUiNotes(userId) + if (!r.ok) return c.json({ ok: false, diverged: r.diverged ?? false, error: r.error }, r.diverged ? 200 : 400) + return c.json({ ok: true }) +}) + +// Take-remote: drop held-back local notes edits, reset hard to origin. The +// escape hatch when save reports a same-spot conflict it can't rebase past. +app.post("/api/notes/discard", requireAuth, async (c) => { + const userId = c.get("userId") as string + const r = await discardUiNotes(userId) + if (!r.ok) return c.json({ error: r.error }, 400) + return c.json({ ok: true }) +}) + +app.delete("/api/workspace/file", requireAuth, async (c) => { + const userId = c.get("userId") as string + 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 vaultDelete(vault as VaultId, path, userId) + if (!r.ok) return c.json({ error: r.error }, 500) + return c.json({ ok: true }) +}) + +app.post("/api/workspace/folder", requireAuth, async (c) => { + const userId = c.get("userId") as string + 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 vaultCreateFolder(vault as VaultId, body.path, userId) + if (!r.ok) return c.json({ error: r.error }, r.error === "exists" ? 409 : 500) + return c.json({ ok: true }) +}) + +app.get("/api/workspace/backlinks", requireAuth, async (c) => { + const vault = c.req.query("vault") ?? "" + if (!VAULTS.has(vault)) return c.json({ error: "invalid vault" }, 400) + const userId = c.get("userId") as string + const path = c.req.query("path") ?? "" + if (!path) return c.json({ backlinks: [] }) + return c.json({ backlinks: await vaultBacklinks(vault as VaultId, path, userId) }) +}) + +// Context repos roster — PERSONAL: lives in the user's own +// personal/<user>/.loopat/config.json. Physical clones stay on-demand at loop +// creation. GET returns the roster; PUT writes it straight to personal config +// (no git promote — personal config syncs through the personal-repo path). +app.get("/api/context/repos", requireAuth, async (c) => { + const u = c.get("userId") as string + const pcfg = await loadPersonalConfig(u) + return c.json({ repos: pcfg.repos ?? [] }) +}) + +app.put("/api/context/repos", requireAuth, async (c) => { + const u = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + const repos = Array.isArray(body.repos) + ? body.repos.filter((r: any) => r?.name && r?.git).map((r: any) => ({ name: String(r.name).trim(), git: String(r.git).trim() })) + : [] + const sp = await savePersonalDisk(u, { repos }) + if (!sp.ok) return c.json({ error: sp.error }, 400) + return c.json({ ok: true, repos }) +}) + +// ── topics ── + +// ── kanban: focus boards (one directory per board, one .md file per column) ── +// Kanban edits go through the editing user's notes worktree (a per-user UI +// loop): carry the user via AsyncLocalStorage + ensure the worktree exists. +// Changes reach origin through the explicit notes save (/api/notes/save). +app.use("/api/kanban/*", requireAuth, async (c, next) => { + const userId = c.get("userId") as string + await ensureUiNotesWorktree(userId) + return kanbanUserCtx.run(userId, () => next()) +}) + +// Board management +app.get("/api/kanban/boards", requireAuth, async (c) => { + const boards = await listBoards() + return c.json({ boards }) +}) + +app.post("/api/kanban/boards", requireAuth, async (c) => { + const body = await c.req.json().catch(() => ({})) + if (typeof body.name !== "string" || !body.name.trim()) { + return c.json({ error: "name required" }, 400) + } + const ok = await createBoard(body.name.trim()) + if (!ok) return c.json({ error: "create failed" }, 500) + return c.json({ ok: true }) +}) + +app.put("/api/kanban/boards/:name/rename", requireAuth, async (c) => { + const oldName = decodeURIComponent(c.req.param("name") ?? "") + const body = await c.req.json().catch(() => ({})) + if (typeof body.name !== "string" || !body.name.trim()) { + return c.json({ error: "name required" }, 400) + } + const ok = await renameBoard(oldName, body.name.trim()) + if (!ok) return c.json({ error: "rename failed" }, 500) + return c.json({ ok: true }) +}) + +// Column management +app.post("/api/kanban/columns/:board", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const body = await c.req.json().catch(() => ({})) + if (typeof body.filename !== "string" || !body.filename.trim()) { + return c.json({ error: "filename required" }, 400) + } + const ok = await createColumn(board, body.filename + (body.filename.endsWith(".md") ? "" : ".md"), body.title) + if (!ok) return c.json({ error: "create failed" }, 500) + kanbanNotify() + return c.json({ ok: true }) +}) + +app.put("/api/kanban/columns/:board/:filename/rename", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const fromFile = decodeURIComponent(c.req.param("filename") ?? "") + const body = await c.req.json().catch(() => ({})) + if (typeof body.toFile !== "string" || !body.toFile.trim()) { + return c.json({ error: "toFile required" }, 400) + } + const ok = await renameColumn(board, fromFile, body.toFile + (body.toFile.endsWith(".md") ? "" : ".md")) + if (!ok) return c.json({ error: "rename failed" }, 500) + kanbanNotify() + return c.json({ ok: true }) +}) + +app.delete("/api/kanban/columns/:board/:filename", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const ok = await deleteColumn(board, filename) + if (!ok) return c.json({ error: "delete failed" }, 500) + kanbanNotify() + return c.json({ ok: true }) +}) + +app.put("/api/kanban/columns/:board/:filename/color", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const body = await c.req.json().catch(() => ({})) + if (typeof body.color !== "string") return c.json({ error: "color required" }, 400) + await setColumnColor(board, filename, body.color) + kanbanNotify() + return c.json({ ok: true }) +}) + +app.put("/api/kanban/columns/:board/:filename/reorder", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const body = await c.req.json().catch(() => ({})) + if (!Array.isArray(body.cids)) return c.json({ error: "cids array required" }, 400) + const ok = await reorderCards(board, filename, body.cids) + if (!ok) return c.json({ error: "reorder failed" }, 500) + kanbanNotify() + return c.json({ ok: true }) +}) + +// Card mutations +app.post("/api/kanban/columns/:board/:filename/cards", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const body = await c.req.json().catch(() => ({})) + if (typeof body.text !== "string" || !body.text.trim()) { + return c.json({ error: "text required" }, 400) + } + const r = await addCard(board, filename, body) + if (!r.ok) return c.json({ error: "add failed" }, 500) + kanbanNotify() + return c.json({ cid: r.cid }) +}) + +app.patch("/api/kanban/columns/:board/:filename/cards/:cid/toggle", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const cid = c.req.param("cid") ?? "" + const ok = await toggleCard(board, filename, cid) + if (!ok) return c.json({ error: "not found" }, 404) + kanbanNotify() + return c.json({ ok: true }) +}) + +app.patch("/api/kanban/columns/:board/:filename/cards/:cid", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const cid = c.req.param("cid") ?? "" + const patch = await c.req.json().catch(() => ({})) + const ok = await updateCardMeta(board, filename, cid, patch) + if (!ok) return c.json({ error: "not found or patch failed" }, 404) + kanbanNotify() + return c.json({ ok: true }) +}) + +app.put("/api/kanban/columns/:board/:filename/cards/:cid/block", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const cid = c.req.param("cid") ?? "" + const body = await c.req.json().catch(() => ({})) + if (typeof body.block !== "string") return c.json({ error: "block required" }, 400) + const ok = await updateCardBlock(board, filename, cid, body.block) + if (!ok) return c.json({ error: "not found" }, 404) + kanbanNotify() + return c.json({ ok: true }) +}) + +app.delete("/api/kanban/columns/:board/:filename/cards/:cid", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const cid = c.req.param("cid") ?? "" + const ok = await deleteCard(board, filename, cid) + if (!ok) return c.json({ error: "not found" }, 404) + kanbanNotify() + return c.json({ ok: true }) +}) + +app.post("/api/kanban/columns/:board/:filename/cards/:cid/move", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const cid = c.req.param("cid") ?? "" + const body = await c.req.json().catch(() => ({})) + if (typeof body.toFile !== "string") return c.json({ error: "toFile required" }, 400) + const ok = await moveCard(board, filename, cid, body.toFile, body.toIndex) + if (!ok) return c.json({ error: "move failed" }, 500) + kanbanNotify() + return c.json({ ok: true }) +}) + +app.post("/api/kanban/columns/:board/:filename/cards/:cid/assign-driver", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const cid = c.req.param("cid") ?? "" + const userId = c.get("userId") as string + const r = await assignDriverForCard(board, filename, cid, userId) + if (!r.ok) return c.json({ error: "no associated loop" }, 400) + return c.json(r) +}) + +app.post("/api/kanban/columns/:board/:filename/cards/:cid/create-loop", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const cid = c.req.param("cid") ?? "" + const userId = c.get("userId") as string + const r = await createLoopFromCard(board, filename, cid, userId) + if (!r.ok) return c.json({ error: "create failed" }, 500) + return c.json(r) +}) + +app.post("/api/kanban/columns/:board/:filename/cards/:cid/link-loop", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const filename = decodeURIComponent(c.req.param("filename") ?? "") + const cid = c.req.param("cid") ?? "" + const userId = c.get("userId") as string + const { loopId } = (await c.req.json().catch(() => ({}))) as { loopId?: string } + if (!loopId) return c.json({ error: "loopId required" }, 400) + const ok = await linkLoopToCard(board, filename, cid, loopId, userId) + if (!ok) return c.json({ error: "link failed" }, 500) + return c.json({ ok: true }) +}) + +// Board data (list columns + config) +app.get("/api/kanban/:board", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const columns = await listKanbanColumns(board) + return c.json({ columns }) +}) + +app.get("/api/kanban/config/:board", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const cfg = await readKanbanConfig(board) + return c.json(cfg ?? { columns: [] }) +}) + +app.put("/api/kanban/config/:board", requireAuth, async (c) => { + const board = decodeURIComponent(c.req.param("board") ?? "default") + const body = await c.req.json().catch(() => ({})) + if (Array.isArray(body.columns)) { + await saveColumnOrder(board, body.columns) + kanbanNotify() + } + return c.json({ ok: true }) +}) + +app.get("/api/topics", requireAuth, async (c) => { + const loops = await listLoops() + const titles = loops + .filter((l) => !l.archived) + .map((l) => ({ id: l.id, title: l.title })) + return c.json({ topics: await listTopics(titles) }) +}) + +// ── Chat ────────────────────────────────────────────────────────────────── +// +// SQLite-backed channels + 1:1 DMs. Real-time fanout via /ws/chat with +// per-conversation subscriber sets. When a loop is spawned from a chat +// conversation, the last 1024 messages are snapshotted to a per-loop jsonl +// at loops/<id>/context/chat/<convId>.jsonl so the AI inside the sandbox +// can read it from /loopat/context/chat/. + +type ChatSubscriber = { ws: any; userId: string; convs: Set<string> } +const chatSubscribers = new Set<ChatSubscriber>() + +function chatBroadcastToConv(convId: string, msg: object, isDm: boolean, dmParties: [string, string] | null) { + const payload = JSON.stringify(msg) + for (const sub of chatSubscribers) { + if (!sub.convs.has(convId)) continue + // DM: only the two parties receive even if a third party somehow subscribed. + if (isDm && dmParties && sub.userId !== dmParties[0] && sub.userId !== dmParties[1]) continue + try { sub.ws.send(payload) } catch {} + } +} + +function chatBroadcastConvCreated(convCreatedPayload: any, isDm: boolean, dmParties: [string, string] | null) { + // For channel creation: broadcast to every connected client so rails refresh. + // For DM creation: only the two parties learn about it. + const payload = JSON.stringify(convCreatedPayload) + for (const sub of chatSubscribers) { + if (isDm && dmParties && sub.userId !== dmParties[0] && sub.userId !== dmParties[1]) continue + try { sub.ws.send(payload) } catch {} + } +} + +app.get("/api/chat/users", requireAuth, async (c) => { + // Workspace member directory for the DM picker. Filter to active accounts — + // pending users can't log in so DMing them is pointless. + const users = await listUsers() + const me = c.get("userId") as string + return c.json({ + users: users + .filter((u) => u.status === "active") + .map((u) => ({ id: u.id, role: u.role, isMe: u.id === me })), + }) +}) + +app.get("/api/chat/conversations", requireAuth, (c) => { + const userId = c.get("userId") as string + const convs = listConversationsForUser(userId) + return c.json({ conversations: convs }) +}) + +app.post("/api/chat/channels", requireAuth, async (c) => { + const userId = c.get("userId") as string + const body = await c.req.json().catch(() => ({})) + if (typeof body.name !== "string") return c.json({ error: "name required" }, 400) + const topic = typeof body.topic === "string" ? body.topic : undefined + const r = createChannel({ name: body.name, topic, createdBy: userId }) + if (!r.ok) return c.json({ error: r.error }, 400) + chatBroadcastConvCreated({ type: "conv_created", conv: r.conv }, false, null) + return c.json({ conv: r.conv }) +}) + +app.delete("/api/chat/channels/:id", requireAdmin, (c) => { + const id = c.req.param("id") ?? "" + const conv = getConv(id) + if (!conv || conv.kind !== "channel") return c.json({ error: "not found" }, 404) + const ok = deleteChannel(id) + if (!ok) return c.json({ error: "delete failed" }, 500) + chatBroadcastConvCreated({ type: "conv_deleted", convId: id }, false, null) + return c.json({ ok: true }) +}) + +app.post("/api/chat/dm/:username", requireAuth, async (c) => { + const me = c.get("userId") as string + const peer = c.req.param("username") ?? "" + if (!peer) return c.json({ error: "username required" }, 400) + if (peer === me) return c.json({ error: "cannot DM yourself" }, 400) + const peerUser = await findUser(peer) + if (!peerUser || peerUser.status !== "active") return c.json({ error: "user not found" }, 404) + const conv = getOrCreateDm(me, peer, me) + // Broadcast so both parties' rails see the new DM (idempotent — no-op if already known). + chatBroadcastConvCreated( + { type: "conv_created", conv }, + true, + [conv.dmUserA as string, conv.dmUserB as string], + ) + return c.json({ conv }) +}) + +app.get("/api/chat/conversations/:id/messages", requireAuth, (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const conv = getConv(id) + if (!conv) return c.json({ error: "not found" }, 404) + if (!userCanAccess(conv, userId)) return c.json({ error: "forbidden" }, 403) + const before = parseInt(c.req.query("before") ?? "0", 10) || 0 + const limit = parseInt(c.req.query("limit") ?? "50", 10) || 50 + return c.json({ messages: listMessages(id, { before, limit }) }) +}) + +app.post("/api/chat/conversations/:id/messages", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const conv = getConv(id) + if (!conv) return c.json({ error: "not found" }, 404) + if (!userCanAccess(conv, userId)) return c.json({ error: "forbidden" }, 403) + const body = await c.req.json().catch(() => ({})) + if (typeof body.text !== "string" || !body.text.trim()) return c.json({ error: "text required" }, 400) + const parentId = Number.isInteger(body.parentId) && body.parentId > 0 ? body.parentId : null + let m + try { + m = postMessage(id, userId, body.text, parentId) + } catch (e: any) { + return c.json({ error: e?.message ?? "post failed" }, 400) + } + const dmParties: [string, string] | null = + conv.kind === "dm" ? [conv.dmUserA as string, conv.dmUserB as string] : null + // Broadcast carries parent_id implicitly via Message.parentId — clients + // route it to the main feed (null) or the open ThreadPanel (matching root). + chatBroadcastToConv(id, { type: "message", message: m }, conv.kind === "dm", dmParties) + return c.json({ message: m }) +}) + +// Thread fetch: root message + all replies. Auth via the conversation the +// root belongs to. Used by ThreadPanel on open. +app.get("/api/chat/threads/:msgId", requireAuth, (c) => { + const userId = c.get("userId") as string + const rootId = parseInt(c.req.param("msgId") ?? "0", 10) + if (!rootId) return c.json({ error: "invalid msgId" }, 400) + const t = listThread(rootId) + if (!t) return c.json({ error: "not found" }, 404) + const conv = getConv(t.root.convId) + if (!conv || !userCanAccess(conv, userId)) return c.json({ error: "forbidden" }, 403) + return c.json({ root: t.root, replies: t.replies }) +}) + +app.post("/api/chat/conversations/:id/read", requireAuth, async (c) => { + const id = c.req.param("id") ?? "" + const userId = c.get("userId") as string + const conv = getConv(id) + if (!conv) return c.json({ error: "not found" }, 404) + if (!userCanAccess(conv, userId)) return c.json({ error: "forbidden" }, 403) + const body = await c.req.json().catch(() => ({})) + const lastReadId = parseInt(body.lastReadId ?? 0, 10) || 0 + if (lastReadId <= 0) return c.json({ error: "lastReadId required" }, 400) + markRead(userId, id, lastReadId) + return c.json({ ok: true }) +}) + +// Spawn a loop seeded from a thread. The thread (= root message + replies, +// length ≥ 1) is the natural semantic unit — even a brand-new top-level +// message with no replies works (snapshot of 1 line). Snapshot lives at +// loops/<id>/context/chat/<rootId>.jsonl, mounted ro at /loopat/context/chat/ +// inside the sandbox. +app.post("/api/chat/threads/:msgId/spawn-loop", requireAuth, async (c) => { + const rootId = parseInt(c.req.param("msgId") ?? "0", 10) + if (!rootId) return c.json({ error: "invalid msgId" }, 400) + const userId = c.get("userId") as string + const t = listThread(rootId) + if (!t) return c.json({ error: "not found" }, 404) + const conv = getConv(t.root.convId) + if (!conv || !userCanAccess(conv, userId)) return c.json({ error: "forbidden" }, 403) + const body = await c.req.json().catch(() => ({})) + const dmPeer = conv.kind === "dm" + ? (conv.dmUserA === userId ? conv.dmUserB : conv.dmUserA) + : null + // Title default: first ~40 chars of the thread root (the topic). Cleaner + // than "from #channel" — at thread granularity the root IS the topic. + const defaultTitle = t.root.text.replace(/\s+/g, " ").slice(0, 40).trim() || "from chat" + const title = typeof body.title === "string" && body.title.trim() + ? body.title.trim() + : defaultTitle + // Onboarding gate (same as POST /api/loops). + const ob = await userOnboarding(userId) + if (ob.gated && !ob.done) return c.json({ error: "onboarding incomplete", onboarding: ob }, 403) + let meta + try { + meta = await createLoop({ title, createdBy: userId }) + } catch (e: any) { + return c.json({ error: e?.message ?? "loop create failed" }, 400) + } + const destPath = pathJoin(loopContextChatDir(meta.id), `${rootId}.jsonl`) + let snapshot + try { + snapshot = await snapshotThreadToJsonl(rootId, destPath) + } catch (e: any) { + return c.json({ error: `snapshot failed: ${e?.message ?? e}` }, 500) + } + if (!snapshot) return c.json({ error: "thread vanished" }, 404) + await patchLoopMeta(meta.id, { + seededFrom: { + kind: "chat", + convId: t.root.convId, + threadRootId: rootId, + messageCount: snapshot.messageCount, + snapshotAt: new Date().toISOString(), + }, + } as any) + const convLabel = conv.kind === "channel" ? `#${conv.name}` : `@${dmPeer}` + const seedPrompt = + `Spawned from a ${convLabel} thread (${snapshot.messageCount} message${snapshot.messageCount === 1 ? "" : "s"}). ` + + `Snapshot at \`/loopat/context/chat/${rootId}.jsonl\` — read it with the Read tool, then propose next steps.` + return c.json({ loopId: meta.id, seedPrompt, messageCount: snapshot.messageCount }) +}) + +// ── Chat WebSocket ──────────────────────────────────────────────────────── + +app.get( + "/ws/chat", + upgradeWebSocket(async (c) => { + const userId = getRequestUserId(c) + if (!userId) { + return { + onOpen(_e, ws) { + ws.send(JSON.stringify({ type: "error", message: "unauthorized" })) + ws.close() + }, + } + } + let sub: ChatSubscriber | null = null + return { + onOpen(_e, ws) { + sub = { ws, userId, convs: new Set() } + chatSubscribers.add(sub) + ws.send(JSON.stringify({ type: "chat_connected" })) + }, + 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 === "subscribe" && typeof msg.convId === "string" && sub) { + const conv = getConv(msg.convId) + if (!conv) return + if (!userCanAccess(conv, userId)) return + sub.convs.add(msg.convId) + } else if (msg?.type === "unsubscribe" && typeof msg.convId === "string" && sub) { + sub.convs.delete(msg.convId) + } + } catch (e) { + try { ws.send(JSON.stringify({ type: "error", message: "bad message" })) } catch {} + } + }, + onClose() { + if (sub) chatSubscribers.delete(sub) + sub = null + }, + } + }) +) + +// ── Kanban WebSocket (real-time updates) ── + +app.get( + "/ws/kanban", + upgradeWebSocket(async (c) => { + const userId = getRequestUserId(c) + if (!userId) { + return { + onOpen(_e, ws) { + ws.send(JSON.stringify({ type: "error", message: "unauthorized" })) + ws.close() + }, + } + } + return { + onOpen(_e, ws) { + const sub: KanbanSubscriber = { ws, userId } + kanbanSubscribers.add(sub) + ws.send(JSON.stringify({ type: "kanban_connected" })) + }, + onMessage(_event, _ws) { + // No client-to-server messages needed for Kanban — it's broadcast-only + }, + onClose(_e, ws) { + for (const sub of kanbanSubscribers) { + if (sub.ws === ws) { kanbanSubscribers.delete(sub); break } + } + }, + } + }) +) + +app.get( + "/ws/loop/:id/term", + upgradeWebSocket(async (c) => { + const id = c.req.param("id") ?? "" + const userId = getRequestUserId(c) + if (!userId) { + return { + onOpen(_e, ws) { + ws.send(JSON.stringify({ type: "error", message: "unauthorized" })) + ws.close() + }, + } + } + const canWrite = true + const exists = await loopExists(id) + if (!exists) { + return { + onOpen(_e, ws) { + ws.send(JSON.stringify({ type: "error", message: `loop ${id} not found` })) + ws.close() + }, + } + } + // Read client dimensions from query params so we can resize the PTY + // before replaying scrollback — avoids displaying 80×24 content on a + // larger terminal, which misplaces the cursor. + const qCols = parseInt(c.req.query("cols") || "0") + const qRows = parseInt(c.req.query("rows") || "0") + let attachedTerm: any = null + return { + async onOpen(_e, ws) { + attachedTerm = ws + try { + if (qCols > 0 && qRows > 0) { + await attachTerm(id, ws, qCols, qRows) + } else { + await attachTerm(id, ws) + } + } catch (e: any) { + attachedTerm = null + const msg = e?.message ?? String(e) + console.error(`[term:${id.slice(0, 8)}] attach failed: ${msg}`) + try { + ws.send(JSON.stringify({ type: "error", message: msg })) + ws.send(JSON.stringify({ type: "exit", code: -1 })) + } catch {} + try { ws.close() } catch {} + } + }, + async onMessage(event, ws) { + try { + const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer) + const msg = JSON.parse(data) + // resize is harmless; allow anonymous so viewers don't trigger auth errors on connect + if (msg?.type === "resize" && typeof msg.cols === "number" && typeof msg.rows === "number") { + resizeTerm(id, msg.cols, msg.rows) + return + } + if (!canWrite) { + try { ws.send(JSON.stringify({ type: "error", message: "login required to send" })) } catch {} + return + } + // Block writes on archived loops (re-read each msg to honor unarchive). + const meta = await getLoop(id) + if (meta?.archived) { + try { ws.send(JSON.stringify({ type: "error", message: "loop is archived (read-only)" })) } catch {} + return + } + if (meta?.rfdRequestedAt) { + try { ws.send(JSON.stringify({ type: "error", message: "loop is in RFD state — click Drive to take over" })) } catch {} + return + } + if (meta && userId && !isDriver(meta, userId)) { + try { ws.send(JSON.stringify({ type: "error", message: `only driver (${effectiveDriver(meta)}) can write` })) } catch {} + return + } + if (msg?.type === "data" && typeof msg.data === "string") writeTerm(id, msg.data) + } 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 userId = getRequestUserId(c) + const canWrite = !!userId + const exists = await loopExists(id) + if (!exists) { + return { + onOpen(_e, ws) { + ws.send(JSON.stringify({ type: "error", message: `loop ${id} not found` })) + ws.close() + }, + } + } + // Anonymous attach is only allowed for loops that have been explicitly + // shared (meta.public). Logged-in users can attach to any loop they can + // see. Writes (sendUserText/clear/etc) for anon are blocked below. + if (!userId) { + const meta = await getLoop(id) + if (!meta?.public) { + return { + onOpen(_e, ws) { + ws.send(JSON.stringify({ type: "error", message: "unauthorized" })) + ws.close() + }, + } + } + } + const session = getSession(id) + let attached: any = null + return { + async onOpen(_e, ws) { + attached = ws + await session.attach(ws) + }, + async onMessage(event, ws) { + if (!canWrite) { + try { ws.send(JSON.stringify({ type: "error", message: "login required to send" })) } catch {} + return + } + // Block all writes on archived loops. Re-read meta per message so + // unarchive takes effect without reconnect. + const meta = await getLoop(id) + if (meta?.archived) { + try { ws.send(JSON.stringify({ type: "error", message: "loop is archived (read-only)" })) } catch {} + return + } + if (meta?.rfdRequestedAt) { + try { ws.send(JSON.stringify({ type: "error", message: "loop is in RFD state — click Drive to take over" })) } catch {} + return + } + if (meta && userId && !isDriver(meta, userId)) { + try { ws.send(JSON.stringify({ type: "error", message: `only driver (${effectiveDriver(meta)}) can write` })) } catch {} + return + } + 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") { + // Validate against SDK PermissionMode values + const validModes = ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"] + const pm = msg.permissionMode + const permissionMode = typeof pm === "string" && validModes.includes(pm) + ? pm as "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk" | "auto" + : undefined + // /goal: extract goal, persist to meta, set on session. + // Rewrite the text so CC sees a natural-language message instead + // of an unrecognized slash command. + const goalMatch = msg.text.match(/^\/goal\s+(.+)/) + if (goalMatch) { + const goal = goalMatch[1].trim() + const setAt = new Date().toISOString() + session.setGoal(goal, setAt) + patchLoopMeta(id, { config: { ...(meta?.config ?? {}), goal, goalSetAt: setAt, goalStatus: "active" } }).catch(() => {}) + msg.text = `My goal is: ${goal}` + } + session.sendUserText(msg.text, permissionMode) + } else if (msg?.type === "clear") { + session.clear(userId ?? "anon") + } else if (msg?.type === "interrupt") { + session.interrupt() + } else if (msg?.type === "queue_clear") { + session.clearQueue() + } else if (msg?.type === "queue_remove") { + if (typeof msg?.index === "number") session.removeQueueItem(msg.index) + } else if (msg?.type === "queue_status") { + try { ws.send(JSON.stringify({ type: "queue_update", queueLength: session.getQueueLength() })) } catch {} + } else if (msg?.type === "answers") { + session.answerQuestions(msg.tool_use_id, msg.answers) + } else if (msg?.type === "permission_answer") { + session.answerPermission(msg.tool_use_id, !!msg.allow) + } else if (msg?.type === "set_max_thinking_tokens") { + session.setMaxThinkingTokens( + typeof msg.tokens === "number" || msg.tokens === null ? msg.tokens : null, + ) + } else if (msg?.type === "get_context_usage") { + session.getContextUsage().then((usage) => { + if (usage) { + try { ws.send(JSON.stringify({ type: "context_usage", ...usage })) } catch {} + } + }).catch(() => {}) + } else if (msg?.type === "set_goal") { + const goal = typeof msg.goal === "string" && msg.goal.trim() ? msg.goal.trim() : null + const setAt = goal ? new Date().toISOString() : undefined + session.setGoal(goal, setAt) + patchLoopMeta(id, { config: { ...(meta?.config ?? {}), goal: goal ?? undefined, goalSetAt: setAt ?? undefined, goalStatus: goal ? "active" : undefined } }).catch(() => {}) + } else if (msg?.type === "complete_goal") { + session.completeGoal() + patchLoopMeta(id, { config: { ...(meta?.config ?? {}), goalStatus: "completed" } }).catch(() => {}) + } else if (msg?.type === "provider_select" && typeof msg.provider === "string") { + const ok = session.setProvider(msg.provider) + if (ok) { + const source = msg.source === "personal" || msg.source === "workspace" ? msg.source : undefined + const selectedModel = typeof msg.model === "string" ? msg.model : undefined + // Persist to loop meta so it survives reloads + patchLoopMeta(id, { config: { default_model: msg.provider, default_model_source: source, ...(selectedModel ? { default_model_id: selectedModel } : {}) } }).catch(() => {}) + try { + // Resolve provider info: personal first, then workspace fallback. + let p: ProviderConfig | undefined + if (userId) { + try { + const loopMeta = await getLoop(id) + const pCfg = await loadPersonalConfig(userId, loopMeta?.config?.vault) + p = pCfg.providers[msg.provider] + } catch {} + } + if (!p) { + const wCfg = await loadConfig() + p = wCfg.providers?.[msg.provider] + } + if (p) { + const activeModel = selectedModel ?? p.models.find(m => m.enabled !== false)?.id ?? p.models[0]?.id ?? "" + const activeModelEntry = p.models.find(m => m.id === activeModel) + const ctxWindow = activeModelEntry?.maxContextTokens && activeModelEntry.maxContextTokens > 0 + ? activeModelEntry.maxContextTokens + : p.maxContextTokens && p.maxContextTokens > 0 + ? p.maxContextTokens + : 200_000 + ws.send(JSON.stringify({ + type: "provider", + name: msg.provider, + model: activeModel, + models: p.models, + contextWindow: ctxWindow, + })) + } + } catch {} + } + } + } catch (e) { + console.error("ws message parse error", e) + } + }, + onClose() { + if (attached) session.detach(attached) + }, + } + }) +) + +// ── static assets (production) ── +import { getLoopStatus, watchStatusFile, markLoopViewed, type LoopStatusMap } from "./loop-status" + +// ── Loop status real-time hub ── + +let lastSnapshot: LoopStatusMap = getLoopStatus() +const statusWatchers = new Map<any, Set<string>>() + +watchStatusFile((curr, prev) => { + lastSnapshot = curr + for (const [ws, ids] of statusWatchers) { + const updates: LoopStatusMap = {} + for (const id of ids) { + if (curr[id]?.updated !== prev[id]?.updated) { + updates[id] = curr[id] + } + } + if (Object.keys(updates).length) { + try { ws.send(JSON.stringify({ type: "update", data: updates })) } catch {} + } + } +}) + +app.get("/ws/loop-status", upgradeWebSocket((c) => { + return { + onOpen: (_ev, ws) => { + statusWatchers.set(ws, new Set()) + }, + onMessage: (ev, ws) => { + try { + const text = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data as ArrayBuffer) + const msg = JSON.parse(text) + if (msg.type === "subscribe") { + const ids = new Set(msg.ids as string[]) + statusWatchers.set(ws, ids) + const init: LoopStatusMap = {} + for (const id of ids) { + if (lastSnapshot[id]) init[id] = lastSnapshot[id] + } + ws.send(JSON.stringify({ type: "init", data: init })) + } + } catch (e) { + console.error("[ws/loop-status] error:", e) + } + }, + onClose: (_ev, ws) => { + statusWatchers.delete(ws) + } + } +})) + +import { join } from "node:path" +import { networkInterfaces } from "node:os" +const webDist = join(import.meta.dir, "..", "..", "web", "dist") +const indexHtml = join(webDist, "index.html") + +app.get("*", async (c, next) => { + const path = c.req.path + // Don't interfere with API / WS routes + if (path.startsWith("/api/") || path.startsWith("/ws/")) return next() + // Try to serve the exact file + const file = Bun.file(join(webDist, path === "/" ? "index.html" : path)) + if (await file.exists()) { + // Hashed build assets are content-addressed → cache hard. HTML must always + // revalidate so a version swap (new chunk names) is picked up immediately. + const isAsset = path.startsWith("/assets/") + return new Response(file, { + headers: { + "content-type": file.type, + "cache-control": isAsset ? "public, max-age=31536000, immutable" : "no-cache", + }, + }) + } + // A missing build asset (a stale chunk after a deploy, e.g. when a browser + // still holds an old index.html) must 404 — NOT fall through to index.html, + // or the browser loads HTML as a JS module and throws "Failed to fetch + // dynamically imported module". Only extensionless paths are real SPA routes. + if (path.startsWith("/assets/") || /\.[a-zA-Z0-9]+$/.test(path)) { + return c.notFound() + } + // SPA fallback (real client-side routes) — always revalidate. + return new Response(Bun.file(indexHtml), { + headers: { "content-type": "text/html", "cache-control": "no-cache" }, + }) +}) + +const port = Number(process.env.PORT ?? 10001) +const hostname = process.env.HOST ?? "127.0.0.1" + +// Fast, serve-critical init (must precede everything else). +await ensureWorkspaceDirs() +const cfg = await loadConfig() +// Initialise chat DB. bootstrap user = first admin (if one exists) — only used +// to seed the default #general channel on a fresh DB. +let chatSeed = "" +try { + const users = await listUsers() + const firstAdmin = users.find((u) => u.role === "admin") + chatSeed = firstAdmin?.id ?? users[0]?.id ?? "" +} catch {} +initChat(chatSeed) + +// === Prepare the runtime BEFORE opening the port ========================= +// The end-user experience we want: loopat checks its dependencies and builds +// the sandbox base image up front — with visible progress — and only starts +// listening once everything is ready. That way creating the first loop is +// instant, instead of silently triggering a multi-minute image build when the +// user least expects it. When the image is already built (steady state, and +// `bun --hot` dev), these steps return immediately. +const bootReady = await printBootstrapBanner(cfg) + +const podmanProbe = await probePodman() +if (podmanProbe.ok) { + console.log(`[loopat] sandbox runtime: ${podmanProbe.version}`) +} else { + console.warn(`[loopat] sandbox runtime: NOT AVAILABLE — ${podmanProbe.hint}`) + console.warn(`[loopat] podman is required — install it, then restart loopat (chat / terminal need it).`) +} + +if (podmanProbe.ok) { + // On a non-linux host the sandbox needs a linux claude that npm/npx didn't + // install (npx skips postinstall scripts) — fetch it now (skipped once + // present / on a linux host). + try { + await ensureSandboxClaudeBinary((m) => console.log(`[loopat] ${m}`)) + } catch (e: any) { + console.warn(`[loopat] sandbox claude fetch failed: ${e?.message ?? e}; sandbox AI won't run until fixed`) + } + // Build the sandbox base image now so the first loop opens instantly. Skips + // immediately when the image for this Containerfile already exists. + try { + console.log(`[loopat] preparing sandbox base image…`) + await ensureSandboxImage({ onProgress: (m) => console.log(`[loopat] ${m}`) }) + console.log(`[loopat] sandbox base image ready`) + } catch (e: any) { + console.warn(`[loopat] sandbox image build failed: ${e?.message ?? e}; loopat will retry when you open a loop`) + } +} + +// === Everything ready — open the port ==================================== +const server = Bun.serve({ + port, + hostname, + fetch: app.fetch, + websocket, +}) +console.log(`[loopat] server listening on http://${hostname}:${port}`) +// The serve container (share / external-port proxy) needs the serve-rs Rust +// binary, which the npm package doesn't ship — so it's unavailable under npx. +// Only claim it's starting when the binary is actually present; otherwise say +// so plainly instead of printing a port nothing is listening on. +{ + const serveBinary = pathJoin(LOOPAT_INSTALL_DIR, "server", "src", "serve-rs", "target", "release", "loopat-serve") + if (existsSync(serveBinary)) { + console.log(`[loopat] workspace serve starting via podman container (port ${process.env.LOOPAT_SERVE_PORT ?? "7788"})`) + } else { + console.log(`[loopat] workspace serve unavailable — serve binary not built (share / external-port off; the workspace runs fine without it)`) + } +} + +// Now the port is open AND the sandbox image is prepared — only now is it +// honestly "ready". (Skipped when the banner reported blockers to fix.) +if (bootReady) printReadyLine() + +const backfilled = await backfillAllMounts() +if (backfilled > 0) console.log(`[loopat] backfilled context mounts on ${backfilled} loop(s)`) + +// host-cli proxy: a unix socket the loop sandboxes mount + forward to, so +// host-only clis (macOS / machine-bound) run on the host on behalf of a loop. +try { + const sock = hostExecSocketPath() + serveHostExec(sock, { loopExists }) + console.log(`[loopat] host-exec socket: ${sock}`) +} catch (e: any) { + console.warn(`[loopat] host-exec socket failed: ${e?.message ?? e}`) +} + +// Pull every imported personal repo from its remote on boot (best-effort). +// personal is a per-user repo synced directly (not via loops), so a host that +// was offline catches up here; settings edits then write-through on save. +void (async () => { + try { + for (const u of await listUsers()) { + if (await isPersonalFresh(u.id)) continue // not imported yet — nothing to pull + const r = await pullPersonalFromRemote(u.id).catch(() => null) + if (r && !r.ok) console.warn(`[loopat] boot pull personal (${u.id}): ${r.error}`) + } + } catch (e: any) { + console.warn(`[loopat] boot personal pull-all failed: ${e?.message ?? e}`) + } +})() + +// Start workspace serve + port-proxy containers on the shared bridge network. +// podman was already probed (and the base image built) before we opened the +// port — see the preparation phase above. +if (podmanProbe.ok) { + ensureServeContainer().catch((e) => { + console.warn(`[loopat] serve container failed: ${e?.message ?? e}`) + }) + ensurePortProxyContainer().catch((e) => { + console.warn(`[loopat] port-proxy container failed: ${e?.message ?? e}`) + }) +} + +// On graceful shutdown, stop every loopat-managed container so the host +// isn't left with orphaned sandbox processes after the server dies. +const stopAllOnExit = async () => { + try { + await stopAllWorkspaceContainers() + } catch (e: any) { + console.warn(`[loopat] stop-all on exit failed: ${e?.message ?? e}`) + } +} +process.on("SIGINT", () => { void stopAllOnExit().finally(() => process.exit(0)) }) +process.on("SIGTERM", () => { void stopAllOnExit().finally(() => process.exit(0)) }) + +// Plugin caching is delegated to CC itself — admin uses `claude plugin +// install` inside each sandbox's .claude/ dir. No loopat-side prewarm. + +export { server } diff --git a/server/src/kanban.ts b/server/src/kanban.ts new file mode 100644 index 00000000..7f07cdc9 --- /dev/null +++ b/server/src/kanban.ts @@ -0,0 +1,824 @@ +import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises" +import { join, basename } from "node:path" +import { existsSync } from "node:fs" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { AsyncLocalStorage } from "node:async_hooks" +import { uiNotesDir } from "./paths" +import { createLoop, patchLoopMeta, type LoopMeta } from "./loops" + +const execFileP = promisify(execFile) + +// Kanban writes land in the editing user's notes UI-loop worktree (uiNotesDir) — +// the same per-user worktree as notes files. The user is carried per-request via +// AsyncLocalStorage, so the existing function signatures stay untouched. Changes +// reach origin through the explicit notes save (syncUiNotes), like any edit. +export const kanbanUserCtx = new AsyncLocalStorage<string>() +function userNotesDir(): string { + const u = kanbanUserCtx.getStore() + if (!u) throw new Error("kanban: missing user context") + return uiNotesDir(u) +} + +// Auto-commit kanban writes so loop-worktree pushes don't silently wipe +// them via the post-receive reset --hard hook. Scoped to focus/ to avoid +// racing with vaultWrite's own commits on other paths under notes/. +async function commitFocus(msg: string): Promise<void> { + // Commit into the user's worktree so refresh (ff-only) isn't blocked by a + // dirty tree. Pushed to origin later by the explicit notes save. + const dir = userNotesDir() + if (!existsSync(join(dir, ".git"))) return + const env = { + ...process.env, + GIT_AUTHOR_NAME: "loopat", + GIT_AUTHOR_EMAIL: "ui@loopat.local", + GIT_COMMITTER_NAME: "loopat", + GIT_COMMITTER_EMAIL: "ui@loopat.local", + } + try { + await execFileP("git", ["-C", dir, "add", "--", "focus/"], { env }) + await execFileP("git", ["-C", dir, "commit", "-m", `kanban: ${msg}`], { env }) + } catch { /* nothing to commit, or transient — best-effort */ } +} + +// ── types ── + +export type KanbanCard = { + cid: string + text: string + done: boolean + assignee?: string + priority?: string + due?: string + loopId?: string + topics: string[] + description: string + subtasks: { text: string; done: boolean }[] +} + +export type KanbanColumn = { + filename: string + title: string + cards: KanbanCard[] +} + +// ── board path helpers ── + +function boardsRoot(): string { + return join(userNotesDir(), "focus", "boards") +} + +function boardDir(board: string): string { + return join(boardsRoot(), board) +} + +// ── regex ── + +const BULLET_CARD_RE = /^(\s*)- \[([ xX])\]\s+(.*)$/ +const META_RE = /^\s*>\s*([\w-]+):\s*(.*)$/ +const INDENT_BULLET_RE = /^\s+-\s*\[([ xX])\]\s+(.*)$/ +const TOPIC_RE = /(?<![\w])#([A-Za-z0-9][\w-]*)/g + +function extractTopics(text: string): string[] { + const seen = new Set<string>() + for (const m of text.matchAll(TOPIC_RE)) { + seen.add(m[1].toLowerCase()) + } + return [...seen] +} + +function hashCid(s: string): string { + let h = 0 + for (let i = 0; i < s.length; i++) { + h = (h * 31 + s.charCodeAt(i)) & 0xffffffff + } + return h.toString(36) +} + +// ── column parser ── + +/** Parse a single column markdown file into its column + cards. */ +function parseColumnFile(filename: string, body: string): KanbanColumn { + const lines = body.split("\n") + let title = basename(filename, ".md") + const cards: KanbanCard[] = [] + + let inFrontmatter = false + let i = 0 + + for (; i < lines.length; i++) { + const line = lines[i] + + // skip YAML frontmatter + if (i === 0 && line.trim() === "---") { + inFrontmatter = true + continue + } + if (inFrontmatter) { + if (line.trim() === "---") { inFrontmatter = false } + continue + } + + // first non-frontmatter # heading → column title + const h1 = line.match(/^#\s+(.*)$/) + if (h1) { + title = h1[1].trim() || title + i++ + break + } + + // if we hit content without a heading, stay at this index + if (line.trim() && !line.startsWith("#")) { + break + } + } + + // parse cards: each top-level - [ ] starts a card, indented content belongs to it + for (; i < lines.length; i++) { + const line = lines[i] + const bm = line.match(BULLET_CARD_RE) + if (!bm) continue + + const indent = bm[1] + // only top-level bullets are cards + if (indent !== "") continue + + const done = bm[2].toLowerCase() === "x" + const text = bm[3].trim() + const cid = hashCid(text) + + let assignee: string | undefined + let priority: string | undefined + let due: string | undefined + let loopId: string | undefined + const descLines: string[] = [] + const subtasks: { text: string; done: boolean }[] = [] + + // consume indented sub-content for this card + let j = i + 1 + while (j < lines.length) { + const sub = lines[j] + // stop at next top-level bullet or next heading + if (/^-\s*\[[ xX]\]/.test(sub) || /^#+\s/.test(sub)) break + if (sub.trim() === "") { j++; continue } + + // only process indented lines + if (!sub.startsWith(" ") && !sub.startsWith("\t")) { j++; continue } + + const mm = sub.match(META_RE) + if (mm) { + const k = mm[1].toLowerCase() + const v = mm[2].trim() + if (k === "assignee") assignee = v || undefined + else if (k === "priority") priority = v || undefined + else if (k === "due") due = v || undefined + else if (k === "loop") { + // accept either "loop: <id>" or "loop: <label> [<id>]" + const bm = v.match(/\[([^\]]+)\]\s*$/) + loopId = (bm ? bm[1] : v).trim() || undefined + } + j++ + continue + } + + const ib = sub.match(INDENT_BULLET_RE) + if (ib) { + subtasks.push({ text: ib[2].trim(), done: ib[1].toLowerCase() === "x" }) + j++ + continue + } + + descLines.push(sub.trim()) + j++ + } + + cards.push({ + cid, + text, + done, + assignee, + priority, + due, + loopId, + topics: extractTopics(text), + description: descLines.join("\n").trim(), + subtasks, + }) + } + + return { filename, title, cards } +} + +// ── board management ── + +export async function listBoards(): Promise<string[]> { + try { + const entries = await readdir(boardsRoot()) + const dirs: string[] = [] + for (const e of entries) { + if (e.startsWith(".")) continue + try { + const s = await readdir(join(boardsRoot(), e)) + if (s) dirs.push(e) // it's a directory + } catch { /* skip non-directories */ } + } + // "default" always first + return dirs.sort((a, b) => { + if (a === "default") return -1 + if (b === "default") return 1 + return a.localeCompare(b) + }) + } catch { + return ["default"] + } +} + +export async function createBoard(name: string): Promise<boolean> { + if (!name || name.startsWith(".") || /[/\\]/.test(name)) return false + try { + await mkdir(boardDir(name), { recursive: true }) + return true + } catch { + return false + } +} + +export async function renameBoard(oldName: string, newName: string): Promise<boolean> { + if (!newName || newName.startsWith(".") || /[/\\]/.test(newName)) return false + try { + await rename(boardDir(oldName), boardDir(newName)) + await commitFocus(`renameBoard ${oldName} → ${newName}`) + return true + } catch { + return false + } +} + +// ── read / list ── + +export async function listKanbanColumns(board = "default"): Promise<KanbanColumn[]> { + const dir = boardDir(board) + let entries: string[] = [] + try { entries = await readdir(dir) } catch { return [] } + + const cols: KanbanColumn[] = [] + for (const f of entries) { + if (!f.endsWith(".md")) continue + try { + const body = await readFile(join(dir, f), "utf8") + cols.push(parseColumnFile(f, body)) + } catch { /* skip unreadable */ } + } + + // sort: predefined order first, then alphabetical + const ORDER = ["backlog", "todo", "in-progress", "done"] + cols.sort((a, b) => { + const ai = ORDER.indexOf(basename(a.filename, ".md")) + const bi = ORDER.indexOf(basename(b.filename, ".md")) + if (ai >= 0 && bi >= 0) return ai - bi + if (ai >= 0) return -1 + if (bi >= 0) return 1 + return a.title.localeCompare(b.title) + }) + return cols +} + +async function readColumnRaw(board: string, filename: string): Promise<{ body: string; path: string } | null> { + const safe = basename(filename) + if (!safe || safe.startsWith(".")) return null + const path = join(boardDir(board), safe) + try { + const body = await readFile(path, "utf8") + return { body, path } + } catch { + return null + } +} + +// ── card helpers: find card lines in raw body ── + +function findCardRange(body: string, cid: string): { start: number; end: number; text: string } | null { + const lines = body.split("\n") + for (let i = 0; i < lines.length; i++) { + const bm = lines[i].match(BULLET_CARD_RE) + if (!bm || bm[1] !== "") continue + const text = bm[3].trim() + if (hashCid(text) !== cid) continue + + // found the card at line i; find where its sub-content ends + let end = i + 1 + while (end < lines.length) { + const sub = lines[end] + if (/^-\s*\[[ xX]\]/.test(sub) || /^#+\s/.test(sub)) break + end++ + } + return { start: i, end, text } + } + return null +} + +// ── mutations ── + +export async function addCard(board: string, filename: string, opts: { + text: string + assignee?: string + priority?: string + due?: string + topics?: string[] + description?: string +}): Promise<{ ok: boolean; cid?: string }> { + const raw = await readColumnRaw(board, filename) + const dir = boardDir(board) + if (!raw) { + // create new column file + const title = basename(filename, ".md") + const body = `# ${title}\n\n- [ ] ${opts.text}\n` + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, basename(filename)), body) + await commitFocus(`addCard ${board}/${filename}`) + return { ok: true, cid: hashCid(opts.text) } + } + + let lines = raw.body.split("\n") + + // build card line with topics + const topicStr = opts.topics?.length ? " " + opts.topics.map((t) => `#${t}`).join(" ") : "" + const cardLine = `- [ ] ${opts.text}${topicStr}` + + // append metadata sub-lines + const subLines: string[] = [] + if (opts.assignee) subLines.push(` > assignee: ${opts.assignee}`) + if (opts.priority) subLines.push(` > priority: ${opts.priority}`) + if (opts.due) subLines.push(` > due: ${opts.due}`) + if (opts.description) { + for (const dl of opts.description.split("\n")) { + subLines.push(` ${dl}`) + } + } + + // append card at end, with blank line separator if there are existing cards + const hasCards = lines.some((l) => /^-\s*\[[ xX]\]/.test(l)) + if (hasCards) lines.push("") + lines.push(cardLine) + for (const sl of subLines) lines.push(sl) + // ensure trailing newline + if (lines[lines.length - 1] !== "") lines.push("") + + await writeFile(raw.path, lines.join("\n")) + await commitFocus(`addCard ${board}/${filename}`) + return { ok: true, cid: hashCid(opts.text) } +} + +export async function toggleCard(board: string, filename: string, cid: string): Promise<boolean> { + const raw = await readColumnRaw(board, filename) + if (!raw) return false + + const lines = raw.body.split("\n") + for (let i = 0; i < lines.length; i++) { + const bm = lines[i].match(BULLET_CARD_RE) + if (!bm || bm[1] !== "") continue + if (hashCid(bm[3].trim()) !== cid) continue + + const ch = bm[2].toLowerCase() === "x" ? " " : "x" + lines[i] = lines[i].replace(/\[[ xX]\]/, `[${ch}]`) + await writeFile(raw.path, lines.join("\n")) + await commitFocus(`toggleCard ${board}/${filename}`) + return true + } + return false +} + +export async function deleteCard(board: string, filename: string, cid: string): Promise<boolean> { + const raw = await readColumnRaw(board, filename) + if (!raw) return false + + const range = findCardRange(raw.body, cid) + if (!range) return false + + const lines = raw.body.split("\n") + // remove trailing blank line if card was the last content + while (range.end < lines.length && lines[range.end].trim() === "") range.end++ + lines.splice(range.start, range.end - range.start) + + // clean up trailing double-blanks + const newBody = lines.join("\n").replace(/\n{3,}/g, "\n\n") + await writeFile(raw.path, newBody) + await commitFocus(`deleteCard ${board}/${filename}`) + return true +} + +export async function moveCard(board: string, fromFile: string, cid: string, toFile: string, toIndex?: number): Promise<boolean> { + const fromRaw = await readColumnRaw(board, fromFile) + if (!fromRaw) return false + + const range = findCardRange(fromRaw.body, cid) + if (!range) return false + + const fromLines = fromRaw.body.split("\n") + + // extract card lines (including sub-content) + const cardLines: string[] = [] + for (let i = range.start; i < range.end; i++) { + cardLines.push(fromLines[i]) + } + + // remove from source + let end = range.end + while (end < fromLines.length && fromLines[end].trim() === "") end++ + fromLines.splice(range.start, end - range.start) + const newFromBody = fromLines.join("\n").replace(/\n{3,}/g, "\n\n") + await writeFile(fromRaw.path, newFromBody) + + // insert into target at position + const toRaw = await readColumnRaw(board, toFile) + const dir = boardDir(board) + if (!toRaw) { + // create target column + const title = basename(toFile, ".md") + const body = `# ${title}\n\n${cardLines.join("\n")}\n` + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, basename(toFile)), body) + await commitFocus(`moveCard ${board}/${fromFile} → ${toFile}`) + return true + } + + const toLines = toRaw.body.split("\n") + + if (toIndex !== undefined && toIndex >= 0) { + // find the line index of the toIndex-th card + let cardCount = 0 + let insertAt = toLines.length + for (let i = 0; i < toLines.length; i++) { + const bm = toLines[i].match(BULLET_CARD_RE) + if (bm && bm[1] === "") { + if (cardCount === toIndex) { + insertAt = i + break + } + cardCount++ + } + } + // insert before the target card + const linesToInsert = [...cardLines] + if (insertAt < toLines.length) { + // add blank line separator before the card we're inserting before + if (insertAt > 0 && toLines[insertAt - 1].trim() !== "") { + linesToInsert.push("") + } + } + toLines.splice(insertAt, 0, ...linesToInsert) + } else { + // append to end + if (toLines[toLines.length - 1] !== "") toLines.push("") + toLines.push(...cardLines) + } + if (toLines[toLines.length - 1] !== "") toLines.push("") + await writeFile(toRaw.path, toLines.join("\n")) + await commitFocus(`moveCard ${board}/${fromFile} → ${toFile}`) + return true +} + +export async function updateCardMeta(board: string, filename: string, cid: string, patch: { + text?: string; assignee?: string; priority?: string; due?: string +}): Promise<boolean> { + const raw = await readColumnRaw(board, filename) + if (!raw) return false + + const range = findCardRange(raw.body, cid) + if (!range) return false + + const lines = raw.body.split("\n") + const cardText = range.text + + // Update the card text line itself + if (patch.text !== undefined && patch.text !== cardText) { + const bm = lines[range.start].match(BULLET_CARD_RE) + if (bm) { + const ch = bm[2].toLowerCase() === "x" ? "x" : " " + lines[range.start] = `- [${ch}] ${patch.text}` + } + } + + // Update/add metadata sub-lines within the card's range + if (patch.assignee !== undefined || patch.priority !== undefined || patch.due !== undefined) { + const metaPatch: Record<string, string | undefined> = { + assignee: patch.assignee, + priority: patch.priority, + due: patch.due, + } + + // find existing meta lines and update them + const seen = new Set<string>() + for (let i = range.start + 1; i < range.end; i++) { + const mm = lines[i].match(META_RE) + if (!mm) continue + const k = mm[1].toLowerCase() + if (k in metaPatch) { + seen.add(k) + const v = metaPatch[k] + if (v !== undefined) { + lines[i] = lines[i].replace(/^(\s*)> .*$/, `$1> ${k}: ${v}`) + } else { + lines[i] = "" + } + } + } + + // add missing meta keys after card line + for (const [k, v] of Object.entries(metaPatch)) { + if (!seen.has(k) && v !== undefined) { + lines.splice(range.start + 1, 0, ` > ${k}: ${v}`) + } + } + } + + await writeFile(raw.path, lines.join("\n")) + await commitFocus(`updateCardMeta ${board}/${filename}`) + return true +} + +/** Replace the entire card block (bullet line + indented content) in a column file. */ +export async function updateCardBlock(board: string, filename: string, cid: string, newBlock: string): Promise<boolean> { + const raw = await readColumnRaw(board, filename) + if (!raw) return false + const range = findCardRange(raw.body, cid) + if (!range) return false + const lines = raw.body.split("\n") + // Also swallow trailing blank lines after the card block + let end = range.end + while (end < lines.length && lines[end].trim() === "") end++ + lines.splice(range.start, end - range.start, ...newBlock.split("\n")) + // Clean up excessive blanks + const newBody = lines.join("\n").replace(/\n{3,}/g, "\n\n") + await writeFile(raw.path, newBody) + await commitFocus(`updateCardBlock ${board}/${filename}`) + return true +} + +export async function assignDriverForCard( + board: string, + filename: string, + cid: string, + userId: string +): Promise<{ ok: boolean; loopId?: string }> { + const cols = await listKanbanColumns(board) + const col = cols.find((c) => c.filename === filename) + const card = col?.cards.find((c) => c.cid === cid) + if (!card || !card.loopId) return { ok: false } + + const updated = await patchLoopMeta(card.loopId, { driver: userId } as Partial<LoopMeta>) + if (!updated) return { ok: false } + + await updateCardMeta(board, filename, cid, { assignee: userId }) + return { ok: true, loopId: card.loopId } +} + +export async function linkLoopToCard( + board: string, + filename: string, + cid: string, + loopId: string, + userId: string +): Promise<boolean> { + const raw = await readColumnRaw(board, filename) + if (!raw) return false + + const range = findCardRange(raw.body, cid) + if (!range) return false + + const lines = raw.body.split("\n") + lines.splice(range.start + 1, 0, ` > loop: ${loopId}`) + await writeFile(raw.path, lines.join("\n")) + await commitFocus(`linkLoopToCard ${board}/${filename}`) + + await patchLoopMeta(loopId, { driver: userId } as Partial<LoopMeta>) + return true +} + +export async function createLoopFromCard( + board: string, + filename: string, + cid: string, + userId: string +): Promise<{ ok: boolean; loopId?: string }> { + const cols = await listKanbanColumns(board) + const col = cols.find((c) => c.filename === filename) + const card = col?.cards.find((c) => c.cid === cid) + if (!card) return { ok: false } + + const loop = await createLoop({ + title: card.text, + createdBy: userId, + // profiles: undefined → loop runs with base + personal CLAUDE.md only. + // Kanban-spawned loops can be promoted to specific profiles later by + // editing meta.config.profiles. (Was: sandbox: pickDefaultSandbox().) + }) + // Set driver to the creating user + await patchLoopMeta(loop.id, { driver: userId } as Partial<LoopMeta>) + + // add loop association as a meta line + const raw = await readColumnRaw(board, filename) + if (raw) { + const range = findCardRange(raw.body, cid) + if (range) { + const lines = raw.body.split("\n") + lines.splice(range.start + 1, 0, ` > loop: ${loop.id}`) + await writeFile(raw.path, lines.join("\n")) + await commitFocus(`createLoopFromCard ${board}/${filename}`) + } + } + + return { ok: true, loopId: loop.id } +} + +export async function createColumn(board: string, filename: string, title?: string): Promise<boolean> { + const safe = basename(filename) + if (!safe || safe.startsWith(".")) return false + const dir = boardDir(board) + await mkdir(dir, { recursive: true }) + const path = join(dir, safe) + const displayTitle = title || basename(safe, ".md") + await writeFile(path, `# ${displayTitle}\n\n`) + await commitFocus(`createColumn ${board}/${safe}`) + return true +} + +/** Persist card order within a column file. */ +export async function reorderCards(board: string, filename: string, orderedCids: string[]): Promise<boolean> { + const raw = await readColumnRaw(board, filename) + if (!raw) return false + + const lines = raw.body.split("\n") + + // Extract card blocks: each card is its bullet line + all indented sub-lines + interface CardBlock { cid: string; start: number; end: number; lines: string[] } + const blocks: CardBlock[] = [] + + for (let i = 0; i < lines.length; i++) { + const bm = lines[i].match(BULLET_CARD_RE) + if (!bm || bm[1] !== "") continue + const text = bm[3].trim() + const cid = hashCid(text) + + let end = i + 1 + while (end < lines.length) { + const sub = lines[end] + if (/^-\s*\[[ xX]\]/.test(sub) || /^#+\s/.test(sub)) break + end++ + } + + blocks.push({ + cid, + start: i, + end, + lines: lines.slice(i, end), + }) + } + + // Reorder blocks + const cidOrder = new Map(orderedCids.map((c, i) => [c, i])) + const notInOrder = blocks.filter((b) => !cidOrder.has(b.cid)) + const ordered = blocks + .filter((b) => cidOrder.has(b.cid)) + .sort((a, b) => (cidOrder.get(a.cid) ?? 0) - (cidOrder.get(b.cid) ?? 0)) + const reordered = [...ordered, ...notInOrder] + + // Find the content before first card and after last card + const firstCardIdx = blocks.reduce((min, b) => Math.min(min, b.start), Infinity) + const prefix = lines.slice(0, firstCardIdx) + + // Rebuild: prefix + reordered card blocks + const result = [...prefix] + for (const b of reordered) { + for (const l of b.lines) result.push(l) + } + + // Trim trailing blanks + while (result.length && result[result.length - 1].trim() === "") result.pop() + result.push("") + + await writeFile(raw.path, result.join("\n")) + await commitFocus(`reorderCards ${board}/${filename}`) + return true +} + +// ── column config (config.yaml per board) ── + +export type KanbanColumnConfig = { + file: string + color?: string +} + +export type KanbanConfig = { + columns: KanbanColumnConfig[] +} + +function configPath(board: string): string { + return join(boardDir(board), "config.yaml") +} + +export async function readKanbanConfig(board: string): Promise<KanbanConfig | null> { + try { + const raw = await readFile(configPath(board), "utf8") + return parseYaml(raw) + } catch { + return null + } +} + +async function writeKanbanConfig(board: string, cfg: KanbanConfig): Promise<void> { + await mkdir(boardDir(board), { recursive: true }) + const lines = ["columns:"] + for (const c of cfg.columns) { + lines.push(` - file: ${c.file}`) + if (c.color) lines.push(` color: "${c.color}"`) + } + await writeFile(configPath(board), lines.join("\n") + "\n") +} + +/** Minimal YAML parser for our simple config format. */ +function parseYaml(raw: string): KanbanConfig { + const cfg: KanbanConfig = { columns: [] } + let cur: Partial<KanbanColumnConfig> | null = null + for (const line of raw.split("\n")) { + const seq = line.match(/^\s*-\s+file:\s*(\S+)/) + if (seq) { + if (cur?.file) { cfg.columns.push({ file: cur.file, color: cur.color }) } + cur = { file: seq[1] } + continue + } + if (cur) { + const color = line.match(/^\s+color:\s*"?([^"]*)"?/) + if (color) { cur.color = color[1] || undefined } + } + } + if (cur?.file) cfg.columns.push({ file: cur.file, color: cur.color }) + return cfg +} + +/** Save column order to config. Creates/updates config.yaml. */ +export async function saveColumnOrder(board: string, orderedFiles: string[]): Promise<void> { + const existing = await readKanbanConfig(board) + const colorMap = new Map((existing?.columns ?? []).map((c) => [c.file, c.color])) + const columns: KanbanColumnConfig[] = orderedFiles.map((f) => { + const entry: KanbanColumnConfig = { file: f } + const color = colorMap.get(f) + if (color) entry.color = color + return entry + }) + await writeKanbanConfig(board, { columns }) + await commitFocus(`saveColumnOrder ${board}`) +} + +/** Update column color in config. */ +export async function setColumnColor(board: string, filename: string, color: string): Promise<void> { + const cfg = await readKanbanConfig(board) + const existing = cfg?.columns ?? [] + const found = existing.find((c) => c.file === filename) + if (found) { + found.color = color + } else { + existing.push({ file: filename, color }) + } + await writeKanbanConfig(board, { columns: existing }) + await commitFocus(`setColumnColor ${board}/${filename}`) +} + +/** Delete a column file. All cards in the column are lost (caller should archive first). */ +export async function deleteColumn(board: string, filename: string): Promise<boolean> { + const safe = basename(filename) + try { + await unlink(join(boardDir(board), safe)) + } catch { + return false + } + const cfg = await readKanbanConfig(board) + if (cfg) { + cfg.columns = cfg.columns.filter((c) => c.file !== safe) + await writeKanbanConfig(board, cfg) + } + await commitFocus(`deleteColumn ${board}/${safe}`) + return true +} + +/** Rename a column file on disk and update config if present. */ +export async function renameColumn(board: string, fromFile: string, toFile: string): Promise<boolean> { + const safeFrom = basename(fromFile) + const safeTo = basename(toFile) + if (!safeTo || safeTo.startsWith(".") || !safeTo.endsWith(".md")) return false + const dir = boardDir(board) + try { + await rename(join(dir, safeFrom), join(dir, safeTo)) + } catch { + return false + } + // update config if present + const cfg = await readKanbanConfig(board) + if (cfg) { + const found = cfg.columns.find((c) => c.file === safeFrom) + if (found) found.file = safeTo + await writeKanbanConfig(board, cfg) + } + await commitFocus(`renameColumn ${board}/${safeFrom} → ${safeTo}`) + return true +} diff --git a/server/src/loop-stats.ts b/server/src/loop-stats.ts new file mode 100644 index 00000000..9380a4d4 --- /dev/null +++ b/server/src/loop-stats.ts @@ -0,0 +1,226 @@ +/** + * Compute a preview of what a loop with given profiles will contain: + * plugin count, skill count, agent count, hook count, MCP server count. + * + * Aggregates from all sources that will be merged at spawn time: + * - team: .loopat/.claude/{settings.json, skills/, agents/} + * - profile: .loopat/profiles/<name>/.claude/{settings.json, skills/, agents/} + * - personal (skipped — per-user override; not included in pre-create preview) + * + * For each enabled plugin, also scans the plugin's source dir (host CC cache + * OR local marketplace source) to count skills/agents/MCPs/hooks contributed. + * + * Result is deduplicated by name (skill "foo" from team + profile counts once). + */ +import { existsSync, readdirSync, statSync } from "node:fs" +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import { parse as parseToml } from "smol-toml" +import { + personalKnowledgeTeamClaudeDir, + personalKnowledgeProfileClaudeDir, +} from "./paths" +import { lookupPluginInstallPath } from "./plugin-installer" + +export type LoopStats = { + plugins: number + skills: number + agents: number + hooks: number + mcpServers: number + /** Toolchain tools (mise.toml [tools] entries) declared across all + * sources, deduped by tool key. */ + toolchain: number +} + +/** + * Count tools declared in a .claude/mise.toml. Each top-level key under + * [tools] is one tool (bare like `python = "3.12"` or nested like + * `[tools."http:a1"]`). Missing file or malformed toml → 0. + * + * Exported so listProfilesRich() / getTiers() can reuse the same parse. + */ +export function countToolchainTools(claudeDir: string): string[] { + const p = join(claudeDir, "mise.toml") + if (!existsSync(p)) return [] + try { + const raw = require("node:fs").readFileSync(p, "utf8") as string + const parsed = parseToml(raw) as { tools?: Record<string, unknown> } + return Object.keys(parsed.tools ?? {}) + } catch { + return [] + } +} + +type Settings = { + enabledPlugins?: Record<string, boolean> + extraKnownMarketplaces?: Record<string, { source?: any }> + mcpServers?: Record<string, any> + hooks?: Record<string, any> | any[] +} + +/** Read settings.json from a .claude/ dir. */ +async function readSettings(claudeDir: string): Promise<Settings | null> { + const p = join(claudeDir, "settings.json") + if (!existsSync(p)) return null + try { + return JSON.parse(await readFile(p, "utf8")) as Settings + } catch { + return null + } +} + +/** Count entries in a dir (skipping dotfiles + non-matching). */ +function countDirEntries(dir: string, opts?: { suffix?: string; mustBeDir?: boolean }): string[] { + if (!existsSync(dir)) return [] + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return [] + } + return entries.filter((name) => { + if (name.startsWith(".")) return false + if (opts?.suffix && !name.endsWith(opts.suffix)) return false + if (opts?.mustBeDir) { + try { + return statSync(join(dir, name)).isDirectory() + } catch { + return false + } + } + return true + }) +} + +/** Count entries in settings.json hooks field (can be array or object). */ +function countHooks(s: Settings | null): number { + if (!s?.hooks) return 0 + if (Array.isArray(s.hooks)) return s.hooks.length + if (typeof s.hooks === "object") { + let n = 0 + for (const v of Object.values(s.hooks)) { + if (Array.isArray(v)) n += v.length + else if (v) n++ + } + return n + } + return 0 +} + +/** + * Scan a plugin's directory for skills, agents, hooks, mcpServers. + * Returns sets of names (so callers can dedupe across plugins/sources). + */ +async function scanPlugin(pluginDir: string): Promise<{ + skills: string[] + agents: string[] + hooks: number + mcpServers: string[] +}> { + const skills = countDirEntries(join(pluginDir, "skills"), { mustBeDir: true }) + const agents = countDirEntries(join(pluginDir, "agents"), { suffix: ".md" }) + .map((n) => n.replace(/\.md$/, "")) + + let hooks = 0 + let mcpServers: string[] = [] + + // Plugin-shipped mcpServers come from the plugin's settings.json mcpServers + // field (loopat doesn't read `.mcp.json` — that file format is deprecated + // here in favor of a single unified settings.json across all tiers). + const settingsPath = join(pluginDir, "settings.json") + if (existsSync(settingsPath)) { + try { + const j = JSON.parse(await readFile(settingsPath, "utf8")) + mcpServers = Object.keys(j?.mcpServers ?? {}) + } catch {} + } + + // Plugin hooks/ dir or hooks.json + const hooksJson = join(pluginDir, "hooks", "hooks.json") + if (existsSync(hooksJson)) { + try { + const j = JSON.parse(await readFile(hooksJson, "utf8")) + if (Array.isArray(j?.hooks)) hooks = j.hooks.length + else if (typeof j?.hooks === "object") { + for (const v of Object.values(j.hooks)) { + if (Array.isArray(v)) hooks += v.length + } + } + } catch {} + } + + return { skills, agents, hooks, mcpServers } +} + +/** + * Main entry: compute the totals for a hypothetical loop with the given + * non-base profiles (team is always implicit). Returns deduped counts. + */ +export async function computeLoopStats(user: string, profiles: string[]): Promise<LoopStats> { + // Collect all source .claude/ dirs to scan — from the user's PER-USER + // knowledge repo (matching resolveLoopPlan / listProfiles). + const sources: Array<{ source: string; dir: string }> = [] + const teamDir = personalKnowledgeTeamClaudeDir(user) + if (existsSync(teamDir)) sources.push({ source: "team", dir: teamDir }) + for (const p of profiles) { + const d = personalKnowledgeProfileClaudeDir(user, p) + if (existsSync(d)) sources.push({ source: `profile:${p}`, dir: d }) + } + + // Sets to dedupe across sources + const enabledPluginSet = new Set<string>() + const skillSet = new Set<string>() + const agentSet = new Set<string>() + const mcpServerSet = new Set<string>() + const toolchainSet = new Set<string>() + let hookCount = 0 + + for (const s of sources) { + const settings = await readSettings(s.dir) + if (settings?.enabledPlugins) { + for (const [k, v] of Object.entries(settings.enabledPlugins)) { + if (v) enabledPluginSet.add(k) + } + } + if (settings?.mcpServers) { + for (const k of Object.keys(settings.mcpServers)) mcpServerSet.add(k) + } + hookCount += countHooks(settings) + + // Loose skills + agents at the source level (not from plugins) + for (const name of countDirEntries(join(s.dir, "skills"), { mustBeDir: true })) { + skillSet.add(name) + } + for (const name of countDirEntries(join(s.dir, "agents"), { suffix: ".md" })) { + agentSet.add(name.replace(/\.md$/, "")) + } + // Toolchain tools from this tier's mise.toml (last-wins semantics for mise + // overrides happen at compose time; for the preview "how many distinct + // tools will end up in PATH", we dedupe by key — which matches the merged + // toolchain since later tiers overwrite same-keyed entries). + for (const tool of countToolchainTools(s.dir)) { + toolchainSet.add(tool) + } + } + + // Now scan each enabled plugin for its contributions + for (const spec of enabledPluginSet) { + const pluginDir = await lookupPluginInstallPath(spec) + if (!pluginDir) continue + const scan = await scanPlugin(pluginDir) + for (const s of scan.skills) skillSet.add(`${spec.split("@")[0]}:${s}`) + for (const a of scan.agents) agentSet.add(`${spec.split("@")[0]}:${a}`) + for (const m of scan.mcpServers) mcpServerSet.add(m) + hookCount += scan.hooks + } + + return { + plugins: enabledPluginSet.size, + skills: skillSet.size, + agents: agentSet.size, + hooks: hookCount, + mcpServers: mcpServerSet.size, + toolchain: toolchainSet.size, + } +} diff --git a/server/src/loop-status.ts b/server/src/loop-status.ts new file mode 100644 index 00000000..cec34f20 --- /dev/null +++ b/server/src/loop-status.ts @@ -0,0 +1,105 @@ +import { join } from "node:path" +import { existsSync, readFileSync, writeFileSync, watch } from "node:fs" +import { LOOPAT_HOME } from "./paths" + +export type LoopStatusEntry = { + status: string + updated: string + viewed?: boolean + /** + * Runtime-readiness phase, set around ensureContainer (term.ts / session.ts): + * - "preparing": the per-loop sandbox image is being built (mise toolchain + * install / base-image pull). Terminal + chat are not yet usable — the UI + * shows a blocking "installing tools" overlay so the user doesn't type into + * a shell that isn't there or fire a chat turn that just queues. + * - "ready": the container is up; normal use. + * Absent on loops that never needed a build (image already cached). + */ + phase?: "preparing" | "ready" +} +export type LoopStatusMap = Record<string, LoopStatusEntry> + +const STATUS_FILE = join(LOOPAT_HOME, "loop-status.json") +let cache: LoopStatusMap = {} +const watchers = new Set<(curr: LoopStatusMap, prev: LoopStatusMap) => void>() + +if (existsSync(STATUS_FILE)) { + try { cache = JSON.parse(readFileSync(STATUS_FILE, "utf8")) } catch {} +} + +function save() { + try { + writeFileSync(STATUS_FILE, JSON.stringify(cache, null, 2)) + } catch (e) { + console.error("[loop-status] Failed to write file:", e) + } +} + +export function updateLoopStatus(loopId: string, status: string) { + const prev = { ...cache } + // Build a NEW entry object rather than mutating the existing one in place: + // `prev` is a shallow copy, so prev[loopId] aliases the live cache entry. + // Mutating it would also change prev[loopId].updated, making the WS hub's + // `curr.updated !== prev.updated` diff a no-op → connected clients never get + // the update (only fresh subscribers, which read lastSnapshot). Preserve + // phase + viewed via spread. + const old = cache[loopId] || { status: "", updated: "", viewed: false } + const entry = { ...old, status, updated: new Date().toISOString() } + if (status === "Done") { + entry.viewed = false + } + cache[loopId] = entry + save() + + // Immediately notify watchers without waiting for file system event + for (const fn of watchers) { + fn(cache, prev) + } +} + +/** + * Set a loop's runtime-readiness phase (see LoopStatusEntry.phase). Bumps + * `updated` so the loop-status WS hub broadcasts the change to subscribers. + * Kept separate from updateLoopStatus so the human-readable build-progress + * string (used by the kanban + the overlay) and the gate flag move independently. + */ +export function setLoopPhase(loopId: string, phase: "preparing" | "ready") { + const prev = { ...cache } + // New object, not in-place mutation — see updateLoopStatus for why (the + // prev-aliasing that silently drops the WS broadcast). + const old = cache[loopId] || { status: "", updated: "", viewed: false } + const entry = { ...old, phase, updated: new Date().toISOString() } + cache[loopId] = entry + save() + for (const fn of watchers) { + fn(cache, prev) + } +} + +export function markLoopViewed(loopId: string) { + if (cache[loopId]) { + cache[loopId].viewed = true + save() + } +} + +export function getLoopStatus(): LoopStatusMap { + return cache +} + +export function watchStatusFile(fn: (curr: LoopStatusMap, prev: LoopStatusMap) => void) { + watchers.add(fn) + let prev = { ...cache } + try { + watch(STATUS_FILE, (eventType) => { + if (eventType === "change") { + try { + const raw = readFileSync(STATUS_FILE, "utf8") + const curr = JSON.parse(raw) + fn(curr, prev) + prev = curr + } catch {} + } + }) + } catch {} +} diff --git a/server/src/loops.ts b/server/src/loops.ts new file mode 100644 index 00000000..5c2ff92e --- /dev/null +++ b/server/src/loops.ts @@ -0,0 +1,2367 @@ +import { chmod, copyFile, mkdir, mkdtemp, readdir, readFile, rename, 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, chmodSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { + loopsDir, + loopDir, + loopWorkdir, + loopClaudeDir, + loopContextDir, + loopContextKnowledge, + loopContextNotes, + loopContextPersonal, + loopContextRepos, + loopMetaPath, + workspaceDir, + workspaceKnowledgeDir, + workspaceNotesDir, + workspaceReposDir, + workspaceRepoDir, + workspaceOriginsDir, + workspaceOriginPath, + personalDir, + personalKnowledgeDir, + personalNotesDir, + personalReposDir, + personalRepoDir, + personalRepoCacheDir, + personalRepoCacheRoot, + personalVaultDir, + uiNotesDir, + personalMemoryDir, + workspaceMemoryDir, + hostDeployKeyPath, + personalGitCryptKeyPath, + loopHistoryPath, + loopChatHistoryPath, + loopKindClaudePath, +} from "./paths" +import type { RepoSpec } from "./config" +import { existsSync as existsSyncBase } from "node:fs" +import { loadConfig, loadPersonalConfig, loadKnowledgeConfig, writeVaultEnv } from "./config" +import { ensurePersonalKeypair } from "./personal-keys" +import { composeLoopClaudeConfig, writeLoopSettings } from "./compose" +import { getProvider, type OnboardingView } from "./git-host" +import { loadExtensionProviders, resolveProviderId, resolveProvider } from "./providers" // also registers built-in providers +import { loadVaultEnvs } from "./vaults" + +const execFileP = promisify(execFile) + +// Serialize async work by key. A loop's create + attach + close all touch the +// same per-user context dirs and worktrees nearly simultaneously; without this +// their git clone / worktree-add race and trip "destination already exists" / +// "'ui/<user>' is already checked out". Calls with the same key run strictly in +// sequence (a later caller sees the dir/worktree the earlier one created and +// takes the idempotent fast-path instead of re-cloning). Keys are bounded (per +// user, per worktree path), so the map doesn't grow unbounded. +const _keyedLocks = new Map<string, Promise<unknown>>() +function runExclusive<T>(key: string, fn: () => Promise<T>): Promise<T> { + const prev = _keyedLocks.get(key) ?? Promise.resolve() + const next = prev.then(fn, fn) // run fn regardless of the previous call's outcome + _keyedLocks.set(key, next.then(() => {}, () => {})) + return next +} + +export type LoopMeta = { + id: string + title: string + createdAt: string + createdBy: string + /** + * Active driver. New loops set this to `createdBy` on creation. Legacy + * loops created before drivers existed may omit it; callers should use + * `effectiveDriver()` rather than reading this field directly. + * + * The driver is the user whose personal config (apiKey, vault, env) the + * sandbox runs under, and the only user permitted to write (send messages, + * change provider, write terminal, etc.). Non-driver users are read-only — + * same set of writes blocked by `archived`. See request-for-drive flow. + */ + driver?: string + /** + * Chronological log of driver assignments. First entry is creation time + * (driver = createdBy). Each subsequent entry is a successful handoff via + * POST /api/loops/:id/drive. Used by the chat UI to splice "driving by X + * since <ts>" markers into the message timeline. Legacy loops may omit + * this; on the next handoff a fresh history starts from there. + */ + driverHistory?: Array<{ driver: string; since: string }> + /** + * RFD ("Request For Drive") state. When set, the current driver has + * released control: the sandbox is torn down, and any authenticated user + * may take over via POST /api/loops/:id/drive. Cleared when someone drives. + */ + rfdRequestedAt?: string + rfdRequestedBy?: string + /** + * One-shot flag written by POST /api/loops/:id/drive, consumed by the next + * sendUserText. While set, the next user message is prefixed with a + * handoff preamble so the model knows the user it's talking to has just + * changed. Cleared atomically when consumed. + */ + pendingDriverNote?: { from: string; to: string; at: string } + repo?: string + branch?: string + /** + * Context-setup problems captured at loop creation (e.g. the per-user + * knowledge/notes clone failed — bad/again-missing key, no access). Surfaced + * as a banner in the loop UI so the user isn't left with a silently-empty + * context. Empty/absent = context set up cleanly. + */ + contextWarnings?: string[] + config?: { + default_model?: string + default_model_source?: "personal" | "workspace" + default_model_id?: string + permission_mode?: string + /** + * Active profiles for this loop (post-2026-05 composition model). + * Profiles live in `<LOOPAT_HOME>/context/profiles/<name>/`; each has a + * profile.json (lists plugin specs) + sibling CLAUDE.md + optional + * knowledge/. On spawn, loopat orchestrates `claude plugin install` for + * the union of plugins, concats CLAUDE.mds, mounts knowledge. + * + * Order matters: CLAUDE.md fragments concat in declared order (later + * shadows earlier). "base" profile is always implicit if present, even + * when this list is empty. Personal CLAUDE.md appends last. + * + * Empty / undefined = no profile-driven plugins, base CLAUDE.md only + * (if it exists), personal CLAUDE.md only. CC still runs. + * + * See docs/composition.md. + */ + profiles?: string[] + /** + * Vault selected for this loop. The named vault under + * `personal/<user>/.loopat/vaults/<vault>/` provides this loop's + * credentials at runtime. Default: "default". The act of choosing here + * is the security boundary — other vaults are not exposed inside the + * sandbox. Set to null only by very old loops created before vaults + * existed; bwrap treats absent/null as "default" for backward compat. + */ + vault?: string + /** + * If true, /loopat/context/knowledge/ is bound rw instead of ro. Set + * for loops that exist to distill notes into knowledge. + */ + knowledge_rw?: boolean + /** + * Admin-only flag: bind the entire LOOPAT_HOME/loops/ tree read-only + * at /loopat/loops/ so this loop can read every other loop's chat + * history, workdir, meta, etc. — for cross-loop distill. Granted only + * to admins at create time; cannot be toggled later. + * + * Privacy note: this exposes other users' chats and workdirs to the + * driver of this loop. Don't ship a UI that lets non-admins flip it. + */ + mount_all_loops?: boolean + /** Session-scoped goal set via /goal. Displayed in UI and injected into the system prompt. */ + goal?: string + goalSetAt?: string + goalStatus?: "active" | "completed" + } + /** + * Archive = "hide + read-only". Hidden from default list, all writes + * (sendUserText / clear / setProvider / writeTerm / answerQuestions / + * vault writes) reject. Reads stay open (attach, history, files, term + * view). Lossless — `unarchive` flips back. See docs/design notes. + */ + archived?: boolean + archivedAt?: string + /** + * Free-form key/value metadata attached by the caller of the v1 Loop API. + * Not interpreted by loopat; not exposed to the sandbox. Used by external + * integrations (e.g. a bot framework storing "slack_thread: C123:1234"). + * Capped at 16 KB JSON-serialized. + */ + metadata?: Record<string, unknown> + /** + * If true, this loop's chat (and only the chat) is readable by anonymous + * visitors at `/share/:id`. Everything else (workspace, files, kanban, ...) + * still requires auth. Only the loop's `createdBy` may toggle it. + */ + public?: boolean + publicAt?: string + /** + * Workspace serve config. When shareEnabled, the loop's workdir is accessible + * via one of three modes: + * + * - "static" — serve container streams workdir files via subdomain + * - "port" — serve container HTTP-proxies to sharePort via subdomain + * - "direct" — port-proxy container TCP/UDP-relays a fixed external + * port (shareExternalPort) to sharePort + * - "ephemeral" — the loop container itself publishes sharePort via + * `-p :<sharePort>`, kernel-assigned host port that + * changes on every container restart. No port-proxy. + * Read the current host port via `podman port`. + */ + shareEnabled?: boolean + shareMode?: "static" | "port" | "ephemeral" + shareAlias?: string + sharePort?: number + /** External port for direct TCP/UDP access (see port-proxy). */ + shareExternalPort?: number + /** Protocol for shareExternalPort: "tcp" (default), "udp", or "static". */ + shareProtocol?: "tcp" | "udp" | "static" + /** + * Set when the loop was spawned from a chat conversation. The snapshot of + * the chat history is at loops/<id>/context/chat/<convId>.jsonl (mounted as + * /loopat/context/chat/<convId>.jsonl inside the sandbox). + */ + seededFrom?: { + kind: "chat" + convId: string + messageCount: number + snapshotAt: string + } + /** + * Last metadata received from the external runtime gateway. Written by + * `recordExternalMeta` on each turn so the UI / admin can see which + * external platform and user this loop serves. Only present on loops + * created via the gateway SSE API. + */ + lastExternalMeta?: { + source: string | null + userId: string | null + metadata: Record<string, unknown> | null + traceId: string | null + at: string + } +} + +const PERSONAL_MEMORY_INDEX_STUB = `# Personal memory index + +Each line points at a memory file in this directory. Maintained by Claude. + +` + +const TEAM_MEMORY_INDEX_STUB = `# Team memory index + +Cross-loop, cross-user memory shared via the notes git repo. One line per entry. +Promote here only when the insight is workspace-wide (a convention, an +operational fact, a non-obvious gotcha). Routine observations belong in +\`/loopat/context/personal/memory/\` instead. + +` + +/** + * Who is currently driving this loop — `meta.driver` if set, else the + * creator. Use this everywhere "whose credentials/permissions" matters. + * Reserve direct `meta.createdBy` reads for "who owns this loop forever" + * (archive, public toggle). + */ +export function effectiveDriver(meta: { createdBy: string; driver?: string }): string { + return meta.driver ?? meta.createdBy +} + +export function isDriver(meta: { createdBy: string; driver?: string }, userId: string): boolean { + return effectiveDriver(meta) === userId +} + +/** + * Derive the ephemeral `-p` set to pass into the loop's container at create + * time. Returns an empty list unless the loop is in "ephemeral" share mode + * with a valid internal port. Static mode and the legacy "port"/"direct" + * modes don't touch the loop container's own port mappings (they go via + * the serve / port-proxy containers instead). + */ +export function loopEphemeralPorts( + meta: Pick<LoopMeta, "shareEnabled" | "shareMode" | "sharePort" | "shareProtocol">, +): { internalPort: number; protocol?: "tcp" | "udp" }[] { + if (!meta.shareEnabled || meta.shareMode !== "ephemeral" || !meta.sharePort) return [] + const proto = meta.shareProtocol === "udp" ? "udp" : "tcp" + return [{ internalPort: meta.sharePort, protocol: proto }] +} + +async function gitInitIfMissing(dir: string) { + if (existsSyncBase(join(dir, ".git"))) return + try { + await execFileP("git", ["-C", dir, "init", "-q", "-b", "main"]) + } catch (e: any) { + console.warn(`[loopat] git init failed for ${dir}: ${e?.message ?? e}`) + } +} + +async function isEmptyOrMissing(dir: string): Promise<boolean> { + if (!existsSyncBase(dir)) return true + try { + const names = await readdir(dir) + return names.length === 0 + } catch { + return true + } +} + +/** + * Materialize a context repo (knowledge / notes) with an `origin` to pull/push. + * Remote backend: clone the configured git url. Local backend (no url): loopat + * hosts the remote itself — a bare repo at origins/<name>.git becomes `origin` + * (docs/context-flow.md "solo"). Either way the working dir ends up a git repo + * with `origin` set, so the symmetric pull/push model just works. + */ +async function ensureContextRepo(dir: string, name: string, url?: string): Promise<void> { + if (url && (await isEmptyOrMissing(dir))) { + try { + try { await rm(dir, { recursive: true, force: true }) } catch {} + await mkdir(join(dir, ".."), { recursive: true }) + await execFileP("git", ["clone", "--", url, dir]) + console.log(`[loopat] cloned ${url} → ${dir}`) + return + } catch (e: any) { + // The WORKSPACE-DEFAULT context clone uses the host's bare ssh — it has + // no per-user vault key, so a private ssh:// URL here is EXPECTED to fail + // `Permission denied (publickey)`. This is a bootstrap display mirror only + // (loops use the per-user path with the vault key, see ensureUserContext), + // so we fall back to a local origin. Log at info, and DON'T echo the raw + // "Permission denied (publickey)" — it gets mistaken for a loop-auth bug. + console.log(`[loopat] workspace-default ${name} not cloned over ssh (no host credential for ${url}) — using local origin (loops use the per-user vault key, unaffected)`) + } + } + // Local backend: loopat-hosted bare origin. + const bare = workspaceOriginPath(name) + if (!existsSyncBase(join(bare, "HEAD"))) { + await mkdir(workspaceOriginsDir(), { recursive: true }) + try { + await execFileP("git", ["init", "--bare", "-b", "main", bare]) + } catch (e: any) { + console.warn(`[loopat] bare init failed (${bare}): ${e?.message ?? e}`) + } + } + if (await isEmptyOrMissing(dir)) { + try { await rm(dir, { recursive: true, force: true }) } catch {} + await mkdir(join(dir, ".."), { recursive: true }) + try { + await execFileP("git", ["clone", "--", bare, dir]) + } catch { + await mkdir(dir, { recursive: true }) + await execFileP("git", ["-C", dir, "init", "-q", "-b", "main"]).catch(() => {}) + await execFileP("git", ["-C", dir, "remote", "add", "origin", bare]).catch(() => {}) + } + } else if (existsSyncBase(join(dir, ".git"))) { + const hasOrigin = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false) + if (!hasOrigin) await execFileP("git", ["-C", dir, "remote", "add", "origin", bare]).catch(() => {}) + } else { + // non-empty dir that isn't a git repo yet (e.g. a freshly-scaffolded + // personal/) → init in place and point it at the local bare origin. + await execFileP("git", ["-C", dir, "init", "-q", "-b", "main"]).catch(() => {}) + await execFileP("git", ["-C", dir, "remote", "add", "origin", bare]).catch(() => {}) + } +} + +/** + * The personal repo is self-describing: its `.loopat/config.json` declares the + * authoritative kn/notes remotes, and a loop connects to them with the user's + * OWN key from the selected vault (`vaults/<vault>/mounts/home/.ssh/id_ed25519`), not + * the host's ssh. Called at loop creation, which has the user + vault in hand. + * + * The startup clone (driven by host config.json) stays as a display mirror; + * here the personal-declared url wins and becomes the context repo's origin. + * + * It sets the origin, fetches with the vault key (host-side), AND persists a + * `core.sshCommand` pointing at the vault key's SANDBOX path so the AI's + * promote (git push from inside the sandbox) authenticates as the user with no + * interactive host-key prompt. Host-side git overrides that config via + * GIT_SSH_COMMAND (env beats config), so the sandbox path is never used here. + */ +export function ensureUserContext(user: string, vault: string = "default"): Promise<string[]> { + // Serialize per (user, vault) so concurrent loop create/attach/close don't + // race on the same clone targets (see runExclusive). + return runExclusive(`ctx:${user}:${vault}`, () => _ensureUserContext(user, vault)) +} +async function _ensureUserContext(user: string, vault: string): Promise<string[]> { + const errors: string[] = [] + const cfg = await loadPersonalConfig(user, vault) + const sshEnv = { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user, vault) } + // Clone-or-sync a PER-USER context main repo from `url` with the vault key. + // STRICT, per the context model: personal wins even when empty — an empty url + // means the dir is REMOVED so the loop sees nothing (no fallback to any + // workspace default). Returns whether the repo exists afterwards. + const ensurePerUserRepo = async (dir: string, url: string | undefined, label: string): Promise<boolean> => { + if (!url) { + try { await rm(dir, { recursive: true, force: true }) } catch {} + return false + } + if (existsSyncBase(join(dir, ".git"))) { + const has = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false) + await execFileP("git", ["-C", dir, "remote", has ? "set-url" : "add", "origin", url]).catch(() => {}) + } else { + try { await rm(dir, { recursive: true, force: true }) } catch {} + await mkdir(join(dir, ".."), { recursive: true }) + try { + await execFileP("git", ["clone", "--", url, dir], { env: sshEnv, timeout: 60_000 }) + console.log(`[loopat] cloned per-user context ${url} → ${dir}`) + } catch (e: any) { + // Concise reason for the UI warning: prefer the meaningful failure line + // (e.g. "Permission denied (publickey)") over git's trailing boilerplate + // ("…and the repository exists."), which reads as a false all-clear. + const lines = (e?.stderr ?? e?.message ?? String(e)).toString().split("\n").map((s: string) => s.trim()).filter(Boolean) + const reason = lines.find((l: string) => /permission denied|fatal:|not found|could not read|access denied|authentication failed|host key/i.test(l)) ?? lines.pop() ?? "clone failed" + console.warn(`[loopat] per-user context clone failed (${url}): ${e?.stderr ?? e?.message ?? e}`) + errors.push(`${label}: couldn't clone ${url} — ${reason}`) + return false + } + } + // No core.sshCommand override: inside the sandbox the vault's .ssh is mounted + // at $HOME/.ssh, so the AI's `git push` resolves the standard-named key + + // config on its own (follow ssh standard, don't special-case). Host-side ops + // here use GIT_SSH_COMMAND (sshEnv). + await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env: sshEnv, timeout: 30_000 }).catch(() => {}) + return true + } + // knowledge is the entry pointer (personal-declared url); clone it first, then + // read ITS .loopat/config.json for the notes remote. The repo roster is + // personal (cfg.repos), no longer inside the knowledge repo. + const hasKnowledge = await ensurePerUserRepo(personalKnowledgeDir(user), cfg.knowledge?.git, "knowledge") + const kcfg = hasKnowledge ? await loadKnowledgeConfig(user) : { notes: undefined } + await ensurePerUserRepo(personalNotesDir(user), kcfg.notes?.git, "notes") + await writeReposManifest(personalReposDir(user), cfg.repos ?? []) + return errors +} + +/** + * Repos are clone-on-demand — they can be large, so we don't pre-clone the + * whole set. Instead write a manifest (REPOS.md) listing the full roster, and + * clone a repo only when it's actually needed. Per docs/context-flow.md the AI + * can also clone any listed repo by hand into context/repos/<name>. + */ +async function writeReposManifest(reposDir: string, specs: RepoSpec[]) { + await mkdir(reposDir, { recursive: true }) + const body = [ + "# repos — clone on demand", + "", + "Full roster below. Only already-cloned repos exist as subdirectories;", + "clone any other on demand: `git clone <git> /loopat/context/repos/<name>`.", + "", + ...specs.filter((r) => r?.name && r?.git).map((r) => `- **${r.name}** — \`${r.git}\``), + "", + ].join("\n") + await writeFile(join(reposDir, "REPOS.md"), body) +} + +/** + * Clone a single registered repo if it isn't present yet. Returns whether the + * repo dir exists afterwards. Used by loop creation and any on-demand path. + */ +async function ensureRepoMirror(user: string, name: string, sshCommand?: string): Promise<string | null> { + // The roster lives in the user's OWN personal config (per-user, no fallback). + const pcfg = await loadPersonalConfig(user) + const spec = pcfg.repos?.find((r) => r.name === name) + if (!spec?.git) return null + const dir = personalRepoCacheDir(user, name) + const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env + // Pin the STANDARD fetch refspec (default branch → refs/remotes/origin/<def>) + // so worktrees off this mirror get an ordinary `origin/<def>` tracking ref — + // `git rebase origin/<def>`, `git status` ahead/behind, `git log origin/<def>` + // all work as in any normal clone. Self-healing: re-assert it on EVERY ensure + // (incl. caches pinned by the old non-standard `+…:refs/heads/<def>` refspec), + // then fetch so the standard ref materializes. + const assertStandardRefspec = async () => { + const def = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"]) + .then((r) => r.stdout.trim()).catch(() => "") + if (def) { + await execFileP("git", ["-C", dir, "config", "remote.origin.fetch", `+refs/heads/${def}:refs/remotes/origin/${def}`]).catch(() => {}) + } + } + // Already mirrored → re-assert the standard refspec, then fetch (incremental, + // fast). HEAD presence == bare repo. + if (existsSyncBase(join(dir, "HEAD"))) { + await assertStandardRefspec() + await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env, timeout: 60_000 }).catch(() => {}) + return dir + } + try { + await mkdir(personalRepoCacheRoot(user), { recursive: true }) + // Bare mirror (depth 1): smallest transfer, no working tree. Loop workdirs + // are `git worktree add`'d off this; their pushes go straight to origin; this + // mirror only ever fetches. So the FIRST loop on a repo clones once (seconds + // with depth=1) and every later loop is just fetch + worktree (instant). + // --depth=1 implies --single-branch, so this mirrors ONLY the default branch + // at depth 1 (small + fast: ~tens of MB, not the whole history of every branch). + try { + await execFileP("git", ["clone", "--bare", "--depth=1", "--", spec.git, dir], { env, timeout: 180_000 }) + } catch { + // Fall back to a non-shallow bare clone if depth=1 is rejected. + try { await rm(dir, { recursive: true, force: true }) } catch {} + await execFileP("git", ["clone", "--bare", "--single-branch", "--", spec.git, dir], { env, timeout: 300_000 }) + } + // A bare clone sets NO fetch refspec, so `git fetch origin` wouldn't advance + // any ref. Pin the STANDARD one for JUST the default branch + // (+refs/heads/<def>:refs/remotes/origin/<def>) so worktrees off this mirror + // get an ordinary `origin/<def>` tracking ref — and the mirror stays small. + await assertStandardRefspec() + // Materialize refs/remotes/origin/<def> now (the clone only wrote + // refs/heads/<def>), so the very first worktree already tracks origin. + await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env, timeout: 60_000 }).catch(() => {}) + console.log(`[loopat] mirrored ${spec.git} → ${dir}`) + return dir + } catch (e: any) { + console.warn(`[loopat] repo mirror failed (${spec.git}): ${e?.stderr ?? e?.message ?? e}`) + return null + } +} + +export async function ensureWorkspaceDirs() { + await mkdir(workspaceDir(), { recursive: true }) + await mkdir(loopsDir(), { recursive: true }) + await mkdir(workspaceReposDir(), { recursive: true }) + + // WORKSPACE-DEFAULT clone (bootstrap display + seed source only — loops use the + // per-user knowledge/notes, see ensureUserContext). knowledge is the entry + // pointer; clone it, then read its .loopat/config.json for notes + repo roster. + const cfg = await loadConfig() + await ensureContextRepo(workspaceKnowledgeDir(), "knowledge", cfg.knowledge?.git || undefined) + const kcfg = await loadKnowledgeConfig() + await ensureContextRepo(workspaceNotesDir(), "notes", kcfg.notes?.git || undefined) + // The repo roster is per-user (PersonalConfig.repos); there is no + // workspace-default roster, so the workspace repos manifest is empty. + await writeReposManifest(workspaceReposDir(), []) + + // workspace memory dir + stub + const tm = workspaceMemoryDir() + await mkdir(tm, { recursive: true }) + const tmIdx = `${tm}/MEMORY.md` + if (!existsSyncBase(tmIdx)) await writeFile(tmIdx, TEAM_MEMORY_INDEX_STUB) + + // knowledge / notes are already git repos with `origin` (ensureContextRepo). + +} + +/** + * Provision a freshly-registered user's personal/ tree. NEVER clones the + * user's remote repo here — the server has no credentials for private repos + * at register time. We: + * 1. mkdir + `git init` an empty personal/<user>/ + * 2. seed `memory/MEMORY.md` so SDK auto-recall sees something + * 3. generate a loopat-managed ed25519 keypair under + * `host-secrets/<user>/deploy-key` (deploy-key flow, host-only) + * + * If `personalRepo` was given at register, the user goes through a separate + * confirm step (see `importPersonalFromRepo`) AFTER they paste the public key + * as a deploy key on the remote. + * + * Returns the public key so the UI can show it. + */ +export async function provisionUserPersonal(userId: string): Promise<{ publicKey: string | null }> { + const dir = personalDir(userId) + await mkdir(dir, { recursive: true }) + + const pm = personalMemoryDir(userId) + await mkdir(pm, { recursive: true }) + const pmIdx = `${pm}/MEMORY.md` + if (!existsSyncBase(pmIdx)) await writeFile(pmIdx, PERSONAL_MEMORY_INDEX_STUB) + + // personal gets a loopat-hosted bare origin too (local backend), so its + // promote is `push origin` like every other context repo. A later import of + // the user's own remote repo (importPersonalFromRepo) replaces this origin. + await ensureContextRepo(dir, `personal-${userId}`, undefined) + + const { publicKey } = await ensurePersonalKeypair(userId) + return { publicKey } +} + +/** + * Provider-agnostic personal onboarding (docs/identity.md integration contract). + * Uses a host-side credential (token, never enters a sandbox) via the selected + * GitHostProvider to create the personal repo + register the deploy key, then + * reuses importPersonalFromRepo to clone + handle git-crypt (empty repo → + * auto-init and return the generated key; existing → needsCryptKey). + * + * The token only *sets things up*; runtime git uses the deploy key / vault. + * `provider` selects the git platform (default "github"); add platforms by + * implementing GitHostProvider (see git-host.ts / providers.ts). + */ +export async function setupPersonalViaProvider(opts: { + userId: string + provider?: string + token: string + baseUrl?: string + repoName: string + cryptKey?: string +}): Promise< + | { ok: true; repo: string; repoUrl: string; created: boolean; autoInitialized?: boolean; cryptKey?: string } + | { ok: false; error: string; needsCryptKey?: boolean } +> { + await loadExtensionProviders() // ensure external (internal-platform) providers are registered + const provider = getProvider(await resolveProviderId(opts.provider)) + if (!provider) return { ok: false, error: `unknown git host provider: ${opts.provider}` } + const cred = { token: opts.token, baseUrl: opts.baseUrl } + + let login: string + let email: string | undefined + try { + const auth = await provider.authenticate(cred) + login = auth.login + email = auth.email + } catch (e: any) { + return { ok: false, error: `${provider.id} auth failed: ${e?.message ?? e}` } + } + + let repo: { url: string; created: boolean } + try { + repo = await provider.ensureRepo(cred, opts.repoName, { private: true }) + } catch (e: any) { + return { ok: false, error: `ensure repo failed: ${e?.message ?? e}` } + } + + // Set up git auth per the provider's mode. + let cloneUrl = repo.url + if (provider.gitAuthMode === "ssh-deploy-key") { + // GitHub-style: register a loopat-generated deploy key; git clones via ssh. + const { publicKey } = await ensurePersonalKeypair(opts.userId) + if (publicKey && provider.registerDeployKey) { + try { + await provider.registerDeployKey(cred, { owner: login, name: opts.repoName }, `loopat:${opts.userId}`, publicKey, false) + } catch (e: any) { + return { ok: false, error: `register deploy key failed: ${e?.message ?? e}` } + } + } + } else { + // https-token git: https://<login>:<token>@host/path — GitLab/Code use the + // username + private_token as basic auth (GitHub PAT works the same way). + // Normalize http→https. (MVP: the token lands in the worktree's .git/config — + // fine for a private, user-owned personal repo; a credential-helper pass can + // harden it later.) + cloneUrl = repo.url.replace( + /^https?:\/\//, + `https://${encodeURIComponent(login)}:${encodeURIComponent(opts.token)}@`, + ) + } + + // Clone + git-crypt via the existing import path (commit author from the + // platform identity — some hosts reject non-corporate emails). + // Internal-setup hook (optional): the provider may seed default files into + // the fresh repo (provider configs, ssh keys, …). Only fires on auto-init. + const seed = provider.seedDefaults + ? (repoDir: string) => + provider.seedDefaults!({ + repoDir, + vaultDir: join(repoDir, ".loopat", "vaults", "default"), + userId: opts.userId, + login, + }) + : undefined + const imp = await importPersonalFromRepo(opts.userId, cloneUrl, opts.cryptKey, { name: login, email }, seed) + if (!imp.ok) return { ok: false, error: imp.error, needsCryptKey: imp.needsCryptKey } + return { + ok: true, + repo: `${login}/${opts.repoName}`, + repoUrl: repo.url, + created: repo.created, + autoInitialized: imp.autoInitialized, + cryptKey: imp.cryptKey, + } +} + +/** Back-compat thin wrapper — GitHub is just the default provider. */ +export async function setupPersonalViaGithub(opts: { + userId: string + token: string + repoName: string + baseUrl?: string + cryptKey?: string +}) { + return setupPersonalViaProvider({ ...opts, provider: "github" }) +} + +/** List the user's repos via a provider (onboarding picker), "personal"-named + * first. Empty when the provider can't list or the call fails. */ +export async function listPersonalReposViaProvider(opts: { + provider?: string + token: string + baseUrl?: string +}): Promise<{ name: string; path: string }[]> { + await loadExtensionProviders() + const provider = getProvider(await resolveProviderId(opts.provider)) + if (!provider?.listRepos) return [] + let repos: { name: string; path: string }[] + try { + repos = await provider.listRepos({ token: opts.token, baseUrl: opts.baseUrl }) + } catch { + return [] + } + return repos.sort((a, b) => (b.name.includes("personal") ? 1 : 0) - (a.name.includes("personal") ? 1 : 0)) +} + +/** Validate a token by authenticating it against the provider. The onboarding + * picker calls this to fail fast on a bad token instead of silently showing + * an empty repo list. */ +export async function authenticateViaProvider(opts: { + provider?: string + token: string + baseUrl?: string +}): Promise<{ ok: true; login: string } | { ok: false; error: string }> { + await loadExtensionProviders() + const provider = getProvider(await resolveProviderId(opts.provider)) + if (!provider) return { ok: false, error: `unknown git host provider: ${opts.provider}` } + try { + const auth = await provider.authenticate({ token: opts.token, baseUrl: opts.baseUrl }) + return { ok: true, login: auth.login } + } catch (e: any) { + return { ok: false, error: `${provider.id} auth failed: ${e?.message ?? e}` } + } +} + +/** The provider's optional token-help hint (URL/text), for the onboarding UI. */ +export async function providerTokenHelp(providerId?: string): Promise<string | null> { + await loadExtensionProviders() + return getProvider(await resolveProviderId(providerId))?.tokenHelp ?? null +} + +export type OnboardingStatus = { + /** Does the active provider implement onboarding at all? (false → no gate) */ + gated: boolean +} & OnboardingView + +/** Resolve the active provider's current onboarding view (done, or next form). + * The provider owns the whole flow; loopat just feeds it context. */ +async function onboardingView(userId: string, vault: string): Promise<OnboardingStatus> { + const provider = await resolveProvider() + if (!provider?.onboarding) return { gated: false, done: true } + const personalRepoImported = !(await isPersonalFresh(userId)) + // Vault env (where api keys live) only exists once the repo is imported. + const vaultEnvs = personalRepoImported ? await loadVaultEnvs(userId, vault) : {} + const config = personalRepoImported ? ((await loadPersonalConfig(userId, vault)) as Record<string, unknown>) : {} + const repoDir = personalRepoImported ? personalDir(userId) : null + try { + const view = await provider.onboarding({ userId, vaultEnvs, config, personalRepoImported, repoDir }) + return { gated: true, ...view } + } catch (e: any) { + // Fail OPEN: a broken onboarding impl must not lock the user out. + console.warn(`[loopat] provider.onboarding(${provider.id}) failed: ${e?.message ?? e}`) + return { gated: false, done: true } + } +} + +export async function userOnboarding(userId: string, vault: string = "default"): Promise<OnboardingStatus> { + return onboardingView(userId, vault) +} + +/** + * Run one onboarding form submission. Fetches the provider's CURRENT form (to + * learn each field's action), then executes the submitted values: + * - "vault-env": write the value to the vault under the field name. + * - "personal-repo-token": provision + import the personal repo with the token. + * Returns the next onboarding view. loopat provides exactly these two + * primitives; the provider composes them into whatever flow it wants. + */ +export async function submitOnboarding( + userId: string, + values: Record<string, string>, + vault: string = "default", +): Promise<{ ok: true; next: OnboardingStatus } | { ok: false; error: string }> { + const provider = await resolveProvider() + if (!provider?.onboarding) return { ok: false, error: "no onboarding for this provider" } + const cur = await onboardingView(userId, vault) + if (cur.done) return { ok: true, next: cur } + // Only "form" remediations have values to run; "route" ones are handled by the + // user acting on the real page, then onboarding() re-checks. + if (cur.show.kind !== "form") return { ok: true, next: cur } + let wroteVaultEnv = false + for (const field of cur.show.fields) { + const raw = values[field.name] + const value = typeof raw === "string" ? raw.trim() : "" + if (!value) continue // skip blanks (the provider's `require` rule is enforced UI-side) + if (field.action === "vault-env") { + const r = await writeVaultEnv(userId, vault, field.name, value) + if (!r.ok) return { ok: false, error: `couldn't save ${field.label}: ${r.error}` } + wroteVaultEnv = true + } else if (field.action === "personal-repo-token") { + const r = await setupPersonalViaProvider({ + userId, + provider: provider.id, + token: value, + baseUrl: provider.baseUrl, + repoName: provider.defaultRepo ?? "loopat-personal", + }) + if (!r.ok) return { ok: false, error: r.error } + } + } + // Persist vault edits (e.g. an api key) to the personal remote — committed but + // unpushed secrets are lost on re-import / another machine. + if (wroteVaultEnv) { + try { + const r = await pushPersonalToRemote(userId) + if (!r.ok) console.warn(`[loopat] personal push after onboarding key (${userId}): ${r.error}`) + } catch (e: any) { + console.warn(`[loopat] personal push after onboarding key (${userId}): ${e?.message ?? e}`) + } + } + return { ok: true, next: await onboardingView(userId, vault) } +} + +/** + * Detect whether `personal/<user>/` is "fresh" — i.e. only has the + * scaffolding we put there (`.git`, `memory/`). If yes, it's safe to wipe + + * clone over the top. Anything else means we refuse to overwrite. + * + * Note: host-secrets/<user>/ lives OUTSIDE personal/<user>/ so it's not + * part of this check and survives import without preservation logic. + */ +export async function isPersonalFresh(userId: string): Promise<boolean> { + const dir = personalDir(userId) + try { + const entries = await readdir(dir) + const SCAFFOLD = new Set([".git", "memory"]) + return entries.every((e) => SCAFFOLD.has(e)) + } catch { + return true + } +} + +/** + * One-shot clone using the user's loopat-managed deploy key. Replaces the + * fresh-scaffolded `personal/<user>/` with the cloned repo. + * + * Two paths: + * + * 1. Default (auto-init). User provides a *clean* repo URL (no git-crypt + * config and no tracked `.loopat/vaults/**`). Server clones, runs + * `git-crypt init`, writes `.gitattributes` + `.gitignore`, commits the + * scaffold, and pushes. The newly-generated symmetric key is saved under + * `host-secrets/<user>/git-crypt.key` AND returned to the caller exactly + * once so the UI can show it for backup. + * + * 2. Recovery (BYOK). User pastes a base64-encoded git-crypt key in + * `cryptKey`. Repo must already be a git-crypt'd loopat repo (typical + * case: same user, new host). Server runs `git-crypt unlock`, stores the + * key under host-secrets/, swaps personal/ in. + * + * Anything in between (partially set-up repo, leftover plaintext secrets, + * git-crypt configured but no key supplied, etc.) is refused with a precise + * error so the user knows what to fix. + * + * Returns { ok: false, error } on any failure; on failure personal/<user>/ + * is left untouched (we clone into a temp dir first). + */ +export async function importPersonalFromRepo( + userId: string, + repoUrl: string, + cryptKey?: string, + author?: { name?: string; email?: string }, + seed?: (repoDir: string) => Promise<void>, +): Promise< + | { ok: true; autoInitialized?: boolean; cryptKey?: string } + | { + ok: false + error: string + needsCryptKey?: boolean + notClean?: boolean + secretsExposed?: boolean + exposedFiles?: string[] + } +> { + if (!repoUrl?.trim()) return { ok: false, error: "repoUrl required" } + + // Refuse if the user has already populated personal/. We don't want to nuke + // their work. They can `rm -rf` manually and retry if that's really intended. + if (!(await isPersonalFresh(userId))) { + return { ok: false, error: "personal/ is not empty — refusing to overwrite" } + } + + // https-token urls carry their own auth (https://user:token@…) and need no + // ssh deploy key; ssh urls require the loopat-managed deploy key. + const isHttps = /^https?:\/\//.test(repoUrl) + const priv = hostDeployKeyPath(userId) + if (!isHttps && !existsSyncBase(priv)) { + return { ok: false, error: "deploy keypair missing — re-register" } + } + + // Clone into a tmp dir. ssh uses the deploy key (StrictHostKeyChecking= + // accept-new, no pre-populated known_hosts on first run); https auths via url. + const tmp = await mkdtemp(join(tmpdir(), `loopat-import-${userId}-`)) + // Bootstrap: first clone of the personal repo uses the host deploy-key (no + // vault key exists yet). Every later op uses the user's vault key. + const cloneEnv = isHttps ? { ...process.env } : { ...process.env, GIT_SSH_COMMAND: personalSshCommand(userId) } + try { + await execFileP("git", ["clone", "--", repoUrl, tmp], { env: cloneEnv }) + } catch (e: any) { + await rm(tmp, { recursive: true, force: true }).catch(() => {}) + const msg = (e?.stderr || e?.message || String(e)).toString().trim().split("\n").slice(-3).join(" ") + return { ok: false, error: `clone failed: ${msg}` } + } + + // Exposure check (always, regardless of path): refuse to adopt a repo whose + // .loopat/vaults/** are plaintext in git. Even with BYOK this is bad — + // if any single secret blob is plaintext, those secrets are already burned. + const exposed = await detectExposedSecrets(tmp) + if (exposed.length > 0) { + await rm(tmp, { recursive: true, force: true }).catch(() => {}) + return { + ok: false, + error: "secrets are exposed (plaintext) in this repo's git history", + secretsExposed: true, + exposedFiles: exposed.slice(0, 20), + } + } + + const hasGitCrypt = await detectGitCryptEnabled(tmp) + const trackedSecrets = await listTrackedSecretFiles(tmp) + + if (cryptKey?.trim()) { + // ── BYOK / recovery path ── + if (!hasGitCrypt) { + await rm(tmp, { recursive: true, force: true }).catch(() => {}) + return { + ok: false, + error: + "you provided a crypt key but this repo has no git-crypt config — leave the key field empty to let loopat initialize the repo, or point at the right repo", + } + } + const unlockResult = await unlockWithCryptKey(tmp, userId, cryptKey) + if (!unlockResult.ok) { + await rm(tmp, { recursive: true, force: true }).catch(() => {}) + // Wrong/invalid key — keep the "paste the key" field open for a retry. + return { ok: false, error: unlockResult.error, needsCryptKey: true } + } + return await swapPersonalDir(userId, tmp) + } + + // ── Default / auto-init path ── + // Require a strictly clean repo: no git-crypt config, no tracked secrets. + if (hasGitCrypt) { + await rm(tmp, { recursive: true, force: true }).catch(() => {}) + return { + ok: false, + // needsCryptKey drives the inline "paste the key" field in BOTH flows + // (provider wizard + direct import); notClean keeps the direct flow's + // Recovery hint working. + needsCryptKey: true, + notClean: true, + error: + "this repo already has git-crypt configured — either point at a fresh empty repo, or paste your existing crypt key to import it", + } + } + if (trackedSecrets.length > 0) { + await rm(tmp, { recursive: true, force: true }).catch(() => {}) + return { + ok: false, + notClean: true, + error: `\`.loopat/vaults/\` in this repo isn't empty (${trackedSecrets.length} file(s) tracked) — use a fresh repo`, + } + } + + const init = await autoInitGitCrypt(tmp, userId, author, seed) + if (!init.ok) { + await rm(tmp, { recursive: true, force: true }).catch(() => {}) + return { ok: false, error: init.error } + } + + const swap = await swapPersonalDir(userId, tmp) + if (!swap.ok) return swap + return { ok: true, autoInitialized: true, cryptKey: init.cryptKey } +} + +/** + * TEAM key: the ssh command a host-side git op uses to reach SHARED context — + * knowledge / notes / repos — as the user, with their OWN vault. We just point + * ssh at the vault's `.ssh` and let it follow the standard: the standard-named + * key (`id_ed25519`, via `-i` because on the host `~` isn't the vault) and the + * vault's own `config` (via `-F`, so the user's Host / known-hosts / strict- + * checking choices apply). No `IdentitiesOnly` / `UserKnownHostsFile` overrides + * — loopat doesn't special-case the key, it follows ssh's standard resolution. + * If the key isn't there the op simply fails: we deliberately do NOT fall back + * to the host deploy-key, so a loop never borrows access it wasn't granted + * (see behavior/02-personal-permissions.md). + */ +function sshCommandForUser(userId: string, vault: string = "default"): string { + const sshDir = join(personalVaultDir(userId, vault), "mounts", "home", ".ssh") + const vaultKey = join(sshDir, "id_ed25519") + const vaultConfig = join(sshDir, "config") + // git can't persist 0600 — it only tracks the exec bit — so a fresh checkout + // of the git-crypt vault lands the key at the umask default (0664 under a 002 + // umask), and ssh refuses it ("permissions too open"). Force 0600 at point of + // use: this fixes every host-side git op AND the file the sandbox bind-mounts + // into $HOME, regardless of how/when it was checked out. Cheap + idempotent. + try { chmodSync(vaultKey, 0o600) } catch {} + const f = existsSyncBase(vaultConfig) ? `-F ${vaultConfig} ` : "" + return `ssh ${f}-i ${vaultKey}` +} + +/** + * PERSONAL key: reaching the user's OWN personal repo (clone / pull / push) uses + * the host-managed per-user deploy-key — permanently, not just at bootstrap. + * personal access is a separate concern from the vault key (which reaches the + * team): the deploy-key + git-crypt key are the two external credentials that + * unlock a personal repo, both held in host-secrets/<user>; the vault key lives + * INSIDE the (now-unlocked) personal repo and only reaches the team. This avoids + * the recursion of "use a key stored in the repo to reach the repo itself". + */ +function personalSshCommand(userId: string): string { + return `ssh -i ${hostDeployKeyPath(userId)} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null` +} + +async function swapPersonalDir( + userId: string, + tmp: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + const dir = personalDir(userId) + try { + await mkdir(join(dir, ".."), { recursive: true }) + await rm(dir, { recursive: true, force: true }) + await rename(tmp, dir) + const pm = personalMemoryDir(userId) + await mkdir(pm, { recursive: true }) + const pmIdx = `${pm}/MEMORY.md` + if (!existsSyncBase(pmIdx)) await writeFile(pmIdx, PERSONAL_MEMORY_INDEX_STUB) + return { ok: true } + } catch (e: any) { + return { ok: false, error: `swap failed: ${e?.message ?? e}` } + } +} + +/** + * Server-side bootstrap for a clean personal repo: git-crypt init, write the + * scaffold (`.gitattributes`, `.gitignore`, `.loopat/vaults/default/.gitkeep`), + * commit, push, and stash the freshly-generated symmetric key under + * host-secrets/<user>/. Returns the key base64-encoded so the UI can show it + * to the user exactly once for backup. + * + * On any failure (clone tampered, push permission missing, git-crypt missing) + * the saved host-secrets key is rolled back so a retry starts from scratch. + */ +async function autoInitGitCrypt( + repoDir: string, + userId: string, + author?: { name?: string; email?: string }, + seed?: (repoDir: string) => Promise<void>, +): Promise<{ ok: true; cryptKey: string } | { ok: false; error: string }> { + // git-crypt must be on the host; check early with a useful error + try { + await execFileP("git-crypt", ["--version"]) + } catch { + return { + ok: false, + error: "git-crypt not installed on host (sudo apt install git-crypt / brew install git-crypt)", + } + } + + // Local-only commit author so this doesn't depend on global git config + try { + await execFileP("git", ["-C", repoDir, "config", "user.email", author?.email ?? "loopat@local"]) + await execFileP("git", ["-C", repoDir, "config", "user.name", author?.name ?? "loopat"]) + } catch (e: any) { + return { ok: false, error: `git config failed: ${e?.message ?? e}` } + } + + try { + await execFileP("git-crypt", ["init"], { cwd: repoDir }) + } catch (e: any) { + const stderr = (e?.stderr ?? "").toString().trim() + return { ok: false, error: `git-crypt init failed: ${stderr || e?.message || e}` } + } + + // Merge .gitattributes (preserve any existing lines, e.g. LFS / line endings). + await appendLineIfMissing( + join(repoDir, ".gitattributes"), + ".loopat/vaults/** filter=git-crypt diff=git-crypt", + (existing, line) => existing.includes(line), + ) + + // Merge .gitignore so host-only state under .loopat/host/ never gets pushed + await appendLineIfMissing( + join(repoDir, ".gitignore"), + "/.loopat/host/", + (existing, line) => + existing.split("\n").some((l) => l.trim() === line || l.trim() === ".loopat/host/"), + ) + + // Scaffold vaults/default/ so future writes land in a tracked directory. + // New imports start in the new layout; legacy `secrets/` is only consulted + // for users who imported before vaults existed. + await mkdir(join(repoDir, ".loopat/vaults/default"), { recursive: true }) + await writeFile(join(repoDir, ".loopat/vaults/default/.gitkeep"), "") + + // Ship the memory index in the scaffold commit so cloning onto a second + // host doesn't depend on swapPersonalDir's late top-up. + await mkdir(join(repoDir, "memory"), { recursive: true }) + if (!existsSyncBase(join(repoDir, "memory/MEMORY.md"))) { + await writeFile(join(repoDir, "memory/MEMORY.md"), PERSONAL_MEMORY_INDEX_STUB) + } + + // Internal-setup hook: let the provider seed default files (provider configs, + // ssh keys, …) into the working tree now. git-crypt is initialized, so + // anything written under .loopat/vaults/** is encrypted, and the scaffold + // commit below picks it up via `git add .loopat`. Non-fatal. + if (seed) { + try { + await seed(repoDir) + } catch (e: any) { + console.warn(`[loopat] seedDefaults hook failed: ${e?.message ?? e}`) + } + } + + // Export the key BEFORE pushing so a push failure rolls back to a state + // that knows whether we had the key at all + let cryptKeyB64: string + let keyBuf: Buffer + try { + const exportPath = join(repoDir, ".git", "git-crypt-export.key") + await execFileP("git-crypt", ["export-key", exportPath], { cwd: repoDir }) + keyBuf = await readFile(exportPath) + cryptKeyB64 = keyBuf.toString("base64") + await rm(exportPath, { force: true }) + } catch (e: any) { + const stderr = (e?.stderr ?? "").toString().trim() + return { ok: false, error: `git-crypt export-key failed: ${stderr || e?.message || e}` } + } + + // Persist to host-secrets BEFORE push so loop start-up code can find it + // even if push partially succeeded. We undo on push failure below. + const { saveGitCryptKey } = await import("./git-crypt-key") + try { + await saveGitCryptKey(userId, keyBuf) + } catch (e: any) { + return { ok: false, error: `failed to save git-crypt key: ${e?.message ?? e}` } + } + + // Stage + commit + try { + await execFileP("git", [ + "-C", + repoDir, + "add", + ".gitattributes", + ".gitignore", + ".loopat", + "memory", + ]) + await execFileP("git", [ + "-C", + repoDir, + "commit", + "-m", + "loopat: initialize personal vault (git-crypt enabled)", + ]) + } catch (e: any) { + await rollbackSavedKey(userId) + const stderr = (e?.stderr ?? "").toString().trim() + return { ok: false, error: `commit failed: ${stderr || e?.message || e}` } + } + + // Determine target branch: prefer existing local HEAD (carries remote's + // default); fall back to "main" for the empty-repo case where there's no + // symbolic ref to follow. + let branch = "main" + try { + const { stdout } = await execFileP("git", ["-C", repoDir, "symbolic-ref", "--short", "HEAD"]) + const v = stdout.trim() + if (v) branch = v + } catch {} + + try { + await execFileP("git", ["-C", repoDir, "push", "origin", `HEAD:${branch}`], { + env: { ...process.env, GIT_SSH_COMMAND: personalSshCommand(userId) }, + }) + } catch (e: any) { + await rollbackSavedKey(userId) + const stderr = (e?.stderr ?? "").toString().trim() + const hint = /denied|read.only|permission/i.test(stderr) + ? " (does the deploy key have write access?)" + : "" + return { ok: false, error: `push failed${hint}: ${stderr || e?.message || e}` } + } + + return { ok: true, cryptKey: cryptKeyB64 } +} + +async function rollbackSavedKey(userId: string) { + const { rm: rmFile } = await import("node:fs/promises") + await rmFile(personalGitCryptKeyPath(userId), { force: true }).catch(() => {}) +} + +async function appendLineIfMissing( + path: string, + line: string, + alreadyPresent: (existing: string, line: string) => boolean, +) { + let existing = "" + try { + existing = await readFile(path, "utf8") + } catch {} + if (alreadyPresent(existing, line)) return + const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n" + await writeFile(path, existing + sep + line + "\n") +} + +async function listTrackedSecretFiles(repoDir: string): Promise<string[]> { + try { + const { stdout } = await execFileP("git", [ + "-C", + repoDir, + "ls-files", + "-z", + ".loopat/vaults", + ]) + return stdout + .split("\0") + .filter(Boolean) + // Scaffold marker files are not real content; ignore them. + .filter((f) => !f.endsWith("/.gitkeep")) + } catch { + return [] + } +} + +export type PersonalDirtyStatus = { + uncommitted: number + unpushed: number + isGitRepo: boolean + hasRemote: boolean +} + +/** + * Inspect personal/<user>/: how many uncommitted worktree changes, how many + * commits not reachable from any remote-tracking branch. Used as the + * pre-flight before a destructive delete. + * + * Returns counts only; the caller decides what "dirty" means (we treat + * uncommitted > 0 || unpushed > 0 as dirty). + */ +export async function inspectPersonalDirty(userId: string): Promise<PersonalDirtyStatus> { + const dir = personalDir(userId) + if (!existsSyncBase(dir) || !existsSyncBase(join(dir, ".git"))) { + return { uncommitted: 0, unpushed: 0, isGitRepo: false, hasRemote: false } + } + let hasRemote = false + try { + const { stdout } = await execFileP("git", ["-C", dir, "remote"]) + hasRemote = stdout.trim().length > 0 + } catch {} + + // Refresh remote-tracking refs so "unpushed" reflects current remote state. + // Best-effort — offline / no network is fine, we'll just over-report. + if (hasRemote) { + try { + await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { + env: { ...process.env, GIT_SSH_COMMAND: personalSshCommand(userId) }, + timeout: 15_000, + }) + } catch {} + } + + let uncommitted = 0 + try { + const { stdout } = await execFileP("git", ["-C", dir, "status", "--porcelain"]) + uncommitted = stdout.split("\n").filter((l) => l.trim().length > 0).length + } catch {} + + let unpushed = 0 + try { + // Commits on HEAD not reachable from any remote-tracking branch. + const { stdout } = await execFileP("git", [ + "-C", + dir, + "rev-list", + "--count", + "HEAD", + "--not", + "--remotes", + ]) + unpushed = parseInt(stdout.trim(), 10) || 0 + } catch { + // No commits at all on HEAD → rev-list errors; treat as 0 + } + + return { uncommitted, unpushed, isGitRepo: true, hasRemote } +} + +/** + * Stage + commit + push everything in personal/<user>/. Best-effort. If + * there's nothing to commit but there are unpushed commits, just push. + */ +export async function syncPersonalToRemote( + userId: string, +): Promise<{ ok: true } | { ok: false, error: string }> { + const dir = personalDir(userId) + if (!existsSyncBase(join(dir, ".git"))) { + return { ok: false, error: "personal/ is not a git repo — nothing to sync to" } + } + + // Author must be set for the commit step. Set locally so we don't rely + // on the host's global git config. + try { + await execFileP("git", ["-C", dir, "config", "user.email", "loopat@local"]) + await execFileP("git", ["-C", dir, "config", "user.name", "loopat"]) + } catch (e: any) { + return { ok: false, error: `git config failed: ${e?.message ?? e}` } + } + + // Stage everything + try { + await execFileP("git", ["-C", dir, "add", "-A"]) + } catch (e: any) { + return { ok: false, error: `git add failed: ${e?.stderr ?? e?.message ?? e}` } + } + + // Commit if there's anything staged. `git diff --cached --quiet` exits + // non-zero when there are staged changes, so we invert the check. + let hadStaged = false + try { + await execFileP("git", ["-C", dir, "diff", "--cached", "--quiet"]) + } catch { + hadStaged = true + } + if (hadStaged) { + try { + await execFileP("git", [ + "-C", + dir, + "commit", + "-m", + "loopat: sync personal vault before delete", + ]) + } catch (e: any) { + return { ok: false, error: `commit failed: ${e?.stderr ?? e?.message ?? e}` } + } + } + + // Determine target branch + let branch = "main" + try { + const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"]) + const v = stdout.trim() + if (v) branch = v + } catch {} + + // Need an origin to push to. If there's no remote (e.g. the user never + // imported, personal/ is the local-only scaffold), refuse — sync is + // impossible. Caller can still force-delete. + let hasOrigin = false + try { + await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]) + hasOrigin = true + } catch {} + if (!hasOrigin) { + return { ok: false, error: "no remote configured — nothing to sync to" } + } + + try { + await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], { + env: { ...process.env, GIT_SSH_COMMAND: personalSshCommand(userId) }, + }) + } catch (e: any) { + const stderr = (e?.stderr ?? "").toString().trim() + return { ok: false, error: `push failed: ${stderr || e?.message || e}` } + } + return { ok: true } +} + +/** + * ff-only sync core — the loop-outside (no-AI) rule from docs/context-flow.md: + * rebase a checkout's local commits onto origin/<branch>. A clean rebase means + * local is now origin + local commits, linear, ready to ff-push. A real + * same-spot conflict is *held back*: we abort (local commits preserved — + * nothing is lost) and report the files so the caller can surface the choice + * (discard local / take remote / resolve in a loop). Never a blind merge. + */ +async function rebaseOntoOrigin( + dir: string, + branch: string, + sshCommand?: string, +): Promise<{ ok: true } | { ok: false; error: string } | { ok: false; conflict: true; files: string[] }> { + const fetchEnv: Record<string, string> = { ...process.env, GIT_TERMINAL_PROMPT: "0" } + if (sshCommand) fetchEnv.GIT_SSH_COMMAND = sshCommand + try { + await execFileP("git", ["-C", dir, "fetch", "origin"], { env: fetchEnv, timeout: 30_000 }) + } catch (e: any) { + return { ok: false, error: `fetch failed: ${e?.stderr ?? e?.message ?? e}` } + } + // No upstream branch yet (empty remote) → nothing to rebase onto. + try { + await execFileP("git", ["-C", dir, "rev-parse", "--verify", "--quiet", `origin/${branch}`]) + } catch { + return { ok: true } + } + try { await execFileP("git", ["-C", dir, "rebase", "--abort"]) } catch {} + try { + await execFileP("git", ["-C", dir, "rebase", `origin/${branch}`], { + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + }) + return { ok: true } + } catch (e: any) { + const stderr = (e?.stderr ?? "").toString() + let files: string[] = [] + try { + const { stdout } = await execFileP("git", ["-C", dir, "diff", "--name-only", "--diff-filter=U"]) + files = stdout.split("\n").filter((l) => l.trim()) + } catch {} + try { await execFileP("git", ["-C", dir, "rebase", "--abort"]) } catch {} + if (files.length > 0 || /CONFLICT/.test(stderr)) return { ok: false, conflict: true, files } + return { ok: false, error: `rebase failed: ${stderr || e?.message || e}` } + } +} + +/** + * Stage + commit local changes. Preserves the repo's existing author (set at + * import from the platform identity — some hosts reject non-corporate emails); + * only falls back to a local identity if none is configured. + */ +async function commitLocalChanges( + dir: string, + message: string, +): Promise<{ ok: true; committed: boolean } | { ok: false; error: string }> { + try { + try { await execFileP("git", ["-C", dir, "config", "user.email"]) } + catch { await execFileP("git", ["-C", dir, "config", "user.email", "loopat@local"]) } + try { await execFileP("git", ["-C", dir, "config", "user.name"]) } + catch { await execFileP("git", ["-C", dir, "config", "user.name", "loopat"]) } + await execFileP("git", ["-C", dir, "add", "-A"]) + } catch (e: any) { + return { ok: false, error: `git add failed: ${e?.stderr ?? e?.message ?? e}` } + } + let staged = false + try { await execFileP("git", ["-C", dir, "diff", "--cached", "--quiet"]) } catch { staged = true } + if (!staged) return { ok: true, committed: false } + try { + await execFileP("git", ["-C", dir, "commit", "-m", message]) + } catch (e: any) { + return { ok: false, error: `commit failed: ${e?.stderr ?? e?.message ?? e}` } + } + return { ok: true, committed: true } +} + +/** + * Pull = align this checkout to origin (the SoT). Commits local edits, rebases + * them onto origin/<branch> (held back on real conflict). With `force`, discards + * local entirely and takes the remote — the "take remote" escape hatch. + */ +export type PersonalPullResult = + | { ok: true; message: string } + | { ok: false; error: string; conflict?: boolean; files?: string[]; needsStash?: boolean } + +export async function pullPersonalFromRemote( + userId: string, + opts?: { force?: boolean }, +): Promise<PersonalPullResult> { + const force = opts?.force ?? false + const dir = personalDir(userId) + if (!existsSyncBase(join(dir, ".git"))) { + return { ok: false, error: "personal/ is not a git repo" } + } + let hasOrigin = false + try { await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]); hasOrigin = true } catch {} + if (!hasOrigin) return { ok: false, error: "no remote configured" } + + let branch = "main" + try { + const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"]) + if (stdout.trim()) branch = stdout.trim() + } catch {} + + if (force) { + // "Take the remote": discard ALL local state, re-align to origin. Doubles as + // the escape hatch for a wedged repo (stuck rebase/merge, dirty index). + const silent = { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" } + try { + try { await execFileP("git", ["-C", dir, "rebase", "--abort"], { env: silent }) } catch {} + try { await execFileP("git", ["-C", dir, "merge", "--abort"], { env: silent }) } catch {} + await execFileP("git", ["-C", dir, "fetch", "origin"], { + env: { ...silent, GIT_SSH_COMMAND: personalSshCommand(userId) }, timeout: 30_000, + }) + await execFileP("git", ["-C", dir, "reset", "--hard", `origin/${branch}`], { env: silent }) + await execFileP("git", ["-C", dir, "clean", "-fd"], { env: silent }) + return { ok: true, message: `reset to origin/${branch}` } + } catch (e: any) { + return { ok: false, error: `force pull failed: ${e?.stderr ?? e?.message ?? e}` } + } + } + + // Normal pull: commit local edits so the tree is clean, then rebase onto origin. + const c = await commitLocalChanges(dir, "loopat: local personal edits") + if (!c.ok) return { ok: false, error: c.error } + const reb = await rebaseOntoOrigin(dir, branch, personalSshCommand(userId)) + if (!reb.ok) { + if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files } + return { ok: false, error: reb.error } + } + return { ok: true, message: `aligned to origin/${branch}` } +} + +/** + * Push = land this checkout on origin (the SoT). Commits local edits, rebases + * onto origin/<branch> (held back on a real conflict — never a blind merge), + * then ff-pushes. Outside a loop there's no AI, so a conflict is surfaced + * (`conflict` + `files`), not swallowed. + */ +export type PersonalPushResult = + | { ok: true; message: string } + | { ok: false; error: string; conflict?: boolean; files?: string[]; needsPull?: boolean } + +export async function pushPersonalToRemote( + userId: string, +): Promise<PersonalPushResult> { + const dir = personalDir(userId) + if (!existsSyncBase(join(dir, ".git"))) { + return { ok: false, error: "personal/ is not a git repo" } + } + let hasOrigin = false + try { await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]); hasOrigin = true } catch {} + if (!hasOrigin) return { ok: false, error: "no remote configured" } + + let branch = "main" + try { + const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"]) + if (stdout.trim()) branch = stdout.trim() + } catch {} + + const c = await commitLocalChanges(dir, "loopat: sync personal vault") + if (!c.ok) return { ok: false, error: c.error } + const reb = await rebaseOntoOrigin(dir, branch, personalSshCommand(userId)) + if (!reb.ok) { + if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files } + return { ok: false, error: reb.error } + } + try { + await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], { + env: { ...process.env, GIT_SSH_COMMAND: personalSshCommand(userId) }, + }) + } catch (e: any) { + const stderr = (e?.stderr ?? "").toString().trim() + // We just rebased onto origin, so a rejection means the remote moved again + // between rebase and push (rare) — caller can simply retry. + return { ok: false, error: `push failed: ${stderr || e?.message || e}`, needsPull: true } + } + return { ok: true, message: c.committed ? "committed and pushed" : "pushed" } +} + +/** + * UI-loop notes worktree: a per-user checkout of notes, opened from origin/main, + * for editing team notes outside any AI loop (the no-AI "UI loop"). Disposable — + * rebuilt from origin if missing. + */ +export async function ensureUiNotesWorktree(user: string): Promise<void> { + // Only BUILD the worktree when it's missing. Do NOT re-clone/fetch on every + // read — that made every notes file-open run ensureUserContext (2 gitlab + // fetches) + a worktree rebuild, which was noticeably laggy. Keeping notes + // fresh is the UI's background-sync job (POST /api/notes/refresh), same as + // knowledge's background pull — not this per-read setup. + if (existsSyncBase(join(uiNotesDir(user), ".git"))) return + await ensureUserContext(user).catch(() => {}) + await ensurePerUserContextWorktree(personalNotesDir(user), uiNotesDir(user), `ui/${user}`) +} + +/** + * Save = land this user's notes edits on origin/main (the SoT). Commits, rebases + * onto origin/main (held back on a real conflict), ff-pushes HEAD:main. notes + * uses the host's default git auth (team origin), not a personal deploy key. + */ +export async function syncUiNotes(user: string): Promise<PersonalPushResult> { + const dir = uiNotesDir(user) + await ensureUiNotesWorktree(user) + const branch = await remoteDefaultBranch(dir) + const c = await commitLocalChanges(dir, "loopat: edit notes") + if (!c.ok) return { ok: false, error: c.error } + const userSsh = sshCommandForUser(user) + const reb = await rebaseOntoOrigin(dir, branch, userSsh) + if (!reb.ok) { + if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files } + return { ok: false, error: reb.error } + } + try { + await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], { + env: { ...process.env, GIT_SSH_COMMAND: userSsh }, + }) + } catch (e: any) { + const stderr = (e?.stderr ?? "").toString().trim() + return { ok: false, error: `push failed: ${stderr || e?.message || e}`, needsPull: true } + } + return { ok: true, message: c.committed ? "saved & pushed" : "pushed" } +} + +/** + * Refresh = the pull half of the notes UI loop: fetch + ff-only merge + * origin/main into the user's worktree. Skips silently if the worktree has + * diverged (committed-but-unpushed local edits) — those reconcile on save. + */ +export async function ffUpdateUiNotes( + user: string, +): Promise<{ ok: true } | { ok: false; diverged?: boolean; error: string }> { + const dir = uiNotesDir(user) + await ensureUiNotesWorktree(user) + const branch = await remoteDefaultBranch(dir) + try { + await execFileP("git", ["-C", dir, "fetch", "origin"], { + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: sshCommandForUser(user) }, timeout: 30_000, + }) + } catch (e: any) { + return { ok: false, error: `fetch failed: ${e?.stderr ?? e?.message ?? e}` } + } + // No upstream yet → nothing to pull. + try { + await execFileP("git", ["-C", dir, "rev-parse", "--verify", "--quiet", `origin/${branch}`]) + } catch { + return { ok: true } + } + try { + await execFileP("git", ["-C", dir, "merge", "--ff-only", `origin/${branch}`], { + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + }) + return { ok: true } + } catch { + // Not ff (local unpushed commits). Leave it; the next save rebases. + return { ok: false, diverged: true, error: "diverged — save your edits first" } + } +} + +/** + * Take-remote: discard the user's held-back notes edits and reset hard to + * origin. The escape hatch from a same-spot conflict the rebase can't clear — + * "drop this edit, take the remote" (the other half is keep-mine via a loop AI + * merge). Destructive by design; the UI confirms first. + */ +export async function discardUiNotes( + user: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + const dir = uiNotesDir(user) + await ensureUiNotesWorktree(user) + const branch = await remoteDefaultBranch(dir) + try { + await execFileP("git", ["-C", dir, "fetch", "origin"], { + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: sshCommandForUser(user) }, timeout: 30_000, + }) + await execFileP("git", ["-C", dir, "reset", "--hard", `origin/${branch}`]) + return { ok: true } + } catch (e: any) { + return { ok: false, error: `discard failed: ${e?.stderr ?? e?.message ?? e}` } + } +} + +/** + * How many commits the user's notes worktree is behind origin/main (after a + * fetch). Drives the "remote updated" hint. 0 = up to date. + */ +export async function notesBehind(user: string): Promise<number> { + const dir = uiNotesDir(user) + await ensureUiNotesWorktree(user) + const branch = await remoteDefaultBranch(dir) + try { + await execFileP("git", ["-C", dir, "fetch", "origin"], { + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: sshCommandForUser(user) }, timeout: 30_000, + }) + } catch { + return 0 + } + try { + const { stdout } = await execFileP("git", ["-C", dir, "rev-list", "--count", `HEAD..origin/${branch}`]) + return parseInt(stdout.trim(), 10) || 0 + } catch { + return 0 + } +} + +// ── Generic repo sync (knowledge / notes / repos) ───────────────────── +// +// Distinct from personal sync above: these workspace-level repos use the +// host's default SSH config (whatever the server clone used at boot), NOT +// a per-user deploy key. Strict ff-only on both directions — by design no +// one edits these outside of loopat, so divergence is treated as an error +// to investigate, not auto-resolved. + +export type RepoSyncStatus = { + isGitRepo: boolean + hasRemote: boolean + branch: string + ahead: number + behind: number + uncommitted: number +} + +export type RepoSyncResult = + | { ok: true; message: string } + | { ok: false; error: string } + +/** + * Best-effort fetch then count ahead/behind vs origin/<branch>. Fetch + * failures are tolerated (offline / auth glitch) — status still reflects + * last-known remote state. + */ +export async function inspectRepoSync(dir: string, user?: string): Promise<RepoSyncStatus> { + if (!existsSyncBase(dir) || !existsSyncBase(join(dir, ".git"))) { + return { isGitRepo: false, hasRemote: false, branch: "", ahead: 0, behind: 0, uncommitted: 0 } + } + + let branch = "" + try { + const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"]) + branch = stdout.trim() + } catch {} + + let hasRemote = false + try { + await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]) + hasRemote = true + } catch {} + + if (hasRemote) { + try { + const env = user ? { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user) } : process.env + await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env, timeout: 15_000 }) + } catch {} + } + + let uncommitted = 0 + try { + const { stdout } = await execFileP("git", ["-C", dir, "status", "--porcelain"]) + uncommitted = stdout.split("\n").filter((l) => l.trim().length > 0).length + } catch {} + + let ahead = 0 + let behind = 0 + if (hasRemote && branch) { + try { + const { stdout } = await execFileP("git", [ + "-C", dir, "rev-list", "--left-right", "--count", `origin/${branch}...${branch}`, + ]) + const m = stdout.trim().match(/^(\d+)\s+(\d+)$/) + if (m) { behind = parseInt(m[1], 10); ahead = parseInt(m[2], 10) } + } catch {} + } + + return { isGitRepo: true, hasRemote, branch, ahead, behind, uncommitted } +} + +/** + * Fetch + ff-only merge into the current HEAD. Aborts on uncommitted + * changes (we don't auto-stash workspace repos — caller decides) and on + * any non-ff condition. + */ +export async function pullRepoFromRemote(dir: string, user?: string): Promise<RepoSyncResult> { + if (!existsSyncBase(join(dir, ".git"))) { + return { ok: false, error: "not a git repo" } + } + + let hasRemote = false + try { + await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]) + hasRemote = true + } catch {} + if (!hasRemote) return { ok: false, error: "no remote configured" } + + let branch = "" + try { + const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"]) + branch = stdout.trim() + } catch {} + if (!branch) return { ok: false, error: "HEAD is detached" } + + let uncommitted = 0 + try { + const { stdout } = await execFileP("git", ["-C", dir, "status", "--porcelain"]) + uncommitted = stdout.split("\n").filter((l) => l.trim().length > 0).length + } catch {} + if (uncommitted > 0) { + return { ok: false, error: `aborted: ${uncommitted} uncommitted change(s) in primary` } + } + + try { + const env = user ? { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user) } : process.env + await execFileP("git", ["-C", dir, "fetch", "origin"], { env, timeout: 30_000 }) + } catch (e: any) { + return { ok: false, error: `fetch failed: ${e?.stderr ?? e?.message ?? e}` } + } + + // Nothing new on origin → report "already up to date" so callers (e.g. the + // background sync on entering a vault) can skip a needless refetch/redraw. + let behind = 0 + try { + const { stdout } = await execFileP("git", ["-C", dir, "rev-list", "--count", `HEAD..origin/${branch}`]) + behind = parseInt(stdout.trim(), 10) || 0 + } catch {} + if (behind === 0) return { ok: true, message: "already up to date" } + + try { + await execFileP("git", ["-C", dir, "merge", "--ff-only", `origin/${branch}`]) + } catch (e: any) { + const stderr = (e?.stderr ?? "").toString().trim() + return { ok: false, error: `merge --ff-only failed (diverged from origin/${branch}?): ${stderr || e?.message || e}` } + } + + return { ok: true, message: `pulled origin/${branch} (${behind} commit${behind > 1 ? "s" : ""})` } +} + +/** + * Push current HEAD branch to origin. Plain `git push` — git refuses + * non-ff by default, which is exactly the abort-on-conflict behavior we + * want. Caller pulls first if rejected. + */ +export async function pushRepoToRemote(dir: string, user?: string): Promise<RepoSyncResult> { + if (!existsSyncBase(join(dir, ".git"))) { + return { ok: false, error: "not a git repo" } + } + + let hasRemote = false + try { + await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]) + hasRemote = true + } catch {} + if (!hasRemote) return { ok: false, error: "no remote configured" } + + let branch = "" + try { + const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"]) + branch = stdout.trim() + } catch {} + if (!branch) return { ok: false, error: "HEAD is detached" } + + try { + const env = user ? { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user) } : process.env + await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], { env }) + } catch (e: any) { + const stderr = (e?.stderr ?? "").toString().trim() + return { ok: false, error: `push failed: ${stderr || e?.message || e}` } + } + + return { ok: true, message: `pushed to origin/${branch}` } +} + +/** + * Promote a just-written knowledge `.loopat/config.json` (repos/notes roster) + * back to the knowledge repo's origin: stage + commit + push with the vault key. + * The config was written by saveKnowledgeConfig(user, …) into the per-user + * knowledge clone. "nothing to commit" is success (idempotent save). + */ +export async function promoteKnowledgeConfig(user: string): Promise<RepoSyncResult> { + const dir = personalKnowledgeDir(user) + if (!existsSyncBase(join(dir, ".git"))) return { ok: false, error: "knowledge repo not cloned" } + await execFileP("git", ["-C", dir, "add", ".loopat/config.json"]).catch(() => {}) + try { + await execFileP("git", ["-C", dir, "-c", "user.email=loopat@local", "-c", "user.name=loopat", + "commit", "-m", "chore(loopat): update .loopat/config.json (repos/notes roster)"]) + } catch (e: any) { + if (/nothing to commit/i.test((e?.stdout ?? e?.stderr ?? "").toString())) return { ok: true, message: "no change" } + return { ok: false, error: `commit failed: ${e?.stderr ?? e?.message ?? e}` } + } + return await pushRepoToRemote(dir, user) +} + +/** + * The user's per-vault SSH public keys — the keys a loop authenticates to TEAM + * repos with (knowledge / notes / repos), one per vault. This is what the user + * must register on the team git host. Distinct from the deploy key (host- + * secrets, personal-repo only). Reads vaults/<v>/mounts/home/.ssh/id_ed25519.pub, or + * derives it from the private key. + */ +export async function listVaultPublicKeys(user: string): Promise<{ vault: string; publicKey: string }[]> { + const { listVaults } = await import("./vaults") + const out: { vault: string; publicKey: string }[] = [] + for (const vault of listVaults(user)) { + const sshDir = join(personalVaultDir(user, vault), "mounts", "home", ".ssh") + const pubPath = join(sshDir, "id_ed25519.pub") + const keyPath = join(sshDir, "id_ed25519") + let pub = "" + if (existsSyncBase(pubPath)) { + pub = (await readFile(pubPath, "utf8")).trim() + } else if (existsSyncBase(keyPath)) { + try { const { stdout } = await execFileP("ssh-keygen", ["-y", "-f", keyPath]); pub = stdout.trim() } catch {} + } + if (pub) out.push({ vault, publicKey: pub }) + } + return out +} + +/** + * Wipe personal/<user>/ AND the saved git-crypt key. Deploy keypair stays + * (it's the SSH identity, reusable for the next import). Re-scaffolds an + * empty git-init'd personal/<user>/ so workspace bind paths still resolve. + */ +export async function deletePersonalVault(userId: string): Promise<{ ok: true } | { ok: false; error: string }> { + const dir = personalDir(userId) + try { + await rm(dir, { recursive: true, force: true }) + } catch (e: any) { + return { ok: false, error: `rm personal/ failed: ${e?.message ?? e}` } + } + const { rm: rmFile } = await import("node:fs/promises") + await rmFile(personalGitCryptKeyPath(userId), { force: true }).catch(() => {}) + + // Re-scaffold empty so the workspace doesn't have a hole. Mirrors + // provisionUserPersonal but without re-running deploy-key gen. + try { + await mkdir(dir, { recursive: true }) + const pm = personalMemoryDir(userId) + await mkdir(pm, { recursive: true }) + const pmIdx = `${pm}/MEMORY.md` + if (!existsSyncBase(pmIdx)) await writeFile(pmIdx, PERSONAL_MEMORY_INDEX_STUB) + await gitInitIfMissing(dir) + } catch (e: any) { + return { ok: false, error: `re-scaffold failed: ${e?.message ?? e}` } + } + return { ok: true } +} + +// git-crypt's per-file magic header (10 bytes): \x00 G I T C R Y P T \x00 +const GIT_CRYPT_MAGIC = Buffer.from([0x00, 0x47, 0x49, 0x54, 0x43, 0x52, 0x59, 0x50, 0x54, 0x00]) + +/** + * Returns tracked files under `.loopat/vaults/**` that are stored as + * plaintext (i.e., the worktree blob doesn't start with the git-crypt magic + * header). Reads the worktree directly: in a fresh clone where git-crypt + * isn't unlocked, the worktree contents ARE the raw blobs, so non-encrypted + * files are visibly plaintext here. + */ +async function detectExposedSecrets(repoDir: string): Promise<string[]> { + // Anything under `.loopat/vaults/` stored as plaintext is an exposure and + // refuses import. + // + // Symlinks are skipped: git stores a symlink's target as the blob, and + // git-crypt's filter doesn't (and can't) encrypt that. The target path + // itself isn't a secret value — and walkVaultFiles refuses to bind any + // symlink whose realpath escapes personal/<user>/. + const exposed: string[] = [] + let stdout = "" + try { + const r = await execFileP("git", ["-C", repoDir, "ls-files", "-z", ".loopat/vaults"]) + stdout = r.stdout + } catch { + return exposed + } + const files = stdout.split("\0").filter(Boolean) + for (const f of files) { + if (f.endsWith("/.gitkeep")) continue + try { + const lst = await lstat(join(repoDir, f)) + if (lst.isSymbolicLink()) continue + const buf = await readFile(join(repoDir, f)) + if (buf.length === 0) continue + if (!buf.subarray(0, GIT_CRYPT_MAGIC.length).equals(GIT_CRYPT_MAGIC)) { + exposed.push(f) + } + } catch { + // unreadable — skip + } + } + return exposed +} + +async function detectGitCryptEnabled(repoDir: string): Promise<boolean> { + try { + const { stdout } = await execFileP("git", ["-C", repoDir, "ls-files", "-z"]) + const files = stdout.split("\0").filter((f) => f.endsWith(".gitattributes")) + for (const f of files) { + try { + const content = await readFile(join(repoDir, f), "utf8") + if (/filter=git-crypt/.test(content)) return true + } catch {} + } + return false + } catch { + return false + } +} + +/** + * Persist cryptKey (base64) to host-secrets/<user>/git-crypt.key and run + * `git-crypt unlock` against the cloned repo. On failure, removes the saved + * keyfile so a retry can paste a different key. + */ +async function unlockWithCryptKey( + repoDir: string, + userId: string, + cryptKeyB64: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + const { saveGitCryptKey, gitCryptKeyExists } = await import("./git-crypt-key") + try { + const keyBuf = Buffer.from(cryptKeyB64.trim(), "base64") + if (keyBuf.length < 32) { + return { ok: false, error: "invalid git-crypt key (too short — must be base64-encoded export-key output)" } + } + await saveGitCryptKey(userId, keyBuf) + } catch (e: any) { + return { ok: false, error: `failed to save git-crypt key: ${e?.message ?? e}` } + } + const keyPath = personalGitCryptKeyPath(userId) + try { + await execFileP("git-crypt", ["unlock", keyPath], { cwd: repoDir }) + // The just-decrypted vault ssh key lands at the umask default (often 0664); + // git can't carry 0600, so lock it down now — before the sandbox bind-mounts + // it into $HOME — so both host git ops and in-container ssh accept it. + await chmod(join(repoDir, ".loopat", "vaults", "default", "mounts", "home", ".ssh", "id_ed25519"), 0o600).catch(() => {}) + return { ok: true } + } catch (e: any) { + if (await gitCryptKeyExists(userId)) { + const { rm: rmFile } = await import("node:fs/promises") + await rmFile(keyPath, { force: true }).catch(() => {}) + } + const stderr = (e?.stderr ?? "").toString().trim() + if (/not the file you generated/i.test(stderr) || /Invalid key file/i.test(stderr)) { + return { ok: false, error: "git-crypt unlock failed: wrong key (HMAC mismatch)" } + } + if (/command not found/i.test(stderr) || e?.code === "ENOENT") { + return { ok: false, error: "git-crypt not installed on host (apt install git-crypt)" } + } + return { ok: false, error: `git-crypt unlock failed: ${stderr || e?.message || e}` } + } +} + +async function ensureSymlink(link: string, target: string) { + try { + await lstat(link) + } catch { + await symlink(target, link, "dir") + } +} + +/** + * Idempotently materialize a per-loop git worktree of `repo` at `path` on + * branch `branchName`. If the path already holds a worktree, no-op. If the + * source isn't a git repo (e.g., knowledge without a remote), fall back to + * a symlink so the path still resolves — those loops can't publish, but + * read access still works. + */ +function ensureContextWorktree(repo: string, path: string, branchName: string): Promise<void> { + // Serialize per MAIN repo (not per path): `git worktree add` takes a lock on + // the main repo, so two concurrent adds from the same repo — even to + // different paths — can collide. Same-path races still hit the early-return + // idempotency inside (see runExclusive). + return runExclusive(`wt:${repo}`, () => _ensureContextWorktree(repo, path, branchName)) +} +async function _ensureContextWorktree(repo: string, path: string, branchName: string) { + let stats: Awaited<ReturnType<typeof lstat>> | null = null + try { stats = await lstat(path) } catch {} + // Real dir with .git → already a worktree, leave it alone. + if (stats?.isDirectory() && existsSyncBase(join(path, ".git"))) return + + // Source isn't a git repo — fall back to symlink (legacy shape). + if (!existsSyncBase(join(repo, ".git"))) { + try { await rm(path, { recursive: true, force: true }) } catch {} + await ensureSymlink(path, repo) + return + } + + // Stale state (old symlink, empty dir, leftover from manual cleanup) → wipe + create. + try { await rm(path, { recursive: true, force: true }) } catch {} + try { await execFileP("git", ["-C", repo, "worktree", "prune"]) } catch {} + // ① pull (docs/context-flow.md): open the worktree from origin's default + // branch so the loop starts from latest consensus, not a stale local HEAD. + const start = await remoteStartPoint(repo) + // -B (not -b): reset the branch if it lingers from a removed worktree, so a + // rebuild always re-opens cleanly from the start point. + // No start point → `worktree add -B <branch> <path>` tries to seed the new + // branch from the main repo's HEAD. For an EMPTY context remote (e.g. a notes + // repo with no commits) the clone's HEAD is an unborn ref (`ref: main`); once + // the repo later gains an unrelated branch (master, pushed by another loop) + // git no longer infers `--orphan` and dies with `fatal: invalid reference: + // HEAD`. Pass `--orphan` ourselves so a startless worktree never depends on a + // resolvable HEAD — it opens an empty branch the loop can commit onto. + const tail = start ? ["-B", branchName, path, start] : ["--orphan", "-B", branchName, path] + + // git-crypt + worktree: the git-crypt key lives in the MAIN repo's + // `.git/git-crypt`, but a worktree's gitdir is separate and has no key — so a + // normal `worktree add` checkout runs the smudge filter, can't find the key, + // and fails ("Unable to open key file"). For a git-crypt repo, add WITHOUT + // checkout, then `git-crypt unlock` the worktree with the host key: that + // installs the key into the worktree's gitdir AND decrypts the working tree. + try { + if (existsSyncBase(join(repo, ".git", "git-crypt"))) { + // git-crypt + worktree: a worktree's gitdir has no git-crypt key, so the + // checkout's smudge filter crashes ("Unable to open key file") — and + // git-crypt doesn't support worktrees anyway (it wants a `.git` dir). We + // don't NEED the worktree's vaults decrypted: the sandbox's vault mounts + // read the MAIN repo's already-unlocked `personal/.loopat/vaults`. So + // neutralize the smudge filter (`smudge=cat`, `required=false`) — the + // git-crypt'd files (vaults/**) land encrypted-as-is, everything else + // (memory, etc.) checks out plainly. + await execFileP("git", ["-C", repo, "-c", "filter.git-crypt.smudge=cat", "-c", "filter.git-crypt.required=false", "worktree", "add", ...tail]) + } else { + await execFileP("git", ["-C", repo, "worktree", "add", ...tail]) + } + } catch (e: any) { + // Idempotent recovery: if a concurrent builder already produced the + // worktree (despite serialization, e.g. across process restarts), accept + // it instead of dumping a raw stack trace. + if (existsSyncBase(join(path, ".git"))) return + throw e + } +} + +/** + * ① pull, per docs/context-flow.md: a loop starts from consensus. Best-effort + * fetch origin, then return `origin/main` as the worktree start-point so the + * loop opens from the latest shared state. Returns null to fall back to local + * HEAD (solo / offline / no remote / no origin/main yet). + */ +/** The remote's default branch (origin/HEAD) — e.g. main or master. Falls back + * to "main". loopat must NOT assume "main": team repos are often on "master". */ +async function remoteDefaultBranch(dir: string): Promise<string> { + try { + const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"]) + const b = stdout.trim().replace(/^origin\//, "") + if (b) return b + } catch {} + try { + const { stdout } = await execFileP("git", ["-C", dir, "ls-remote", "--symref", "origin", "HEAD"]) + const m = stdout.match(/ref:\s+refs\/heads\/(\S+)\s+HEAD/) + if (m?.[1]) return m[1] + } catch {} + // Bare mirrors don't carry a refs/remotes/origin/HEAD, but their own HEAD + // symbolic-ref names the default branch the clone tracked — cheaper than (and + // a fallback for) ls-remote, and correct for `ensureRepoMirror`'s mirrors. + try { + const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "HEAD"]) + const b = stdout.trim().replace(/^origin\//, "") + if (b) return b + } catch {} + return "main" +} + +async function remoteStartPoint(repo: string, sshCommand?: string): Promise<string | null> { + try { + await execFileP("git", ["-C", repo, "remote", "get-url", "origin"]) + } catch { + return null + } + try { + const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env + await execFileP("git", ["-C", repo, "fetch", "--quiet", "origin"], { env, timeout: 15_000 }) + } catch {} + const branch = await remoteDefaultBranch(repo) + try { + await execFileP("git", ["-C", repo, "rev-parse", "--verify", "--quiet", `origin/${branch}^{commit}`]) + return `origin/${branch}` + } catch { + return null + } +} + +/** + * Worktree from a PER-USER context main repo. When the main repo is absent (the + * user declared no remote for this context — see ensureUserContext's strict + * rule), the loop gets an EMPTY dir, never a fallback to a workspace default. + */ +async function ensurePerUserContextWorktree(repo: string, path: string, branch: string) { + if (!existsSyncBase(join(repo, ".git"))) { + try { await rm(path, { recursive: true, force: true }) } catch {} + await mkdir(path, { recursive: true }) + return + } + await ensureContextWorktree(repo, path, branch) +} + +export async function ensureContextMounts(id: string, createdBy: string) { + await mkdir(loopContextDir(id), { recursive: true }) + // knowledge / notes are per-user (cloned by ensureUserContext from the user's + // personal-declared remotes). Each worktree opens from origin/main — a fresh + // pull of consensus; the local main repo is just the fetch cache + worktree + // host (docs/context-flow.md). Empty when the user declared no remote. + await ensurePerUserContextWorktree(personalKnowledgeDir(createdBy), loopContextKnowledge(id), `loop/${id}`) + await ensurePerUserContextWorktree(personalNotesDir(createdBy), loopContextNotes(id), `loop/${id}`) + // personal is also a per-loop worktree — same shape, wired to the user's + // private remote. ensureContextWorktree falls back to a symlink when + // personal/ isn't a git repo yet. + await ensureContextWorktree(personalDir(createdBy), loopContextPersonal(id), `loop/${id}`) + await mkdir(personalReposDir(createdBy), { recursive: true }) + await ensureSymlink(loopContextRepos(id), personalReposDir(createdBy)) +} + +export async function listLoops(): Promise<LoopMeta[]> { + 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 + } +} + +// refreshLoopSandbox removed entirely — profile model re-composes every spawn. + +async function shortBranchSlug(title: string): Promise<string> { + 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 + createdBy: string + profiles?: string[] + vault?: string + knowledgeRw?: boolean + mountAllLoops?: boolean +}): Promise<LoopMeta> { + await ensureWorkspaceDirs() + const id = randomUUID() + const createdAt = new Date().toISOString() + const meta: LoopMeta = { + id, + title: opts.title.trim() || "untitled", + createdAt, + createdBy: opts.createdBy, + driver: opts.createdBy, + driverHistory: [{ driver: opts.createdBy, since: createdAt }], + } + if (opts.profiles && opts.profiles.length > 0) { + meta.config = { ...(meta.config ?? {}), profiles: opts.profiles } + } + if (opts.vault && opts.vault !== "default") { + meta.config = { ...(meta.config ?? {}), vault: opts.vault } + } + if (opts.knowledgeRw) { + meta.config = { ...(meta.config ?? {}), knowledge_rw: true } + } + if (opts.mountAllLoops) { + meta.config = { ...(meta.config ?? {}), mount_all_loops: true } + } + await mkdir(loopDir(id), { recursive: true }) + await mkdir(loopClaudeDir(id), { recursive: true }) + // Compose skills/agents + profile-chain doctrine into .claude/, write + // settings.json (autoMemory). Plugin resolution happens at spawn time + // (see session.ts) — SDK loads plugins via its `plugins` option, no + // loop-local install state needed. + await composeLoopClaudeConfig(id, opts.createdBy, opts.profiles) + await writeLoopSettings(id) + + // Pull the per-user knowledge/notes FIRST: clones the user's knowledge repo + // (from personal.knowledge) and reads its .loopat/config.json for the notes + // remote + repo roster — which the workdir clone-on-demand below depends on. + // Surface any clone failures (bad key / no access) as a loop banner so the + // user isn't left with a silently-empty context. + const ctxWarnings = await ensureUserContext(opts.createdBy, opts.vault ?? "default").catch( + (e: any) => { console.warn(`[loopat] ensureUserContext(${opts.createdBy}): ${e?.message ?? e}`); return [`context init failed: ${e?.message ?? e}`] }, + ) + if (ctxWarnings.length) meta.contextWarnings = ctxWarnings + + // workdir = git worktree add (if repo selected) OR plain mkdir + if (opts.repo) { + // mirror + fetch as the user (their vault key), not the host's ssh. + const userSsh = sshCommandForUser(opts.createdBy, opts.vault ?? "default") + // Ensure the host-only bare mirror exists (clone once, fetch otherwise), then + // worktree the workdir off it — fast even for huge repos. + const repoPath = await ensureRepoMirror(opts.createdBy, opts.repo, userSsh) + if (!repoPath) { + throw new Error(`repo "${opts.repo}" not found / clone failed`) + } + const branch = `loop/${(await shortBranchSlug(meta.title))}-${id.slice(0, 6)}` + try { + // ① pull (docs/context-flow.md): ensureRepoMirror already fetched, so the + // mirror's `origin/<default>` tracking ref is the latest consensus. Open + // the loop branch off `origin/<default>` (not the bare mirror's HEAD) so + // the worktree is an ORDINARY clone: it has a real `origin/<default>` + // tracking ref and `git rebase origin/<default>` / `git status` + // ahead-behind work with nothing special to learn. Fall back to HEAD only + // if origin/<default> can't be resolved (offline / empty remote). + const start = await remoteStartPoint(repoPath, userSsh) + const addArgs = start + ? ["-C", repoPath, "worktree", "add", "-b", branch, loopWorkdir(id), start] + : ["-C", repoPath, "worktree", "add", "-b", branch, loopWorkdir(id)] + await execFileP("git", addArgs) + 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 }) + } + + // (ensureUserContext already ran above, before the workdir clone.) + await ensureContextMounts(id, effectiveDriver(meta)) + await writeFile(loopMetaPath(id), JSON.stringify(meta, null, 2)) + return meta +} + +/** + * Spawn a child "distill loop" from a source loop. The child's workdir gets + * a point-in-time snapshot of the source's conversation files plus a + * project-tier CLAUDE.md telling the AI it's a distill loop. Knowledge is + * rw so the child can publish sedimented insights. The source is not + * touched. Any authenticated user may distill any loop — distill is a + * read-only relationship. + */ +export async function distillLoop(sourceId: string, byUser: string): Promise<LoopMeta> { + const source = await getLoop(sourceId) + if (!source) throw new Error(`source loop ${sourceId} not found`) + + const shortId = source.id.slice(0, 6) + const child = await createLoop({ + title: `distill: ${shortId} ${source.title}`, + createdBy: byUser, + knowledgeRw: true, + }) + + // Snapshot the source's conversation into the child's workdir. + const sourceDir = join(loopWorkdir(child.id), "source") + await mkdir(sourceDir, { recursive: true }) + for (const [from, to] of [ + [loopHistoryPath(sourceId), join(sourceDir, "messages.jsonl")], + [loopChatHistoryPath(sourceId), join(sourceDir, "chat_history.jsonl")], + ]) { + if (existsSyncBase(from)) { + await copyFile(from, to) + } + } + + // Drop the distill kind's project-tier CLAUDE.md into the workdir. Claude + // Code auto-loads <workdir>/CLAUDE.md (settingSources includes "project"). + const tmpl = loopKindClaudePath("distill") + if (existsSyncBase(tmpl)) { + await copyFile(tmpl, join(loopWorkdir(child.id), "CLAUDE.md")) + } + + return child +} + +export async function getLoop(id: string): Promise<LoopMeta | null> { + try { + const raw = await readFile(loopMetaPath(id), "utf8") + return JSON.parse(raw) as LoopMeta + } catch { + return null + } +} + +export async function patchLoopMeta(id: string, patch: Partial<LoopMeta>): Promise<LoopMeta | null> { + const meta = await getLoop(id) + if (!meta) return null + const updated = { ...meta, ...patch } + await writeFile(loopMetaPath(id), JSON.stringify(updated, null, 2)) + return updated +} + +export async function loopExists(id: string): Promise<boolean> { + try { + const s = await stat(loopDir(id)) + return s.isDirectory() + } catch { + return false + } +} + +export async function backfillAllMounts(): Promise<number> { + let count = 0 + try { + const ids = await readdir(loopsDir()) + for (const id of ids) { + try { + const meta = await getLoop(id) + if (!meta?.createdBy) { + console.warn(`[loopat] loop ${id}: meta missing createdBy — skipping mount backfill`) + continue + } + await ensureContextMounts(id, effectiveDriver(meta)) + count++ + } catch {} + } + } catch {} + return count +} diff --git a/server/src/mcp-oauth.ts b/server/src/mcp-oauth.ts new file mode 100644 index 00000000..8593f951 --- /dev/null +++ b/server/src/mcp-oauth.ts @@ -0,0 +1,561 @@ +/** + * MCP OAuth 2.0 client — loopat owns the OAuth dance, not the sandboxed CC. + * + * Flow (high-level): + * 1. POST /api/mcp-auth/start { serverName, loopId } + * ↓ + * read merged settings.json for the loop (loopClaudeDir/settings.json), + * look up mcpServers[serverName]; parse `Authorization: Bearer ${VAR}` + * from its headers — VAR is the env file we'll write the token to + * ↓ + * discover OAuth metadata: + * - RFC 9728 (protected-resource metadata) → list of auth servers + * - RFC 8414 (auth-server metadata) → endpoints + * ↓ + * RFC 7591 dynamic client registration (DCR) — register loopat as a + * client at the auth server, get client_id / optionally client_secret + * ↓ + * generate PKCE verifier + challenge, generate state + * ↓ + * stash flow context (user, server, envName, verifier, client creds, ...) + * keyed by state in an in-memory map with TTL + * ↓ + * return { authorizationUrl } to the frontend; frontend navigates browser + * + * 2. browser → MCP server auth page → user authorizes → MCP server redirects + * back to GET /api/mcp-auth/callback?code=…&state=… + * ↓ + * look up state in map (verify CSRF), exchange code+verifier for + * access_token at token_endpoint, write to the user's personal default + * vault as env `<envName>` + * ↓ + * redirect browser back to /settings/mcp-auth?status=ok&server=… + * + * Discovery / DCR / token exchange are all standard OAuth 2.0 + RFCs that + * MCP spec mandates for servers offering OAuth. Servers that don't support + * DCR will need a future "operator pre-configures client_id" fallback. + */ +import { createHash, randomBytes } from "node:crypto" + +import { type McpServerConfig, writeVaultEnv } from "./config" +import { DEFAULT_VAULT } from "./vaults" + +/** + * Extract the env var name from a server's `Authorization: Bearer ${VAR}` + * header. Returns null if the server has no parseable Bearer-template header + * (in which case loopat-managed OAuth doesn't apply — the server is either + * static-keyed, uses a non-Bearer auth scheme, or isn't HTTP). + * + * Strict matching by design: + * - header key matched case-insensitively + * - value must be exactly `Bearer ${VARNAME}` (case-insensitive `Bearer`, + * single env ref, no other characters) + * - `VARNAME` must be a valid env var identifier `[A-Z_][A-Z0-9_]*` + * + * Half-static templates like `Bearer ${PREFIX}_suffix` are rejected — we'd + * not know which env to write the OAuth result to. + */ +// Split into two parts so that `bearer` is case-insensitive (HTTP scheme +// matching) while the env name capture is strictly uppercase + underscore + +// digits — matches POSIX-style convention loopat enforces for vault env files. +const BEARER_PREFIX_RE = /^bearer\s+/i +const ENV_REF_RE = /^\$\{([A-Z_][A-Z0-9_]*)\}$/ + +export function parseBearerEnvName(server: McpServerConfig | undefined | null): string | null { + if (!server) return null + const headers = (server as any).headers as Record<string, string> | undefined + if (!headers || typeof headers !== "object") return null + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() !== "authorization") continue + if (typeof v !== "string") return null + const trimmed = v.trim() + const prefix = trimmed.match(BEARER_PREFIX_RE) + if (!prefix) return null + const remainder = trimmed.slice(prefix[0].length) + const m = remainder.match(ENV_REF_RE) + return m ? m[1] : null + } + return null +} + +// ${VAR} or ${VAR:-default}, anywhere in a string. +const ENV_REF_G = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +/** Every env-var name referenced via ${VAR} in a server's url + header values. + * Generalizes parseBearerEnvName beyond `Authorization: Bearer ${VAR}` (e.g. a + * key embedded in the url). A server is "authed" when ALL of these are set. */ +export function mcpRequiredEnvs(server: McpServerConfig | undefined | null): string[] { + if (!server) return [] + const out = new Set<string>() + const scan = (s: unknown) => { + if (typeof s !== "string") return + for (const m of s.matchAll(ENV_REF_G)) out.add(m[1]) + } + scan((server as any).url) + const headers = (server as any).headers + if (headers && typeof headers === "object") for (const v of Object.values(headers)) scan(v) + return [...out] +} + +/** Reverse a `${VAR}`-templated string (e.g. the server url) against a concrete + * pasted value, extracting each VAR. Returns null if the paste doesn't match + * the template's shape. Powers the "paste your MCP URL → auto-fill the secrets" + * setup flow. */ +export function parseTemplateVars(template: string, pasted: string): Record<string, string> | null { + const names: string[] = [] + let re = "^" + let last = 0 + for (const m of template.matchAll(ENV_REF_G)) { + const idx = m.index ?? 0 + re += escapeRegex(template.slice(last, idx)) + "(.+?)" + names.push(m[1]) + last = idx + m[0].length + } + re += escapeRegex(template.slice(last)) + "$" + const match = pasted.trim().match(new RegExp(re)) + if (!match) return null + const out: Record<string, string> = {} + names.forEach((n, i) => { out[n] = match[i + 1] }) + return out +} + +const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes — generous; OAuth flows are slow + +type FlowState = { + user: string + serverName: string + serverUrl: string + envName: string + redirectUri: string + codeVerifier: string + clientId: string + clientSecret?: string + authorizationEndpoint: string + tokenEndpoint: string + scope?: string + createdAt: number +} + +/** + * Pending OAuth flows keyed by `state` parameter. Each entry self-expires + * after STATE_TTL_MS. Lazy cleanup on insert: every time we add an entry, we + * sweep expired ones. No background timer needed. + */ +class FlowStateMap { + private map = new Map<string, FlowState>() + + put(state: string, value: FlowState): void { + this.sweep() + this.map.set(state, value) + } + + /** Returns and **removes** the entry (one-shot consumption). */ + consume(state: string): FlowState | null { + const v = this.map.get(state) + if (!v) return null + this.map.delete(state) + if (Date.now() - v.createdAt > STATE_TTL_MS) return null + return v + } + + private sweep() { + const now = Date.now() + for (const [k, v] of this.map) { + if (now - v.createdAt > STATE_TTL_MS) this.map.delete(k) + } + } +} + +export const flowStates = new FlowStateMap() + +// ── helpers ──────────────────────────────────────────────────────────── + +function b64url(buf: Buffer): string { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +function genState(): string { + return b64url(randomBytes(24)) +} + +function genPkce(): { verifier: string; challenge: string } { + const verifier = b64url(randomBytes(32)) + const challenge = b64url(createHash("sha256").update(verifier).digest()) + return { verifier, challenge } +} + +/** + * Try a sequence of `.well-known` candidate URLs derived from a base URL, and + * return the first that responds with JSON. MCP spec says the protected- + * resource metadata sits at `<base>/.well-known/oauth-protected-resource` but + * servers vary on whether the MCP path segment is included. + */ +async function fetchJsonFirstOk(urls: string[], timeoutMs = 5000): Promise<{ url: string; json: any } | null> { + for (const url of urls) { + try { + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), timeoutMs) + const r = await fetch(url, { signal: ctrl.signal, headers: { Accept: "application/json" } }) + clearTimeout(t) + if (!r.ok) continue + const json = await r.json().catch(() => null) + if (json && typeof json === "object") return { url, json } + } catch {} + } + return null +} + +// ── RFC 9728 protected-resource metadata ─────────────────────────────── + +type ProtectedResourceMetadata = { + resource: string + authorization_servers: string[] +} + +async function discoverProtectedResource(serverUrl: string): Promise<ProtectedResourceMetadata | null> { + // The MCP server URL may be `https://host/mcp` or just `https://host`. + // Try both `host/.well-known/oauth-protected-resource/mcp` (path-suffixed) + // and `host/.well-known/oauth-protected-resource` (root). + const u = new URL(serverUrl) + const candidates = [ + `${u.origin}/.well-known/oauth-protected-resource${u.pathname}`.replace(/\/+$/, ""), + `${u.origin}/.well-known/oauth-protected-resource`, + ] + const r = await fetchJsonFirstOk(candidates) + if (!r) return null + const json = r.json + if ( + !json.authorization_servers || + !Array.isArray(json.authorization_servers) || + json.authorization_servers.length === 0 + ) { + return null + } + return json as ProtectedResourceMetadata +} + +// ── RFC 8414 authorization-server metadata ───────────────────────────── + +type AuthServerMetadata = { + issuer?: string + authorization_endpoint: string + token_endpoint: string + registration_endpoint?: string + scopes_supported?: string[] + grant_types_supported?: string[] + code_challenge_methods_supported?: string[] + /** Optional. If supplied, controls token endpoint authentication. */ + token_endpoint_auth_methods_supported?: string[] +} + +async function discoverAuthServer(authServerUrl: string): Promise<AuthServerMetadata | null> { + const u = new URL(authServerUrl) + const candidates = [ + `${u.origin}/.well-known/oauth-authorization-server${u.pathname}`.replace(/\/+$/, ""), + `${u.origin}/.well-known/oauth-authorization-server`, + // Some MCP servers expose this even when also hosting protected resource. + // (RFC 8414 doesn't standardize a path, but this is conventional.) + ] + const r = await fetchJsonFirstOk(candidates) + if (!r) return null + const json = r.json + if (!json.authorization_endpoint || !json.token_endpoint) return null + return json as AuthServerMetadata +} + +// ── OAuth capability probe ───────────────────────────────────────────── +// +// Tells the UI in advance whether loopat can OAuth into a given MCP server, +// so a "needs auth" button isn't shown for servers it can't actually handle +// (Slack / Google Drive / other consumer providers that don't expose DCR). + +export type OAuthSupport = + /** Auth server exposes registration_endpoint — loopat can DCR + auto-auth. */ + | "dcr" + /** OAuth flow exists but DCR isn't available — admin would need to manually + * register an app with the provider; loopat doesn't (yet) accept static + * client_id input, so this is effectively unsupported. */ + | "manual" + /** Server doesn't advertise OAuth (no .well-known/oauth-protected-resource). + * Either public, API-key-based, or some other auth scheme — loopat has + * nothing to do; CC connects directly. */ + | "none" + /** Probe failed — server unreachable, malformed metadata, etc. */ + | "unreachable" + +type ProbeResult = { support: OAuthSupport; probedAt: number } + +// In-memory cache. Keyed by server URL. TTL differs by result class so a +// transient "unreachable" doesn't get stuck for a day. +const probeCache = new Map<string, ProbeResult>() +const TTL_OK_MS = 24 * 60 * 60 * 1000 +const TTL_NEG_MS = 5 * 60 * 1000 + +export async function probeOAuthSupport(serverUrl: string, opts: { force?: boolean } = {}): Promise<OAuthSupport> { + const cached = probeCache.get(serverUrl) + if (!opts.force && cached) { + const ttl = cached.support === "dcr" || cached.support === "manual" ? TTL_OK_MS : TTL_NEG_MS + if (Date.now() - cached.probedAt < ttl) return cached.support + } + const support = await runProbe(serverUrl) + probeCache.set(serverUrl, { support, probedAt: Date.now() }) + return support +} + +async function runProbe(serverUrl: string): Promise<OAuthSupport> { + let prm + try { + prm = await discoverProtectedResource(serverUrl) + } catch { + return "unreachable" + } + if (!prm) return "none" + const authServerUrl = prm.authorization_servers[0] + let asm + try { + asm = await discoverAuthServer(authServerUrl) + } catch { + return "unreachable" + } + if (!asm) return "unreachable" + return asm.registration_endpoint ? "dcr" : "manual" +} + +/** Clear cached probe result(s). url omitted → clear everything. */ +export function evictOAuthProbe(url?: string): void { + if (url) probeCache.delete(url) + else probeCache.clear() +} + +// ── RFC 7591 dynamic client registration ─────────────────────────────── + +type DcrResponse = { + client_id: string + client_secret?: string + client_id_issued_at?: number + client_secret_expires_at?: number + redirect_uris?: string[] +} + +async function dynamicRegister( + registrationEndpoint: string, + redirectUri: string, +): Promise<DcrResponse | null> { + const body = { + client_name: "loopat", + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_basic", + } + try { + const r = await fetch(registrationEndpoint, { + method: "POST", + headers: { "content-type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }) + if (!r.ok) { + console.warn(`[loopat] DCR failed (${r.status}): ${await r.text().catch(() => "")}`) + return null + } + return (await r.json()) as DcrResponse + } catch (e: any) { + console.warn(`[loopat] DCR error: ${e?.message ?? e}`) + return null + } +} + +// ── flow: start ──────────────────────────────────────────────────────── + +export type StartResult = + | { ok: true; authorizationUrl: string; state: string } + | { ok: false; error: string } + +/** + * Look up a server in the loop's merged settings.json. Returns null when + * either the merged file is missing (loop never composed) or the server name + * isn't declared. + */ +async function lookupServerInMergedSettings( + loopId: string, + serverName: string, +): Promise<McpServerConfig | null> { + if (!loopId) return null + const { existsSync } = await import("node:fs") + const { readFile } = await import("node:fs/promises") + const { join } = await import("node:path") + const { loopClaudeDir } = await import("./paths") + const settingsPath = join(loopClaudeDir(loopId), "settings.json") + if (!existsSync(settingsPath)) return null + try { + const j = JSON.parse(await readFile(settingsPath, "utf8")) as { + mcpServers?: Record<string, McpServerConfig> + } + return j.mcpServers?.[serverName] ?? null + } catch { + return null + } +} + +/** + * Begin an OAuth flow for (user, serverName) in the context of `loopId`. The + * browser-side caller navigates to `authorizationUrl` next. The OAuth token, + * once obtained, lands in the user's personal default vault under the env + * name parsed from the server's `Authorization: Bearer ${VAR}` header. + */ +export async function startMcpAuth(opts: { + user: string + serverName: string + /** Loop the auth request originates from — used to resolve the server in + * the loop's merged settings.json. */ + loopId: string + publicBaseUrl: string +}): Promise<StartResult> { + const { user, serverName, loopId, publicBaseUrl } = opts + + const srv = await lookupServerInMergedSettings(loopId, serverName) + if (!srv) { + return { ok: false, error: `server "${serverName}" not found in loop's merged settings.json` } + } + if (srv.type !== "http" && srv.type !== "sse") { + return { ok: false, error: `server "${serverName}" is type "${srv.type}"; only http/sse support OAuth` } + } + const serverUrl = (srv as any).url as string + if (!serverUrl) return { ok: false, error: `server "${serverName}" missing url` } + + const envName = parseBearerEnvName(srv) + if (!envName) { + return { + ok: false, + error: `server "${serverName}" does not declare \`Authorization: Bearer \${VAR}\` in headers — loopat-managed OAuth requires that template`, + } + } + + // 1) discover protected-resource → list of authorization servers + const prm = await discoverProtectedResource(serverUrl) + if (!prm) { + return { + ok: false, + error: `failed to discover protected-resource metadata at ${serverUrl} — the server may not implement OAuth, or .well-known/oauth-protected-resource is unreachable`, + } + } + const authServerUrl = prm.authorization_servers[0] + + // 2) discover authorization-server metadata + const asm = await discoverAuthServer(authServerUrl) + if (!asm) { + return { ok: false, error: `failed to discover auth-server metadata at ${authServerUrl}` } + } + + // 3) DCR (RFC 7591) — if the server doesn't expose registration_endpoint we + // refuse with an actionable error. (Future: operator-supplied client_id + // fallback.) + const redirectUri = `${publicBaseUrl.replace(/\/+$/, "")}/api/mcp-auth/callback` + if (!asm.registration_endpoint) { + return { + ok: false, + error: `auth server ${authServerUrl} does not advertise registration_endpoint (DCR); operator-static client_id fallback not yet implemented`, + } + } + const reg = await dynamicRegister(asm.registration_endpoint, redirectUri) + if (!reg) return { ok: false, error: `dynamic client registration failed` } + + // 4) PKCE + state, stash, build authorization URL + const { verifier, challenge } = genPkce() + const state = genState() + + flowStates.put(state, { + user, + serverName, + serverUrl, + envName, + redirectUri, + codeVerifier: verifier, + clientId: reg.client_id, + clientSecret: reg.client_secret, + authorizationEndpoint: asm.authorization_endpoint, + tokenEndpoint: asm.token_endpoint, + scope: asm.scopes_supported?.join(" "), + createdAt: Date.now(), + }) + + const authUrl = new URL(asm.authorization_endpoint) + authUrl.searchParams.set("response_type", "code") + authUrl.searchParams.set("client_id", reg.client_id) + authUrl.searchParams.set("redirect_uri", redirectUri) + authUrl.searchParams.set("state", state) + authUrl.searchParams.set("code_challenge", challenge) + authUrl.searchParams.set("code_challenge_method", "S256") + if (asm.scopes_supported?.length) { + authUrl.searchParams.set("scope", asm.scopes_supported.join(" ")) + } + + return { ok: true, authorizationUrl: authUrl.toString(), state } +} + +// ── flow: callback ────────────────────────────────────────────────────── + +export type CallbackResult = + | { ok: true; serverName: string } + | { ok: false; error: string } + +/** + * Process the OAuth redirect coming back from the MCP server. Exchanges + * code+verifier for an access_token and writes it to the user's personal + * default vault under the env name captured at flow start. + */ +export async function completeMcpAuth(opts: { + state: string + code: string +}): Promise<CallbackResult> { + const flow = flowStates.consume(opts.state) + if (!flow) return { ok: false, error: `unknown or expired state` } + + // token endpoint exchange + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: opts.code, + redirect_uri: flow.redirectUri, + client_id: flow.clientId, + code_verifier: flow.codeVerifier, + }) + + const headers: Record<string, string> = { + "content-type": "application/x-www-form-urlencoded", + Accept: "application/json", + } + if (flow.clientSecret) { + headers["Authorization"] = + "Basic " + + Buffer.from(`${encodeURIComponent(flow.clientId)}:${encodeURIComponent(flow.clientSecret)}`).toString("base64") + } + + let resp: Response + try { + resp = await fetch(flow.tokenEndpoint, { method: "POST", headers, body }) + } catch (e: any) { + return { ok: false, error: `token exchange network error: ${e?.message ?? e}` } + } + if (!resp.ok) { + return { ok: false, error: `token exchange ${resp.status}: ${await resp.text().catch(() => "")}` } + } + const tok = await resp.json().catch(() => null) + if (!tok?.access_token) return { ok: false, error: `token response missing access_token` } + + // Persist as a plain vault env in the user's personal default vault. + // Refresh/revoke aren't implemented yet; refresh_token (if present) is + // dropped intentionally — re-running the flow ("Re-authorize" in /mcp) + // is the only refresh path today. + await writeVaultEnv(flow.user, DEFAULT_VAULT, flow.envName, tok.access_token) + + // Token is persisted, but **already-running** LoopSessions still hold the + // old `query()` options. We intentionally do NOT auto-restart them here: + // that would interrupt long-running generations the user may have started + // in other loops. The /mcp popover exposes an explicit "Reload" button so + // the user reloads on their own terms, on the loop they're currently in. + return { ok: true, serverName: flow.serverName } +} diff --git a/server/src/paths.ts b/server/src/paths.ts new file mode 100644 index 00000000..f8e7edf2 --- /dev/null +++ b/server/src/paths.ts @@ -0,0 +1,226 @@ +import { homedir } from "node:os" +import { basename, dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +/** + * LOOPAT_HOME *is* the workspace directory. Single-workspace by design — to + * run a second workspace, start a second loopat instance with a different + * LOOPAT_HOME. Default `~/.loopat`. The display/URL name is the basename + * with leading dots stripped (so `~/.loopat` → "loopat"). + */ +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, "../..") +export const TEMPLATES_DIR = join(LOOPAT_INSTALL_DIR, "server", "templates") + +export const WORKSPACE = basename(LOOPAT_HOME).replace(/^\.+/, "") || "loopat" + +export const workspaceDir = () => LOOPAT_HOME +export const usersPath = () => join(LOOPAT_HOME, "users.json") +export const loopsDir = () => join(LOOPAT_HOME, "loops") +export const workspaceContextDir = () => join(LOOPAT_HOME, "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) +// Local git hosting: when a context repo has no remote, loopat hosts the +// `origin` itself as a bare repo here (docs/context-flow.md "solo"). +export const workspaceOriginsDir = () => join(LOOPAT_HOME, "origins") +export const workspaceOriginPath = (name: string) => join(workspaceOriginsDir(), `${name}.git`) +// External git-host provider extensions (e.g. internal "Code" platform). Drop a +// duck-typed provider file here; loopat loads it without any core change. +export const extensionsProvidersDir = () => join(LOOPAT_HOME, "extensions", "providers") +export const personalDir = (user: string) => join(LOOPAT_HOME, "personal", user) + +// Per-user context main repos. knowledge/notes/repos are NOT workspace-shared: +// each user's loop sees ONLY what their personal.knowledge points at (no +// fallback to the workspace default). These are the main repos the loop's +// context worktrees are derived from — the per-user analogue of the shared +// workspaceKnowledgeDir()/workspaceNotesDir(). Live under context/users/<user>/ +// so they never collide with the workspace-default context/knowledge. +export const userContextDir = (user: string) => join(workspaceContextDir(), "users", user) +export const personalKnowledgeDir = (user: string) => join(userContextDir(user), "knowledge") +export const personalNotesDir = (user: string) => join(userContextDir(user), "notes") +export const personalReposDir = (user: string) => join(userContextDir(user), "repos") +export const personalRepoDir = (user: string, name: string) => join(personalReposDir(user), name) +// Bare mirror cache for a roster repo — host-only (NOT mounted into the sandbox). +// Cloned once, fetched per new-loop; loop workdirs are `git worktree add`'d off +// it; pushes from those worktrees go straight to origin. Kept OUT of +// personalReposDir so the sandbox's context/repos stays a clean clone-on-demand +// area (just REPOS.md), never a pile of bare repos. +export const personalRepoCacheRoot = (user: string) => join(userContextDir(user), "repo-cache") +export const personalRepoCacheDir = (user: string, name: string) => join(personalRepoCacheRoot(user), name) +// The per-user knowledge repo's .loopat root (holds its config.json = notes + +// repo roster). Workspace-default equivalent is workspaceLoopatRoot(). +export const personalKnowledgeLoopatRoot = (user: string) => join(personalKnowledgeDir(user), ".loopat") + +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 loopContextRepos = (id: string) => join(loopContextDir(id), "repos") +export const loopContextChatDir = (id: string) => join(loopContextDir(id), "chat") +export const loopMetaPath = (id: string) => join(loopDir(id), "meta.json") + +// UI loop checkouts — a per-user worktree for editing team context (notes) from +// outside any AI loop (a "no-AI UI loop", see docs/context-flow.md). Disposable: +// opened from origin/main, synced back ff-only. +export const uiDir = (user: string) => join(LOOPAT_HOME, "ui", user) +export const uiNotesDir = (user: string) => join(uiDir(user), "notes") +export const loopHistoryPath = (id: string) => join(loopDir(id), "messages.jsonl") +export const loopChatHistoryPath = (id: string) => join(loopDir(id), "chat_history.jsonl") + +export const chatDbPath = () => join(LOOPAT_HOME, "chat.db") + +export const personalMemoryDir = (user: string) => join(personalDir(user), "memory") +export const workspaceMemoryDir = () => join(workspaceNotesDir(), "memory") +// (Old `workspaceLoopat{Reserved,Claude,Skills,Agents,Sandbox*}` helpers +// deleted — superseded by the tiered .claude/ model. Workspace CC config now +// lives at `<knowledge>/.loopat/.claude/` and is accessed via +// `workspaceTeamClaudeDir/SettingsPath/ClaudeMdPath/SkillsDir/AgentsDir` below.) + +// Per-loop $HOME overlay (docker container layer for home). The sandbox's +// $HOME is an overlayfs mount: lower = workspaceHomeSkelDir (shared skeleton, +// typically empty), upper = home-upper (per-loop persistent diff), work = +// home-work (overlayfs internal scratch). merged is the mount point that +// bwrap binds into the sandbox at $HOME. Persists across loop restarts; AI's +// pip/npm installs and shell history survive. +export const loopHomeUpper = (id: string) => join(loopDir(id), "home-upper") +export const loopHomeWork = (id: string) => join(loopDir(id), "home-work") +export const loopHomeMerged = (id: string) => join(loopDir(id), "home-merged") +// Workspace-shared base layer for the home overlay. User can drop default +// dotfiles in here; left empty by default. +export const workspaceHomeSkelDir = () => join(LOOPAT_HOME, "sandbox-home-skel") +// Bundled platform doctrine — ships with loopat code, always present. +export const bundledDoctrinePath = () => join(TEMPLATES_DIR, "CLAUDE.md") + +// Per-loop-kind templates (distill, future: review, plan, etc.). Each kind +// has its own dir; createLoop / distillLoop copies the kind's CLAUDE.md into +// the new loop's workdir as the L2++ project-tier doctrine. +export const loopKindTemplateDir = (kind: string) => join(TEMPLATES_DIR, "loop-kinds", kind) +export const loopKindClaudePath = (kind: string) => join(loopKindTemplateDir(kind), "CLAUDE.md") + +// Personal `.loopat/` reserved namespace: per-user loopat config + vaults. +// Mirrors `knowledge/.loopat/` as the personal counterpart. +// +// Vault model: each loop selects one vault (default = "default"). The vault is +// NOT exposed to the sandbox as a directory. Instead two filesystem conventions +// inside the vault drive automatic delivery at spawn time: +// - `vaults/<v>/envs/<NAME>` → injected as env var $NAME +// - `vaults/<v>/mounts/home/<rel>/...` → bound at $HOME/<rel>/... +// AI never sees "vault" as a concept — it just sees a configured machine. +export const personalLoopatDir = (user: string) => join(personalDir(user), ".loopat") +export const personalLoopatConfigPath = (user: string) => join(personalLoopatDir(user), "config.json") +export const personalVaultsDir = (user: string) => join(personalLoopatDir(user), "vaults") +// Personal `.claude/` — CC-native shape. The 4th layer in loopat's tiered +// .claude merge (workspace + profiles + personal + repo). Lives under +// `.loopat/` to mirror the team convention (`knowledge/.loopat/.claude/`): +// loopat-controlled config goes under `.loopat/` so the personal repo's +// other content (memory, scratch files) stays cleanly separate. +// Contains: settings.json, CLAUDE.md, skills/, agents/. +export const personalClaudeDir = (user: string) => join(personalLoopatDir(user), ".claude") +export const personalClaudeMdPath = (user: string) => join(personalClaudeDir(user), "CLAUDE.md") +export const personalSettingsPath = (user: string) => join(personalClaudeDir(user), "settings.json") +export const personalSkillsDir = (user: string) => join(personalClaudeDir(user), "skills") +export const personalAgentsDir = (user: string) => join(personalClaudeDir(user), "agents") +// Composed output inside each loop's .claude/. Regenerated every spawn. +// Plugin loading does NOT touch the loop's .claude/ — SDK loads plugins via +// its `plugins` option (resolved from server cache; see plugin-installer.ts). +export const loopComposedSkillsDir = (id: string) => join(loopDir(id), ".claude", "skills") +export const loopComposedAgentsDir = (id: string) => join(loopDir(id), ".claude", "agents") + +// Platform-shipped builtin plugins live under server/templates/plugins/<name>/. +// They're always loaded into every loop (plugin-installer.ts:resolveBuiltinPlugins). +// No marketplace wrapper — direct path injection via SDK plugins option. +export const personalVaultDir = (user: string, vault: string) => join(personalVaultsDir(user), vault) +/** Convention dir: every file under this dir is auto-injected as an env var + * at spawn time. Filename = env var name. content = value (trailing newline + * stripped). Subdirs not recursed. */ +export const personalVaultEnvsDir = (user: string, vault: string) => + join(personalVaultDir(user, vault), "envs") +/** Path to a specific env-var file inside the vault. */ +export const personalVaultEnvPath = (user: string, vault: string, name: string) => + join(personalVaultEnvsDir(user, vault), name) +/** Convention dir: every top-level entry under this is auto-bound at the + * corresponding $HOME-relative path. e.g. `mounts/home/.ssh/` → `$HOME/.ssh/`. */ +export const personalVaultMountsHomeDir = (user: string, vault: string) => + join(personalVaultDir(user, vault), "mounts", "home") + +// Host-only per-user state: deploy key (loopat → personal repo) and git-crypt +// key (decrypts secrets/ inside the cloned personal repo). Kept OUTSIDE +// personal/<user>/ so it never appears in the sandbox bind view. The user +// can't see these from inside their loop's terminal / file browser. +export const hostSecretsDir = (user: string) => join(LOOPAT_HOME, "host-secrets", user) +export const hostDeployKeyPath = (user: string) => join(hostSecretsDir(user), "deploy-key") +export const hostDeployKeyPubPath = (user: string) => join(hostSecretsDir(user), "deploy-key.pub") +export const personalGitCryptKeyPath = (user: string) => join(hostSecretsDir(user), "git-crypt.key") +export const personalTokenUsagePath = (user: string) => join(personalLoopatDir(user), "token-usage.json") +export const workspaceSecretsDir = () => join(workspaceDir(), "secrets") + +// ─── Profile composition model (post-2026-05 design, CC-native refactor) ─ +// +// See docs/composition.md. The team workspace lives inside the +// knowledge git repo at `.loopat/`, structured as a stack of CC-native +// `.claude/` directories — one per tier (team / profile). loopat materializes +// a merge of selected tiers into each loop's `.claude/`. +// +// knowledge/.loopat/ +// .claude/ ← team-tier CC config +// settings.json (enabledPlugins, extraKnownMarketplaces) +// CLAUDE.md, skills/, agents/ +// profiles/<name>/.claude/ ← profile-tier CC config +// settings.json, CLAUDE.md, skills/, agents/ +// marketplace/ ← team's local CC marketplace +// .claude-plugin/marketplace.json +// <plugin>/... +// +// No loopat-invented schema (profile.json gone). Admins use CC's own +// commands (`claude plugin install --scope=project` etc.) inside these +// dirs to edit team / profile configuration. +export const workspaceLoopatRoot = () => join(workspaceKnowledgeDir(), ".loopat") + +// Team-tier .claude/ — analogous to ~/.claude/ but shared across team via git. +export const workspaceTeamClaudeDir = () => join(workspaceLoopatRoot(), ".claude") +export const workspaceTeamSettingsPath = () => join(workspaceTeamClaudeDir(), "settings.json") +export const workspaceTeamClaudeMdPath = () => join(workspaceTeamClaudeDir(), "CLAUDE.md") +export const workspaceTeamSkillsDir = () => join(workspaceTeamClaudeDir(), "skills") +export const workspaceTeamAgentsDir = () => join(workspaceTeamClaudeDir(), "agents") + +// Profiles — each is a dir with a `.claude/` subdir (CC project-tier shape). +export const workspaceProfilesDir = () => join(workspaceLoopatRoot(), "profiles") +export const workspaceProfileDir = (name: string) => join(workspaceProfilesDir(), name) +export const workspaceProfileClaudeDir = (name: string) => + join(workspaceProfileDir(name), ".claude") +export const workspaceProfileSettingsPath = (name: string) => + join(workspaceProfileClaudeDir(name), "settings.json") + +// Per-user equivalents: a loop sources team .claude / profiles from the loop +// creator's PER-USER knowledge repo (context/users/<user>/knowledge), not the +// workspace-default one. Mirrors the per-user notes/repos model. +export const personalKnowledgeTeamClaudeDir = (user: string) => + join(personalKnowledgeLoopatRoot(user), ".claude") +export const personalKnowledgeProfilesDir = (user: string) => + join(personalKnowledgeLoopatRoot(user), "profiles") +export const personalKnowledgeProfileDir = (user: string, name: string) => + join(personalKnowledgeProfilesDir(user), name) +export const personalKnowledgeProfileClaudeDir = (user: string, name: string) => + join(personalKnowledgeProfileDir(user, name), ".claude") +export const personalKnowledgeProfileClaudeMdPath = (user: string, name: string) => + join(personalKnowledgeProfileClaudeDir(user, name), "CLAUDE.md") +export const workspaceProfileClaudeMdPath = (name: string) => + join(workspaceProfileClaudeDir(name), "CLAUDE.md") +export const workspaceProfileSkillsDir = (name: string) => + join(workspaceProfileClaudeDir(name), "skills") +export const workspaceProfileAgentsDir = (name: string) => + join(workspaceProfileClaudeDir(name), "agents") + +// (Marketplace location is NOT a loopat convention. Teams choose where to +// host private plugins — typically `<knowledge>/marketplace/` — and declare +// it via `extraKnownMarketplaces` in their team `.claude/settings.json`. +// loopat just registers whatever's declared; it doesn't probe fixed paths.) diff --git a/server/src/personal-keys.ts b/server/src/personal-keys.ts new file mode 100644 index 00000000..c43a8d0f --- /dev/null +++ b/server/src/personal-keys.ts @@ -0,0 +1,60 @@ +/** + * Loopat-managed SSH keypair for the user's personal git repo (deploy key). + * + * Lives under `host-secrets/<user>/deploy-key` — OUTSIDE personal/<user>/ so + * it never enters the sandbox bind view. The user can't see this key from + * inside their loop. It's loopat-the-platform's clone credential, not a + * user-owned tool. + * + * Public key is rendered to the UI once at register time so the user can + * register it as a deploy key on their personal git repo (aone / github). + * + * Idempotent: if the keypair already exists, returns the existing public key. + */ +import { existsSync } from "node:fs" +import { mkdir, readFile, chmod } from "node:fs/promises" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { + hostSecretsDir, + hostDeployKeyPath, + hostDeployKeyPubPath, +} from "./paths" + +const execFileP = promisify(execFile) + +/** + * Generate ed25519 keypair if missing. Tolerant: if `ssh-keygen` is not on + * PATH (host missing openssh-client) returns `{ publicKey: null }` so the + * rest of register/provision proceeds — user can install ssh-keygen later + * and retrigger key gen via /api/personal/import (which calls this again). + */ +export async function ensurePersonalKeypair(userId: string): Promise<{ publicKey: string | null }> { + const dir = hostSecretsDir(userId) + const priv = hostDeployKeyPath(userId) + const pub = hostDeployKeyPubPath(userId) + + await mkdir(dir, { recursive: true }) + await chmod(dir, 0o700).catch(() => {}) + + if (!existsSync(priv) || !existsSync(pub)) { + const comment = `loopat:${userId}` + try { + await execFileP("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", comment, "-f", priv, "-q"]) + } catch (e: any) { + console.warn(`[loopat] ssh-keygen failed for user=${userId}: ${e?.message ?? e}. Install openssh-client to enable deploy-key flow.`) + return { publicKey: null } + } + await chmod(priv, 0o600).catch(() => {}) + await chmod(pub, 0o644).catch(() => {}) + } + + const publicKey = (await readFile(pub, "utf8")).trim() + return { publicKey } +} + +export async function getPublicKey(userId: string): Promise<string | null> { + const pub = hostDeployKeyPubPath(userId) + if (!existsSync(pub)) return null + return (await readFile(pub, "utf8")).trim() +} diff --git a/server/src/plugin-installer.ts b/server/src/plugin-installer.ts new file mode 100644 index 00000000..e89938f8 --- /dev/null +++ b/server/src/plugin-installer.ts @@ -0,0 +1,287 @@ +/** + * Plugin orchestration on the host. Post-wholesale-bind (2026-05): the inner + * SDK now resolves enabledPlugins natively because bwrap.ts ro-binds + * ~/.claude/plugins/ wholesale into the sandbox. So loopat's job here is + * purely host-side preparation: + * + * 1. Make sure every marketplace declared in the loop's merged + * `extraKnownMarketplaces` is registered with the host CC. + * 2. Make sure every spec in `enabledPlugins` is installed in the host CC + * cache (otherwise SDK would find an enabled-but-uninstalled spec and + * fail to load it). + * + * That's it — no path resolution, no return value beyond success/failure. The + * `plugins:` SDK option is reserved for the loopat-shipped builtin (which + * lives under LOOPAT_INSTALL_DIR, not in CC's plugin cache). + * + * `lookupPluginInstallPath` remains a host-side utility for the slash-command + * pre-seed (session.ts) and the loop-stats preview (loop-stats.ts). + */ +import { existsSync, statSync } from "node:fs" +import { readFile } from "node:fs/promises" +import { execFile } from "node:child_process" +import { homedir } from "node:os" +import { join, resolve as resolvePath } from "node:path" +import { promisify } from "node:util" +import { TEMPLATES_DIR, loopClaudeDir } from "./paths" + +const execFileP = promisify(execFile) + +/** loopat-shipped builtin plugin (not in CC's plugin cache; passed via SDK option). */ +export const BUILTIN_LOOPAT_PLUGIN_PATH = join(TEMPLATES_DIR, "plugins", "loopat") + +const USER_CLAUDE_DIR = join(homedir(), ".claude") +const USER_INSTALLED_PLUGINS = join(USER_CLAUDE_DIR, "plugins", "installed_plugins.json") +const USER_KNOWN_MARKETPLACES = join(USER_CLAUDE_DIR, "plugins", "known_marketplaces.json") + +type InstalledPluginsFile = { + version: number + plugins: Record<string, Array<{ installPath: string; version: string; scope?: string }>> +} +type KnownMarketplacesFile = Record<string, { installLocation?: string; source?: any }> +type MarketplaceCatalog = { + plugins?: Array<{ name: string; source: string | { source: string; [k: string]: any } }> +} + +async function readJsonOpt<T>(path: string): Promise<T | null> { + if (!existsSync(path)) return null + try { + return JSON.parse(await readFile(path, "utf8")) as T + } catch { + return null + } +} + +async function runClaude(args: string[]): Promise<{ ok: boolean; out: string; err: string }> { + try { + const { stdout, stderr } = await execFileP("claude", args) + return { ok: true, out: stdout, err: stderr } + } catch (e: any) { + return { + ok: false, + out: e?.stdout?.toString?.() ?? "", + err: e?.stderr?.toString?.() ?? e?.message ?? String(e), + } + } +} + +/** + * Compare two marketplace sources (from settings.json and from CC's + * known_marketplaces.json). Used to detect URL/path drift — if a team admin + * changes the marketplace URL, members' host CC needs to re-register. + */ +export function sourcesMatch(declared: any, existing: any): boolean { + if (declared === existing) return true + if (!declared || !existing) return false + if (typeof declared !== "object" || typeof existing !== "object") return false + if (declared.source !== existing.source) return false + switch (declared.source) { + case "git": + case "url": + return declared.url === existing.url + case "github": + return (declared.repo ?? declared.repository) === (existing.repo ?? existing.repository) + case "directory": + return declared.path === existing.path + default: + return JSON.stringify(declared) === JSON.stringify(existing) + } +} + +async function ensureMarketplace( + name: string, + addPath: string, + declaredSource: any, + km: KnownMarketplacesFile | null, +): Promise<void> { + const existing = (km?.[name] as any)?.source + if (existing) { + if (sourcesMatch(declaredSource, existing)) return + console.warn( + `[plugins] marketplace "${name}" source drift; re-registering ` + + `(was ${JSON.stringify(existing)}, want ${JSON.stringify(declaredSource)})`, + ) + await runClaude(["plugin", "marketplace", "remove", name]) + } + const add = await runClaude(["plugin", "marketplace", "add", addPath]) + if (!add.ok) { + console.warn(`[plugins] failed to register marketplace "${name}": ${add.err}`) + } +} + +async function ensureExtraMarketplaces( + extras: Record<string, { source?: any }> | undefined, + loopId: string, + km: KnownMarketplacesFile | null, +): Promise<void> { + if (!extras) return + for (const [name, entry] of Object.entries(extras)) { + const src = entry?.source as any + let addPath: string | undefined + let normalized: any = src + if (typeof src === "string") { + addPath = src + normalized = { source: "github", repo: src } + } else if (src?.source === "directory" && typeof src.path === "string") { + addPath = resolvePath(loopClaudeDir(loopId), src.path) + normalized = { source: "directory", path: addPath } + } else if (src?.source === "github" && typeof src.repo === "string") { + addPath = src.repo + normalized = { source: "github", repo: src.repo } + } else if ((src?.source === "git" || src?.source === "url") && typeof src.url === "string") { + addPath = src.url + normalized = { source: src.source, url: src.url } + } + if (!addPath) { + console.warn(`[plugins] extraKnownMarketplaces["${name}"]: unsupported source shape, skip`) + continue + } + await ensureMarketplace(name, addPath, normalized, km) + } +} + +/** + * Install + version-pin check. + * + * `lockedVersions` maps spec → required version label, sourced from the loop's + * snapshot installed_plugins.json (compose merged it from team/profile/personal + * tiers). When provided we check the host already has that exact version in + * its CC cache; if not, run `claude plugin install` and verify the resulting + * version matches. + * + * Mismatch is loud-failure (option a — see docs/composition.md). The recovery + * is for the user to either restore the pinned version manually (e.g. via + * marketplace clone checkout dance) or for admin to bump the team lock. + * Option b (loopat does the checkout dance automatically) is a future add. + * + * When `lockedVersions[spec]` is null/undefined: no version pin → fall back to + * the old "install if missing" behavior. + */ +async function ensurePluginsInstalled( + specs: string[], + ip: InstalledPluginsFile | null, + lockedVersions: Record<string, string | undefined>, +): Promise<void> { + if (specs.length === 0) return + const installedKeys = new Set(Object.keys(ip?.plugins ?? {})) + for (const spec of specs) { + const wantVersion = lockedVersions[spec] + const hostEntry = ip?.plugins?.[spec]?.[0] + const hostVersion = hostEntry?.version + + if (wantVersion && hostVersion === wantVersion) continue // pinned + matches + if (!wantVersion && installedKeys.has(spec)) continue // not pinned + installed + + const r = await runClaude(["plugin", "install", spec, "--scope=user"]) + if (!r.ok) { + console.warn(`[plugins] install failed for "${spec}": ${r.err.trim().split("\n").slice(-2).join(" | ")}`) + continue + } + + // Verify post-install version matches the pin, if there is one. + if (wantVersion) { + const ip2 = await readJsonOpt<InstalledPluginsFile>(USER_INSTALLED_PLUGINS) + const got = ip2?.plugins?.[spec]?.[0]?.version + if (got !== wantVersion) { + throw new Error( + `[plugins] version mismatch for "${spec}": loop pinned "${wantVersion}", ` + + `host installed "${got ?? "<unknown>"}". CC's marketplace clone has likely ` + + `advanced past the pinned version. Options:\n` + + ` 1. Admin: bump the team lock — set this spec's version to "${got ?? "<new>"}" ` + + `in knowledge/.loopat/.claude/plugins/installed_plugins.json + git push knowledge.\n` + + ` 2. Member: restore the pinned version manually — checkout marketplace at the ` + + `pinned gitCommitSha, run \`claude plugin install ${spec}\`, then checkout master.`, + ) + } + } + } +} + +/** + * Mtime cache on loops/<id>/.claude/settings.json — we only re-run marketplace + * registration + install when compose actually rewrote settings. + */ +type EnsureCacheEntry = { mtime: number } +const ensureCache = new Map<string, EnsureCacheEntry>() + +/** + * Idempotently ensure the host CC has every marketplace + enabled plugin + * installed that the loop's merged settings.json declares. No return value — + * the inner SDK resolves enabledPlugins natively at spawn time (via the + * wholesale ~/.claude/plugins/ bind in bwrap.ts). + */ +export async function ensureLoopPluginsInstalled(loopId: string): Promise<void> { + const settingsPath = join(loopClaudeDir(loopId), "settings.json") + const mtime = existsSync(settingsPath) ? statSync(settingsPath).mtimeMs : 0 + + const cached = ensureCache.get(loopId) + if (cached && cached.mtime === mtime) return + + const settings = await readJsonOpt<{ + enabledPlugins?: Record<string, boolean> + extraKnownMarketplaces?: Record<string, { source?: any }> + }>(settingsPath) + + const enabled = Object.entries(settings?.enabledPlugins ?? {}) + .filter(([_, v]) => v) + .map(([k]) => k) + + if (enabled.length === 0 && !settings?.extraKnownMarketplaces) { + ensureCache.set(loopId, { mtime }) + return + } + + const km = await readJsonOpt<KnownMarketplacesFile>(USER_KNOWN_MARKETPLACES) + const ip = await readJsonOpt<InstalledPluginsFile>(USER_INSTALLED_PLUGINS) + + // Read the loop's plugin version lock (compose's merged installed_plugins.json + // snapshot). When present, ensurePluginsInstalled enforces the pinned + // version per spec; otherwise it falls back to "install if missing". + const lockPath = join(loopClaudeDir(loopId), "plugins", "installed_plugins.json") + const lock = await readJsonOpt<InstalledPluginsFile>(lockPath) + const lockedVersions: Record<string, string | undefined> = {} + for (const [spec, entries] of Object.entries(lock?.plugins ?? {})) { + lockedVersions[spec] = entries[0]?.version + } + + await ensureExtraMarketplaces(settings?.extraKnownMarketplaces, loopId, km) + await ensurePluginsInstalled(enabled, ip, lockedVersions) + + ensureCache.set(loopId, { mtime }) +} + +/** + * Resolve a `name@marketplace` spec to a host path (best-effort, no install + * side-effect). Used by: + * - session.ts: pre-seed plugin slash-commands before CC's init payload + * - loop-stats.ts: count plugin contributions for the NewLoopDialog preview + * + * Prefers the marketplace's local source dir (preserves symlinks); falls back + * to the CC cache installPath. Returns null if not installed. + */ +export async function lookupPluginInstallPath(spec: string): Promise<string | null> { + const ip = await readJsonOpt<InstalledPluginsFile>(USER_INSTALLED_PLUGINS) + const km = await readJsonOpt<KnownMarketplacesFile>(USER_KNOWN_MARKETPLACES) + if (!ip) return null + const entry = ip.plugins?.[spec]?.[0] + if (!entry?.installPath) return null + + const atIdx = spec.lastIndexOf("@") + if (atIdx >= 0) { + const pluginName = spec.slice(0, atIdx) + const marketName = spec.slice(atIdx + 1) + const market = km?.[marketName] + if (market?.installLocation) { + const catalog = await readJsonOpt<MarketplaceCatalog>( + join(market.installLocation, ".claude-plugin", "marketplace.json"), + ) + const cat = catalog?.plugins?.find((p) => p.name === pluginName) + const src = typeof cat?.source === "string" ? cat.source : null + if (src?.startsWith("./")) { + const p = join(market.installLocation, src) + if (existsSync(p)) return p + } + } + } + return existsSync(entry.installPath) ? entry.installPath : null +} diff --git a/server/src/podman.ts b/server/src/podman.ts new file mode 100644 index 00000000..6811b247 --- /dev/null +++ b/server/src/podman.ts @@ -0,0 +1,1395 @@ +/** + * Podman-based sandbox: one long-lived rootless container per loop. Both SDK + * CLI and PTY bash run inside the same container via `podman exec`, so they + * share PID / Mount / IPC namespaces — the terminal can `ps` and see what the + * AI is running, and vice versa. Idle → `podman stop` → kernel reaps the + * namespace. + * + * Naming note: this module is internal — "podman" is the implementation + * mechanism. User-facing concept stays "sandbox" (see docs/sandbox.md). + * + * Key decisions: + * - Base image is `loopat-sandbox:latest`, built locally on first run from + * server/templates/sandbox/Containerfile (FROM Aliyun AC2 Ubuntu 24.04 + bash + + * coreutils + util-linux + procps + less). Keeps the image small + boring + * — every "heavy" tool (claude binary, node, mise, host caches) is bound + * in from the host at container-create time via --volume. Glibc inside + * the image matches the host (both Ubuntu 24.04 lineage), so host-built + * binaries Just Work. + * - slirp4netns (default rootless): each container gets a private IP + * (10.0.2.x); outbound API calls via NAT, inbound via container IP. + * - --userns=keep-id: host uid is mapped to the same uid inside, so files + * created by the AI are owned by the user on the host too. Rootless + * subuid/subgid mappings (see /etc/subuid) make this work. + * - --init: podman auto-injects catatonit (or tini) as PID 1 so zombies + * from orphaned background processes get reaped. + * - Long-lived container with `sleep infinity` as the main command. Both + * SDK and PTY are `podman exec` siblings of this. + * + * Two mount-authority tiers (same model as bwrap): + * - operator: ~/.dashscope/config.json `mounts` (any host path) + * - member: convention-based via `vaults/<v>/mounts/home/<rel>/...` → $HOME/<rel>/... + * - admin: no mount capability + * + * See memory: project_loop_dir_is_sandbox.md + */ +import { execFile, spawn } from "node:child_process" +import { createHash } from "node:crypto" +import { existsSync, readdirSync, statSync } from "node:fs" +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { homedir, tmpdir } from "node:os" +import { join, dirname } from "node:path" +import { promisify } from "node:util" +import { + WORKSPACE, + loopWorkdir, + loopClaudeDir, + loopsDir, + loopContextChatDir, + loopContextKnowledge, + loopContextNotes, + personalDir, + personalKnowledgeDir, + personalNotesDir, + personalReposDir, + personalRepoCacheDir, + LOOPAT_INSTALL_DIR, + loopHomeUpper, + workspaceHomeSkelDir, + loopDir, + personalVaultMountsHomeDir, +} from "./paths" +import { loadConfig } from "./config" +import { DEFAULT_VAULT, listVaultHomeMounts } from "./vaults" +import { hostExecDir, writeHostShims } from "./host-exec" +import { resolveSandboxClaudeBinary } from "./claude-binary" +import { parse as tomlParse, stringify as tomlStringify } from "smol-toml" + +const execFileP = promisify(execFile) + +// ── Virtual paths (kept identical to bwrap era so AI doctrine still applies) ── +export const V_LOOP = (id: string) => `/loopat/loop/${id}` +export const V_LOOP_WORKDIR = (id: string) => `/loopat/loop/${id}/workdir` +export const V_LOOP_CLAUDE = (id: string) => `/loopat/loop/${id}/.claude` +export const V_ALL_LOOPS = "/loopat/loops" +export const V_CONTEXT_KNOWLEDGE = "/loopat/context/knowledge" +export const V_CONTEXT_NOTES = "/loopat/context/notes" +export const V_CONTEXT_NOTES_MEMORY = "/loopat/context/notes/memory" +export const V_CONTEXT_PERSONAL = "/loopat/context/personal" +export const V_CONTEXT_PERSONAL_MEMORY = "/loopat/context/personal/memory" +export const V_CONTEXT_REPOS = "/loopat/context/repos" +export const V_CONTEXT_CHAT = "/loopat/context/chat" + +// host-cli proxy: the dir holding the host-exec unix socket, mounted in so the +// loop's `loopat-host` forwarder can reach the host. Mount the DIR (not the +// socket file) so a server restart that recreates the socket inode stays +// visible inside running containers. +export const V_HOST_EXEC_DIR = "/loopat/host-exec" +export const V_HOST_EXEC_SOCK = "/loopat/host-exec/host-exec.sock" + +// $HOME inside the container. MUST equal the sandbox user's /etc/passwd home, +// otherwise ssh/git resolve `~` (e.g. ~/.ssh) via getpwuid (= passwd home), NOT +// $HOME — so a vault mounted at $HOME/.ssh is invisible to ssh and every +// sandbox-side `git push`/clone fails "Host key verification failed" / can't +// find the key. The image's `loopat` user (uid 2000) has passwd home +// /home/loopat, so we use exactly that. +// +// Still NOT the host's homedir: binding host $HOME at its real path makes podman +// auto-create nested-bind parent dirs owned by a subuid the host can't delete. +// /home/loopat is a CONTAINER-internal path — host-absolute binds sit outside +// it, and it vanishes with the container, so there's no host residue. (Per-user +// distinction is unnecessary: each loop has its own isolated container + home +// overlay; the home path inside need not encode the user.) +export const V_HOME = (_user: string) => `/home/loopat` + +// Label keys for podman inspect. +const LABEL_LOOP = "loopat.loop-id" +const LABEL_WORKSPACE = "loopat.workspace" +const LABEL_CONFIG_HASH = "loopat.config-hash" + +// Image used as the base for every loop container. Built locally from +// server/templates/sandbox/Containerfile via ensureSandboxImage(). +// Per-workspace image name so multiple LOOPAT_HOMEs on one host don't share +// (and can't accidentally delete) each other's images. `uninstall` finds them +// by the loopat.workspace label, not this name — the name only prevents tag +// collisions. Same-Containerfile builds still share overlay layers, so the +// per-workspace tags don't multiply disk usage. +// Image tag is content-addressed (no workspace prefix) so the same image is +// reused across workspaces/LOOPAT_HOMEs instead of rebuilt per workspace. The +// trade-off (deliberate): deleting a workspace no longer prunes its images — +// we'd rather leave a residual image than rebuild on every fresh workspace. +// Containers + their LABEL_WORKSPACE stay workspace-scoped (runtime isolation). +export const SANDBOX_IMAGE = process.env.LOOPAT_SANDBOX_IMAGE || `loopat-sandbox:latest` +// Prebuilt multi-arch base image published to GHCR by CI, tagged by the +// Containerfile content hash. ensureSandboxImage pulls this instead of building +// locally — a pull is faster and far more reliable than apt-installing ~150 +// packages over a flaky China mirror. Falls back to a local build when the pull +// fails (ghcr unreachable on this network, or a locally-modified Containerfile +// whose hash was never published). Override the repo via env for forks. +const SANDBOX_IMAGE_REF = process.env.LOOPAT_SANDBOX_IMAGE_REF || "ghcr.io/simpx/loopat-sandbox" + +// Container name: prefix with workspace to avoid collisions between loopat +// instances running on the same host with different LOOPAT_HOME. Loop UUIDs +// are already globally unique; the prefix is for human grep. +export function containerName(loopId: string): string { + return `loopat-${WORKSPACE}-${loopId}` +} + +export type ContainerOptions = { + loopId: string + createdBy: string + vaultName?: string + knowledgeRw?: boolean + mountAllLoops?: boolean + /** Source roster repo for this loop's workdir (meta.repo). The workdir is a + * `git worktree add` off this repo's bare mirror (repo-cache/<repo>), so its + * .git gitdir points into that mirror. When set, the mirror is bind-mounted + * at its host path (src=dst, rw) so the worktree resolves inside the sandbox. */ + repo?: string + /** Extra env vars to pre-bake into the container at create time. */ + extraEnv?: Record<string, string> + /** Image to create the container from. Defaults to SANDBOX_IMAGE. + * Production callers resolve a per-loop child via ensureLoopImage; tests + * may omit this and get the base. */ + image?: string + /** Ephemeral port publishing: when set, the container is created with + * `-p :<internalPort>[/<proto>]` so the kernel assigns a random host + * port. Host port is queried via `podman port` after start. Changing + * this list shifts the config hash → container recreate. */ + ephemeralPorts?: { internalPort: number; protocol?: "tcp" | "udp" }[] +} + +/** + * Resolve a sandbox-side path. `~` / `$HOME` resolve to V_HOME(user) — the + * sandbox's virtual home, NOT the host's homedir. Absolute paths pass through. + * Operator src side: `~` resolves to host homedir (since the operator config + * names host paths). + */ +function expandSandboxPath(p: string, virtualHome: string): string { + if (p === "~" || p === "$HOME") return virtualHome + if (p.startsWith("~/")) return virtualHome + p.slice(1) + if (p.startsWith("$HOME/")) return virtualHome + p.slice("$HOME".length) + return p +} + +function expandHostPath(p: string, hostHome: string): string { + if (p === "~" || p === "$HOME") return hostHome + if (p.startsWith("~/")) return hostHome + p.slice(1) + if (p.startsWith("$HOME/")) return hostHome + p.slice("$HOME".length) + return p +} + +function isValidOperatorMountSrc(s: unknown): s is string { + if (typeof s !== "string" || !s) return false + if (!(s === "~" || s === "$HOME" || s.startsWith("~/") || s.startsWith("$HOME/") || s.startsWith("/"))) { + return false + } + return !s.split("/").some((seg) => seg === "..") +} + +function isValidMountDst(s: unknown): s is string { + if (typeof s !== "string" || !s) return false + return s === "~" || s === "$HOME" || s.startsWith("~/") || s.startsWith("$HOME/") || s.startsWith("/") +} + +export type VolumeMount = { + src: string + dst: string + /** true = read-only; false = read-write (default). */ + ro?: boolean +} + +/** + * Build the volume list for `podman create`. Returns the same logical bind + * set the bwrap era built, just expressed as podman --volume pairs. + * + * NOTE: this is async (loads config + checks fs for existence-conditional + * binds) but does NO container I/O. + */ +export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeMount[]> { + const hostHome = homedir() + const { loopId, createdBy, vaultName, knowledgeRw, mountAllLoops, repo } = opts + const virtualHome = V_HOME(createdBy) + const mounts: VolumeMount[] = [] + + // /tmp: shared with host (for socat / mktemp / IPC sockets). Same as today. + mounts.push({ src: "/tmp", dst: "/tmp" }) + + // $HOME: per-loop upper layer, persistent across container restarts. We + // place it under /loopat/home/<user> instead of host's actual homedir so + // nothing nests under it. (See V_HOME comment for why.) + mounts.push({ src: loopHomeUpper(loopId), dst: virtualHome }) + + // Virtual mount points for AI / user: + mounts.push({ src: loopWorkdir(loopId), dst: V_LOOP_WORKDIR(loopId) }) + + // Workdir built from a roster repo is a `git worktree add` off the repo's + // bare mirror (repo-cache/<repo>); the workdir's .git is a gitdir pointer INTO + // that mirror. Bind the mirror at its host path (src=dst) so the worktree's + // objects/refs/worktree-metadata resolve inside the sandbox — otherwise + // `git status` in the workdir is "fatal: not a git repository". rw because git + // writes the worktree's index/HEAD/logs under <mirror>/worktrees/<wt>/. + if (repo) { + const cache = personalRepoCacheDir(createdBy, repo) + mounts.push({ src: cache, dst: cache }) + } + mounts.push({ src: loopClaudeDir(loopId), dst: V_LOOP_CLAUDE(loopId) }) + mounts.push({ + src: loopContextKnowledge(loopId), + dst: V_CONTEXT_KNOWLEDGE, + ro: !knowledgeRw, + }) + mounts.push({ src: loopContextNotes(loopId), dst: V_CONTEXT_NOTES }) + mounts.push({ src: personalDir(createdBy), dst: V_CONTEXT_PERSONAL }) + + // Re-bind personal at the host-absolute path. compose.ts creates symlinks + // under loops/<id>/.claude/skills/<name> whose targets are host-absolute + // paths into personalDir(user); without this re-bind the targets wouldn't + // resolve inside the container. + mounts.push({ src: personalDir(createdBy), dst: personalDir(createdBy) }) + + // LOOPAT_INSTALL_DIR ro (claude binary + builtin plugins). + mounts.push({ src: LOOPAT_INSTALL_DIR, dst: LOOPAT_INSTALL_DIR, ro: true }) + + // The claude binary may live OUTSIDE LOOPAT_INSTALL_DIR: under npx, loopat is + // at _npx/<hash>/node_modules/loopat while claude is a sibling at + // _npx/<hash>/node_modules/@anthropic-ai/claude-agent-sdk-<plat>/. The + // sandbox exec's it by its host path, so bind that path in (ro) when it isn't + // already covered by the install-dir mount — otherwise the AI is code 127. + try { + const claudeDir = dirname(resolveSandboxClaudeBinary()) + if (existsSync(claudeDir) && !claudeDir.startsWith(LOOPAT_INSTALL_DIR)) { + mounts.push({ src: claudeDir, dst: claudeDir, ro: true }) + } + } catch {} + + // ~/.claude/plugins/ ro-bind under the sandbox $HOME so the SDK's plugin + // resolution (which reads from ~/.claude/plugins/) finds the same set the + // host has. Source path is host's actual ~/.claude/plugins/; dst is the + // sandbox $HOME's analogue. + const hostUserPluginsDir = join(hostHome, ".claude", "plugins") + const sandboxUserPluginsDir = join(virtualHome, ".claude", "plugins") + if (existsSync(hostUserPluginsDir)) { + mounts.push({ src: hostUserPluginsDir, dst: sandboxUserPluginsDir, ro: true }) + } + + // Per-loop installed_plugins.json snapshot (if compose wrote one): file- + // level bind OVER the wholesale dir bind. podman --volume supports file + // binds. + const loopInstalledPlugins = join(loopClaudeDir(loopId), "plugins", "installed_plugins.json") + if (existsSync(loopInstalledPlugins)) { + mounts.push({ + src: loopInstalledPlugins, + dst: join(sandboxUserPluginsDir, "installed_plugins.json"), + ro: true, + }) + } + + // Repos: bind at virtual path AND host-absolute path (git worktree internals + // store absolute gitdir paths). Both RW. PER-USER (the loop's own roster). + const reposDir = personalReposDir(createdBy) + if (existsSync(reposDir)) { + mounts.push({ src: reposDir, dst: V_CONTEXT_REPOS }) + mounts.push({ src: reposDir, dst: reposDir }) + } + + // notes/knowledge main repos: re-bind at host-absolute path so the per-loop + // worktree `.git` files resolve. PER-USER — the worktrees are derived from + // personalKnowledgeDir/personalNotesDir(createdBy), so the main repos bound + // here must match (same as personalDir above). + const notesRepo = personalNotesDir(createdBy) + if (existsSync(notesRepo)) { + mounts.push({ src: notesRepo, dst: notesRepo }) + } + const knowledgeRepo = personalKnowledgeDir(createdBy) + if (existsSync(knowledgeRepo)) { + mounts.push({ src: knowledgeRepo, dst: knowledgeRepo, ro: !knowledgeRw }) + } + + // chat snapshots (per-loop, ro). Only mount if populated. + const chatDir = loopContextChatDir(loopId) + if (existsSync(chatDir)) { + mounts.push({ src: chatDir, dst: V_CONTEXT_CHAT, ro: true }) + } + + // host-cli proxy: mount the host-exec socket dir so the loop's `loopat-host` + // forwarder can reach the host. Mounting the socket IS the trust decision — + // a loop with this mount may run any host cli (see host-exec.ts). The dir is + // created by serveHostExec at boot; mount if present (bind-try semantics). + const hostExec = hostExecDir() + if (existsSync(hostExec)) { + mounts.push({ src: hostExec, dst: V_HOST_EXEC_DIR }) + } + + // All-loops ro view (admin-gated): expose LOOPAT_HOME/loops/ at /loopat/loops. + if (mountAllLoops) { + mounts.push({ src: loopsDir(), dst: V_ALL_LOOPS, ro: true }) + } + + // Operator-tier mounts: from workspace config `mounts`. Any host path is + // fair game; operator owns the host. src is a host path (expand against + // host's home), dst is a sandbox path (expand against virtual home). + const workspaceCfg = await loadConfig() + for (const m of workspaceCfg.mounts ?? []) { + if (!isValidOperatorMountSrc(m.src) || !isValidMountDst(m.dst)) { + console.warn(`[loopat] skipping invalid workspace mount ${JSON.stringify(m)}`) + continue + } + const src = expandHostPath(m.src, hostHome) + const dst = expandSandboxPath(m.dst, virtualHome) + if (!existsSync(src)) continue // bind-try semantics + mounts.push({ src, dst, ro: !m.rw }) + } + + // Member-tier vault mounts: vaults/<v>/mounts/home/<top> → $HOME/<top>. + const vault = vaultName?.trim() || DEFAULT_VAULT + for (const m of listVaultHomeMounts(createdBy, vault)) { + if (!existsSync(m.src)) continue + mounts.push({ src: m.src, dst: join(virtualHome, m.rel) }) + } + + // No mise bind — toolchains are baked into the per-loop image instead + // (see ensureLoopImage). The image's MISE_DATA_DIR=/opt/loopat-mise lives + // outside $HOME so the home-upper overlay can't shadow installed tools. + + return mounts +} + +/** + * Build env-var map to bake into the container at create time. + * + * mise PATH is set by the IMAGE (ENV directives in base + per-loop child), + * not here — so the toolchain works for any process inside the container + * without needing host-side env extraction. + */ +export async function buildContainerEnv(opts: ContainerOptions): Promise<Record<string, string>> { + const out: Record<string, string> = {} + // Sandbox $HOME is /loopat/home/<user> (see V_HOME comment). + out.HOME = V_HOME(opts.createdBy) + // host-cli proxy: tell the loop's `loopat-host` forwarder where the mounted + // socket is and which loop it speaks for (server uses it for the workdir). + out.LOOPAT_HOST_SOCK = V_HOST_EXEC_SOCK + out.LOOPAT_LOOP_ID = opts.loopId + for (const [k, v] of Object.entries(opts.extraEnv ?? {})) { + out[k] = v + } + return out +} + +/** + * Build the `podman create` argv (after "podman create"). The container is + * named, labeled with the loop id + a config-hash so we can detect spec + * drift and recreate when needed. + * + * The image name comes from `opts.image` when provided (typically the + * per-loop child image from ensureLoopImage); otherwise it defaults to + * the base SANDBOX_IMAGE. Callers in the production path (ensureContainer) + * always resolve via ensureLoopImage; tests that construct opts directly + * get the base image without a build step. + */ +export async function buildPodmanCreateArgs(opts: ContainerOptions): Promise<string[]> { + const mounts = await buildVolumeMounts(opts) + const env = await buildContainerEnv(opts) + const home = homedir() + + const args: string[] = [ + "--name", containerName(opts.loopId), + "--label", `${LABEL_LOOP}=${opts.loopId}`, + "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, + // --userns=keep-id:uid=2000,gid=2000 maps whatever uid is running + // podman on the host → fixed container uid 2000. The image places + // the `loopat` user at uid 2000, so `whoami` inside is always + // "loopat" regardless of which host user owns the rootless daemon. + // + // File ownership across the boundary: container loopat ↔ host caller. + // Files we write through bind mounts are owned by the host user (the + // person who launched loopat), so they can manage them normally. + // + // Why not "USER root" instead: claude CLI refuses to run with + // --dangerously-skip-permissions when uid == 0. loopat sandboxes use + // bypassPermissions by default, so container-root is untenable for + // the SDK driver. + "--userns", "keep-id:uid=2000,gid=2000", + // Init reaps zombies from orphaned bg processes. + "--init", + // Nested rootless podman: every sandbox can run podman without a + // per-loop opt-in. --privileged is the only sustainable choice — a + // precise cap set ends up chasing one new boundary per podman release + // (NET_RAW for slirp, unmask for ro sysctls, ...). Tradeoff: outer + // container loses kernel isolation, but the userns + bind-mount + // boundary (uid 2000 ↔ host caller via keep-id) still constrains + // host damage. Sandbox doctrine here is "containerized dev env", + // not "untrusted-code prison". /dev/fuse is for the future switch + // to fuse-overlayfs storage if vfs ever bites on disk pressure. + "--privileged", + "--device", "/dev/fuse", + // Shared bridge network so the serve container can reach loop + // containers by name (aardvark-dns). Outbound API calls via NAT. + "--network", LOOPAT_NETWORK, + "--hostname", `loop-${opts.loopId.slice(0, 8)}`, + // Container cwd at creation; per-exec we override with -w. + "--workdir", V_LOOP_WORKDIR(opts.loopId), + // No interactive stdin / tty on the main process — it's just a sleeper. + ] + + // Volumes. + for (const m of mounts) { + args.push("--volume", `${m.src}:${m.dst}${m.ro ? ":ro" : ""}`) + } + + // Env. + for (const [k, v] of Object.entries(env)) { + args.push("--env", `${k}=${v}`) + } + + // Ephemeral port publish. `-p :<inner>` tells podman to ask the kernel + // for any free host port; query `podman port` after start to learn + // which one. Different from the port-proxy path (which publishes the + // whole range up front from a separate container) — here each loop + // container directly owns its share port mapping. + for (const ep of opts.ephemeralPorts ?? []) { + const proto = ep.protocol === "udp" ? "/udp" : "" + args.push("-p", `:${ep.internalPort}${proto}`) + } + + // Config hash. Covers mounts + opts but NOT env — see hashCreateArgs + // doc for why. + const hash = hashCreateArgs(mounts, opts) + args.push("--label", `${LABEL_CONFIG_HASH}=${hash}`) + + // Image + command tail. The image's CMD already runs `sleep infinity`, but + // we pass it explicitly so a future image-CMD change can't accidentally + // break the long-lived semantic. + const image = opts.image ?? SANDBOX_IMAGE + args.push(image, "/bin/sleep", "infinity") + return args +} + +/** + * Config hash: covers everything that, if changed, would require recreating + * the container — mounts + loop-scoped opts. Deliberately EXCLUDES the env + * map because different callers (term.ts / session.ts) legitimately pass + * different extraEnv (PTY doesn't need ANTHROPIC_API_KEY; SDK does). If we + * hashed env, those callers would force-recreate the container on every + * activity flip, killing each other's exec'd processes with SIGKILL (the + * actual bug behind "PTY exits 137 the moment a chat starts"). + * + * Env still lands in `podman create --env` for convenience (so an exec + * without explicit env inherits something sane), but the values that + * actually matter at runtime should be passed at exec time anyway. + */ +function hashCreateArgs( + mounts: VolumeMount[], + opts: ContainerOptions, +): string { + const h = createHash("sha256") + h.update("v1\n") + h.update(`loop:${opts.loopId}\n`) + h.update(`createdBy:${opts.createdBy}\n`) + h.update(`vault:${opts.vaultName ?? ""}\n`) + h.update(`knowledgeRw:${opts.knowledgeRw ? "1" : "0"}\n`) + h.update(`mountAllLoops:${opts.mountAllLoops ? "1" : "0"}\n`) + for (const m of [...mounts].sort((a, b) => a.dst.localeCompare(b.dst))) { + h.update(`vol\t${m.src}\t${m.dst}\t${m.ro ? "ro" : "rw"}\n`) + } + // Ephemeral port set is part of create-args — must invalidate hash so + // toggling share rebuilds the container with new `-p` flags. + for (const ep of [...(opts.ephemeralPorts ?? [])].sort((a, b) => a.internalPort - b.internalPort)) { + h.update(`epport\t${ep.internalPort}\t${ep.protocol ?? "tcp"}\n`) + } + return h.digest("hex").slice(0, 16) +} + +// ── podman binary wrapping ──────────────────────────────────────────────── + +const PODMAN_BIN = process.env.LOOPAT_PODMAN_BIN || "podman" + +async function runPodman( + args: string[], + opts: { allowFail?: boolean; onLine?: (line: string) => void } = {}, +): Promise<{ stdout: string, stderr: string, code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(PODMAN_BIN, args, { stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + const emit = (s: string) => { + const trimmed = s.trim() + if (trimmed) opts.onLine?.(trimmed) + } + child.stdout.on("data", (b: Buffer) => { + const s = b.toString() + stdout += s + const lines = s.split("\n") + for (const line of lines.slice(0, -1)) emit(line) + }) + child.stderr.on("data", (b: Buffer) => { + const s = b.toString() + stderr += s + const lines = s.split("\n") + for (const line of lines.slice(0, -1)) emit(line) + }) + child.on("error", (e: any) => { + if (e?.code === "ENOENT") { + reject(new Error(`podman binary not found (looked for "${PODMAN_BIN}"); install with: sudo apt install podman uidmap fuse-overlayfs`)) + } else { + reject(e) + } + }) + child.on("exit", (code) => { + const result = { stdout, stderr, code: code ?? -1 } + if (code === 0 || opts.allowFail) { + resolve(result) + } else { + const err: any = new Error(`podman ${args[0]} failed (exit ${code}): ${stderr.trim() || stdout.trim()}`) + err.result = result + reject(err) + } + }) + }) +} + +export type PodmanProbeResult = { + ok: boolean + version?: string + hint?: string +} + +export async function probePodman(): Promise<PodmanProbeResult> { + try { + const { stdout } = await runPodman(["--version"]) + const version = stdout.trim() + return { ok: true, version } + } catch (e: any) { + return { + ok: false, + hint: e?.message?.includes("not found") + ? "install with: sudo apt install podman uidmap fuse-overlayfs" + : `podman probe failed: ${e?.message ?? e}`, + } + } +} + +/** + * Ensure the loopat-sandbox base image exists in podman's local store. If + * missing, build it from server/templates/sandbox/Containerfile. The + * Containerfile is FROM Aliyun AC2 Ubuntu 24.04 + apt-installs basic shell + * tools; the first build pulls the base (~104MB) from the AC2 registry + * (anonymous, China-reachable — docker.io is not), subsequent + * `ensureContainer` calls reuse the cached image. + * + * Concurrency: build is idempotent at podman's layer cache, but we still + * guard with a per-process Promise so two simultaneous ensureContainer + * calls don't fire two builds. + */ +/** Hash the base Containerfile content. Used both as the tag suffix for + * the base image itself and mixed into per-loop child image tags so that + * base-image changes (e.g. apt installs added to the Containerfile) cascade + * through and invalidate stale child images. + */ +export async function baseContainerfileHash(): Promise<string> { + const dir = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox") + const containerfile = join(dir, "Containerfile") + if (!existsSync(containerfile)) { + throw new Error(`Containerfile not found at ${containerfile}`) + } + const h = createHash("sha256").update(await readFile(containerfile, "utf8")) + // Files COPY'd by the Containerfile also affect the image — hash them too so + // editing the forwarder rebuilds the base image. + const forwarder = join(dir, "loopat-host") + if (existsSync(forwarder)) h.update(await readFile(forwarder, "utf8")) + return h.digest("hex").slice(0, 16) +} + +let _imageBuildInFlight: Promise<void> | null = null +export async function ensureSandboxImage(opts?: { onProgress?: (msg: string) => void }): Promise<void> { + if (_imageBuildInFlight) return _imageBuildInFlight + _imageBuildInFlight = (async () => { + const containerfile = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox", "Containerfile") + if (!existsSync(containerfile)) { + throw new Error(`Cannot build sandbox image: Containerfile not found at ${containerfile}`) + } + + // Hash the Containerfile so the base image auto-rebuilds when it changes. + const hash = await baseContainerfileHash() + const hashTag = `loopat-sandbox-${hash}:latest` + + const present = await runPodman(["image", "exists", hashTag], { allowFail: true }) + if (present.code === 0) { + // Re-tag so the unversioned SANDBOX_IMAGE name always points at the + // latest built version. + await runPodman(["tag", hashTag, SANDBOX_IMAGE], { allowFail: true }) + return + } + + // Fast path: pull the prebuilt image CI published for this exact + // Containerfile (content-hash tag). On success, tag it as both the hashTag + // (so the next boot's existence check above short-circuits) and the + // unversioned SANDBOX_IMAGE. Only fall back to a local build if the pull + // fails — ghcr unreachable on this network, or a modified Containerfile + // whose hash was never published. + const remoteRef = `${SANDBOX_IMAGE_REF}:${hash}` + console.log(`[podman] pulling prebuilt sandbox image ${remoteRef}`) + opts?.onProgress?.("Pulling prebuilt sandbox image…") + const pulled = await runPodman(["pull", remoteRef], { allowFail: true }) + if (pulled.code === 0) { + await runPodman(["tag", remoteRef, hashTag], { allowFail: true }) + await runPodman(["tag", remoteRef, SANDBOX_IMAGE], { allowFail: true }) + console.log(`[podman] sandbox image ready (pulled ${remoteRef})`) + return + } + console.log(`[podman] prebuilt image unavailable — building sandbox image ${SANDBOX_IMAGE} locally (may take a few minutes)`) + opts?.onProgress?.("Building sandbox environment…") + + // Stream build output, parsing STEP lines into progress messages. + const buildDir = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox") + let lastStep = "" + const r = await runPodman( + ["build", "-t", SANDBOX_IMAGE, "-t", hashTag, "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, "-f", containerfile, buildDir], + { + onLine: (line) => { + const m = line.match(/^STEP\s+(\d+)\/(\d+):\s+(.+)/) + if (m) { + lastStep = descStep(m[3]) + opts?.onProgress?.(`Building sandbox: ${lastStep} (step ${m[1]}/${m[2]})`) + } + }, + }, + ) + if (r.code !== 0) { + throw new Error(`sandbox image build failed: ${r.stderr || r.stdout}`) + } + console.log(`[podman] sandbox image ready`) + })() + try { + await _imageBuildInFlight + } finally { + _imageBuildInFlight = null + } +} + +/** Translate a podman build STEP instruction into a short human label. */ +function descStep(instruction: string): string { + const lower = instruction.toLowerCase() + if (lower.startsWith("from ")) return "base image" + if (lower.includes("apt-get")) return "system packages" + if (lower.includes("userdel") || lower.includes("useradd") || lower.includes("groupadd")) return "user setup" + if (lower.includes("curl") && lower.includes("mise")) return "mise tool manager" + if (lower.includes("mkdir") && lower.includes("loopat-mise")) return "mise directories" + if (lower.startsWith("env ")) return "environment" + if (lower.startsWith("user ")) return "user" + if (lower.startsWith("copy ")) return "copying config" + if (lower.startsWith("run ")) return "running setup" + if (lower.startsWith("cmd ")) return "entrypoint" + return "building" +} + +/** + * Per-loop warning state set by ensureLoopImage when toolchain baking + * fails. Read by attachTerm (term.ts) to surface a yellow banner in the + * PTY so the user knows their mise.toml is broken — without losing the + * loop entirely (we fall back to the base image and keep going). + */ +const _loopWarnings = new Map<string, string>() +export function getLoopWarning(loopId: string): string | undefined { + return _loopWarnings.get(loopId) +} + +/** + * Ensure a per-loop image exists for this loop's composed mise.toml, + * returning its tag. Behavior: + * - no mise.toml (or empty) → base SANDBOX_IMAGE + * - mise.toml present, build OK → loopat-sandbox-<hash>:latest, clear any + * prior warning for this loop + * - mise.toml present, build FAILS → log error, stash a per-loop warning, + * fall back to base SANDBOX_IMAGE so the loop still starts. The PTY + * surfaces the warning on attach; the user can fix mise.toml and + * restart the loop to re-attempt. + * + * The tag is `loopat-sandbox-<sha256-of-mise.toml-content>:latest`, so two + * loops with the same toolchain spec share an image (and the build's mise + * install layer caches via podman layer cache). Concurrent builds of the + * same tag are coalesced via _loopImageInFlight. + */ +const _loopImageInFlight = new Map<string, Promise<string>>() +export async function ensureLoopImage(loopId: string, opts?: { onProgress?: (msg: string) => void }): Promise<string> { + await ensureSandboxImage(opts) + + const miseTomlPath = join(loopClaudeDir(loopId), "mise.toml") + if (!existsSync(miseTomlPath)) { + _loopWarnings.delete(loopId) + return SANDBOX_IMAGE + } + const content = await readFile(miseTomlPath, "utf8") + if (!content.trim()) { + _loopWarnings.delete(loopId) + return SANDBOX_IMAGE + } + + // Hash both mise.toml AND the base Containerfile so that base-image + // changes (apt installs added, configs changed) cascade into a fresh + // child build. Without the base part, child images stay frozen against + // an old base layer set even after `loopat-sandbox:latest` is rebuilt + // — silent skew that has bitten us (e.g. podman missing from loops + // after the nested-podman base change shipped). + const baseHash = await baseContainerfileHash() + const hash = createHash("sha256").update(`base:${baseHash}\n`).update(content).digest("hex").slice(0, 16) + const tag = `loopat-sandbox-${hash}:latest` + + const existing = _loopImageInFlight.get(tag) + if (existing) return existing + + const built = (async () => { + const present = await runPodman(["image", "exists", tag], { allowFail: true }) + if (present.code === 0) { + _loopWarnings.delete(loopId) + return tag + } + + console.log(`[podman] building loop image ${tag} for loop ${loopId.slice(0, 8)}`) + opts?.onProgress?.("Installing tools from mise.toml…") + const buildDir = await mkdtemp(join(tmpdir(), "loopat-img-")) + try { + // A loopat-native `[host]` table declares host-only clis (macOS / + // machine-bound) the sandbox can't run natively. We bake a forwarding + // shim per cli into the image's mise shims dir (already first on PATH), + // and strip the table before mise sees it — mise would reject the + // unknown table. The shim just hands off to `loopat-host` (in the base + // image) → mounted socket → host execFile. See host-exec.ts. + let hostClis: string[] = [] + let miseConfig = content + try { + const parsed: any = tomlParse(content) + if (parsed && Array.isArray(parsed.host?.clis)) { + hostClis = parsed.host.clis.filter((x: unknown): x is string => typeof x === "string" && !!x) + } + if (parsed && "host" in parsed) { + const { host: _host, ...rest } = parsed + miseConfig = tomlStringify(rest) + } + } catch {} + await writeFile(join(buildDir, "mise.toml"), miseConfig) + // Override `mise trust` interactively by marking the config path + // trusted via env. `mise install -y` installs everything in + // mise.toml; `mise reshim` ensures /opt/loopat-mise/shims/ has a + // shim for every tool. + const lines = [ + `FROM ${SANDBOX_IMAGE}`, + `COPY mise.toml /opt/loopat-mise/config/config.toml`, + `RUN MISE_TRUSTED_CONFIG_PATHS=/opt/loopat-mise/config/config.toml \\`, + ` mise install -y \\`, + ` && MISE_TRUSTED_CONFIG_PATHS=/opt/loopat-mise/config/config.toml \\`, + ` mise reshim`, + ] + if (hostClis.length) { + // Generate the shims into the build context, then COPY them in AFTER + // reshim so mise's own reshim can't clobber them. + await writeHostShims(join(buildDir, "host-bin"), hostClis) + lines.push(`COPY host-bin/ /opt/loopat-mise/shims/`) + } + const childContainerfile = lines.join("\n") + "\n" + await writeFile(join(buildDir, "Containerfile"), childContainerfile) + + const r = await runPodman( + ["build", "-t", tag, "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, "-f", join(buildDir, "Containerfile"), buildDir], + { + allowFail: true, + onLine: (line) => { + const m = line.match(/^STEP\s+(\d+)\/(\d+):\s+(.+)/) + if (m) { + opts?.onProgress?.(`Installing tools: ${descStep(m[3])} (step ${m[1]}/${m[2]})`) + } + }, + }, + ) + if (r.code !== 0) { + // Don't throw — fall back to base so the loop still starts. The + // user can inspect via terminal, fix mise.toml, and restart. + const detail = (r.stderr || r.stdout || "").trim().split("\n").slice(-3).join(" | ").slice(0, 400) + const msg = `toolchain build failed — sandbox started without baked tools. mise install rejected ${miseTomlPath}: ${detail}` + console.error(`[podman] ${msg}`) + _loopWarnings.set(loopId, msg) + return SANDBOX_IMAGE + } + console.log(`[podman] loop image ${tag} ready`) + _loopWarnings.delete(loopId) + } finally { + await rm(buildDir, { recursive: true, force: true }).catch(() => {}) + } + return tag + })() + _loopImageInFlight.set(tag, built) + try { + return await built + } finally { + _loopImageInFlight.delete(tag) + } +} + +type ContainerInspectRow = { + exists: boolean + running: boolean + configHash?: string + imageId?: string +} + +async function inspectContainer(loopId: string): Promise<ContainerInspectRow> { + const name = containerName(loopId) + const r = await runPodman( + ["inspect", "--format", "{{.State.Running}}|{{index .Config.Labels \"" + LABEL_CONFIG_HASH + "\"}}|{{.Image}}", name], + { allowFail: true }, + ) + if (r.code !== 0) return { exists: false, running: false } + const [running, configHash, imageId] = r.stdout.trim().split("|") + return { + exists: true, + running: running === "true", + configHash: configHash === "<no value>" || configHash === "" ? undefined : configHash, + imageId: imageId === "<no value>" || imageId === "" ? undefined : imageId, + } +} + +export async function containerExists(loopId: string): Promise<boolean> { + return (await inspectContainer(loopId)).exists +} + +export async function containerRunning(loopId: string): Promise<boolean> { + return (await inspectContainer(loopId)).running +} + +/** Return the container's bridge network IP, or null if not running. */ +export async function getContainerIP(loopId: string): Promise<string | null> { + const name = containerName(loopId) + const r = await runPodman( + ["inspect", "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", name], + { allowFail: true }, + ) + if (r.code !== 0) return null + const ip = r.stdout.trim() + if (!ip || ip === "<no value>") return null + return ip +} + +/** Look up the actual host port for an ephemeral `-p :<inner>` mapping. + * + * `podman port <ct> <inner>/<proto>` prints lines like `0.0.0.0:44513`. + * Returns the first numeric port, or null if the container isn't running + * or doesn't have a mapping for that internal port. Cheap (~ms), so we + * call it on demand from the API rather than caching aggressively — the + * mapping changes only when the container is recreated. + */ +export async function getEphemeralHostPort( + loopId: string, + internalPort: number, + protocol: "tcp" | "udp" = "tcp", +): Promise<number | null> { + const name = containerName(loopId) + const r = await runPodman( + ["port", name, `${internalPort}/${protocol}`], + { allowFail: true }, + ) + if (r.code !== 0) return null + // First line is the v4 binding (e.g. "0.0.0.0:44513"); take it. + const first = r.stdout.split("\n").find((l) => l.trim().length > 0) ?? "" + const m = first.trim().match(/:(\d+)$/) + if (!m) return null + const port = Number(m[1]) + return Number.isFinite(port) && port > 0 ? port : null +} + +// Per-workspace network (+ loopat.workspace label) so parallel LOOPAT_HOMEs +// stay isolated and `uninstall` removes only its own. +const LOOPAT_NETWORK = `loopat-${WORKSPACE}` +const SERVE_CONTAINER = `loopat-${WORKSPACE}-serve` + +let _networkReady = false +let _serveReady: Promise<void> | null = null + +/** Ensure the shared bridge network exists so containers can reach each other. */ +export async function ensureLoopatNetwork(): Promise<void> { + if (_networkReady) return + const r = await runPodman(["network", "exists", LOOPAT_NETWORK], { allowFail: true }) + if (r.code !== 0) { + console.log(`[podman] creating network ${LOOPAT_NETWORK}`) + const create = await runPodman(["network", "create", "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, LOOPAT_NETWORK]) + if (create.code !== 0) { + throw new Error(`Failed to create podman network ${LOOPAT_NETWORK}: ${create.stderr}`) + } + } + _networkReady = true +} + +/** Ensure the workspace serve container is running on the shared network. */ +export async function ensureServeContainer(): Promise<void> { + if (_serveReady) return _serveReady + _serveReady = (async () => { + const cfg = await loadConfig() + const enabled = cfg.serveEnabled ?? true // default on for backward compat + + // Check current container state + const cur = await runPodman( + ["inspect", "--format", "{{.State.Running}}", SERVE_CONTAINER], + { allowFail: true }, + ) + + if (!enabled) { + // Disabled — stop and remove if exists + if (cur.code === 0) { + console.log(`[podman] serve disabled, removing serve container`) + await runPodman(["stop", "--time", "5", SERVE_CONTAINER], { allowFail: true }) + await runPodman(["rm", "--force", SERVE_CONTAINER], { allowFail: true }) + } + _serveReady = null + return + } + + await ensureLoopatNetwork() + await ensureSandboxImage() + + if (cur.code === 0 && cur.stdout.trim() === "true") { + _serveReady = null + return + } + + if (cur.code === 0) { + // Exists but not running — start it + console.log(`[podman] starting serve container`) + await runPodman(["start", SERVE_CONTAINER]) + _serveReady = null + return + } + + // Create the serve container + console.log(`[podman] creating serve container on network ${LOOPAT_NETWORK}`) + const serveBinary = join(LOOPAT_INSTALL_DIR, "server", "src", "serve-rs", "target", "release", "loopat-serve") + if (!existsSync(serveBinary)) { + _serveReady = null + throw new Error(`serve binary not found at ${serveBinary}. Run: cd server/src/serve-rs && cargo build --release`) + } + + const createArgs = [ + "--name", SERVE_CONTAINER, + "--network", LOOPAT_NETWORK, + "--hostname", "loopat-serve", + "--volume", `${loopsDir()}:/loopat/loops:ro`, + "--volume", `${serveBinary}:/usr/local/bin/loopat-serve:ro`, + "-p", `${SERVE_HOST}:${SERVE_PORT}:7788`, + "-e", `LOOPAT_WORKSPACE=${WORKSPACE}`, + "-e", `LOOPAT_LOOPS_DIR=/loopat/loops`, + "--init", + SANDBOX_IMAGE, + "/usr/local/bin/loopat-serve", + ] + const r = await runPodman(["create", ...createArgs]) + if (r.code !== 0) { + _serveReady = null + throw new Error(`serve container create failed: ${r.stderr}`) + } + await runPodman(["start", SERVE_CONTAINER]) + console.log(`[podman] serve container ready on port ${SERVE_PORT}`) + })() + try { + await _serveReady + } finally { + _serveReady = null + } +} + +const PORT_PROXY_CONTAINER = `loopat-${WORKSPACE}-port-proxy` + +let _portProxyReady: Promise<void> | null = null + +/** Find occupied TCP ports in a range. + * + * Uses `ss` instead of `lsof`: unprivileged `lsof` only sees sockets owned + * by the current user, so cross-user services (ollama, system dashboards, + * other devs on a shared box) get missed and the port-proxy container + * fails to start with `bind: address already in use`. `ss` reads from + * /proc/net/tcp directly and shows every listening socket on the host, + * which is what we actually need to know when picking host ports to + * publish. + */ +function findOccupiedPorts(lo: number, hi: number): Set<number> { + const ports = new Set<number>() + const { execFileSync } = require("node:child_process") + const add = (port: number) => { if (port >= lo && port <= hi) ports.add(port) } + // Linux: ss reads /proc/net/tcp (every listening socket). + try { + const out = execFileSync("ss", ["-tlnH"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }) as string + for (const line of out.split("\n")) { + const parts = line.trim().split(/\s+/) + if (parts.length < 4) continue + const addr = parts[3] // "0.0.0.0:8080" | "[::]:8080" | "127.0.0.1:8080" + const colonIdx = addr.lastIndexOf(":") + if (colonIdx !== -1) add(Number(addr.slice(colonIdx + 1))) + } + return ports + } catch {} + // macOS (no ss): lsof. NAME column looks like "*:8080" or "127.0.0.1:8080 (LISTEN)". + try { + const out = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }) as string + for (const line of out.split("\n")) { + const m = line.match(/:(\d+)\s*\(LISTEN\)/) + if (m) add(Number(m[1])) + } + } catch {} + return ports +} + +/** Build port-proxy create args, excluding occupiedPorts from the -p range. */ +function buildPortProxyCreateArgs(binary: string, portRange: string, occupiedPorts: Set<number>): string[] { + const [lo, hi] = portRange.split("-").map(Number) + const publishArgs: string[] = [] + for (let p = lo; p <= hi; p++) { + if (!occupiedPorts.has(p)) { + publishArgs.push("-p", `0.0.0.0:${p}:${p}`) + } + } + return [ + "--name", PORT_PROXY_CONTAINER, + "--network", LOOPAT_NETWORK, + "--hostname", "loopat-port-proxy", + "--volume", `${loopsDir()}:/loopat/loops:ro`, + "--volume", `${binary}:/usr/local/bin/loopat-port-proxy:ro`, + ...publishArgs, + "-e", `LOOPAT_WORKSPACE=${WORKSPACE}`, + "-e", `LOOPAT_LOOPS_DIR=/loopat/loops`, + "--init", + SANDBOX_IMAGE, + "/usr/local/bin/loopat-port-proxy", + ] +} + +/** Ensure the port-proxy container is running for direct TCP/UDP forwarding. */ +export async function ensurePortProxyContainer(): Promise<void> { + if (_portProxyReady) return _portProxyReady + _portProxyReady = (async () => { + const cfg = await loadConfig() + const enabled = cfg.serveDynamicEnabled ?? false + const portRange = cfg.serveDynamicPortRange || (process.env.LOOPAT_EXTERNAL_PORT_RANGE ?? "10000-20000") + + const cur = await runPodman( + ["inspect", "--format", "{{.State.Running}}", PORT_PROXY_CONTAINER], + { allowFail: true }, + ) + + if (!enabled) { + if (cur.code === 0) { + console.log(`[podman] dynamic port disabled, removing port-proxy container`) + await runPodman(["stop", "--time", "5", PORT_PROXY_CONTAINER], { allowFail: true }) + await runPodman(["rm", "--force", PORT_PROXY_CONTAINER], { allowFail: true }) + } + _portProxyReady = null + return + } + + await ensureLoopatNetwork() + await ensureSandboxImage() + + if (cur.code === 0 && cur.stdout.trim() === "true") { + _portProxyReady = null + return + } + + if (cur.code === 0) { + // Exists but not running. Try start first, but if it fails with a + // port conflict, fall through to recreate without occupied ports. + const startR = await runPodman(["start", PORT_PROXY_CONTAINER], { allowFail: true }) + if (startR.code === 0) { + _portProxyReady = null + return + } + if (/(bind|address already in use|rootlessport)/i.test(startR.stderr + startR.stdout)) { + console.log(`[podman] existing port-proxy container has port conflicts — recreating`) + await runPodman(["rm", "--force", PORT_PROXY_CONTAINER], { allowFail: true }) + } else { + _portProxyReady = null + throw new Error(`port-proxy start failed: ${startR.stderr || startR.stdout}`) + } + } + + const binary = join(LOOPAT_INSTALL_DIR, "server", "src", "port-proxy-rs", "target", "release", "loopat-port-proxy") + if (!existsSync(binary)) { + _portProxyReady = null + throw new Error(`port-proxy binary not found at ${binary}. Run: cd server/src/port-proxy-rs && cargo build --release`) + } + + // Use lsof to find ports already in use, then exclude them from -p. + // The port-proxy inside uses inotify for dynamic listener lifecycle — + // no container restart needed when shareExternalPort configs change. + const [lo, hi] = portRange.split("-").map(Number) + if (!lo || !hi || lo >= hi) { + _portProxyReady = null + throw new Error(`invalid port range: ${portRange}`) + } + const occupied = findOccupiedPorts(lo, hi) + if (occupied.size > 0) console.log(`[podman] ${occupied.size} port(s) in ${portRange} already in use — skipping`) + + const args = buildPortProxyCreateArgs(binary, portRange, occupied) + const createR = await runPodman(["create", ...args]) + if (createR.code !== 0) { + _portProxyReady = null + throw new Error(`port-proxy container create failed: ${createR.stderr}`) + } + const startR = await runPodman(["start", PORT_PROXY_CONTAINER]) + if (startR.code !== 0) { + _portProxyReady = null + throw new Error(`port-proxy start failed: ${startR.stderr}`) + } + const mapped = (hi - lo + 1) - occupied.size + console.log(`[podman] port-proxy container ready (${mapped} ports in ${portRange})`) + })() + try { + await _portProxyReady + } finally { + _portProxyReady = null + } +} + +const SERVE_HOST = process.env.LOOPAT_SERVE_HOST ?? "127.0.0.1" +const SERVE_PORT = Number(process.env.LOOPAT_SERVE_PORT ?? 7788) + +/** + * Lock down the vault `.ssh` perms RIGHT BEFORE the container that bind-mounts + * it starts. The vault's `.ssh` is git-crypt-decrypted / git-checked-out, so its + * private key lands at the umask default (0664) and the dir at 0775 — and git + * can't carry 0600 (it only tracks the exec bit). loops.ts chmods the key 0600 + * "at point of use" host-side, but that only runs when a HOST git op goes through + * sshCommandForUser; a container that's created/recreated on a path that didn't + * (server restart → first attach, config/image-drift recreate) would bind-mount + * a stale-perms key. ssh then ignores the key (or rejects it under StrictModes) + * and the FIRST sandbox ssh fails `Permission denied (publickey)`; a later op + * that re-chmods host-side makes the retry succeed — exactly the intermittent + * we saw. Do it here, in the one chokepoint every container start passes + * through, so the perms are correct the instant the bind goes live. Cheap + + * idempotent; best-effort (a missing vault is fine — the mount just won't exist). + */ +export async function ensureSandboxSshPerms(user: string, vault: string): Promise<void> { + const sshDir = join(personalVaultMountsHomeDir(user, vault), ".ssh") + if (!existsSync(sshDir)) return + try { + await chmod(sshDir, 0o700).catch(() => {}) + for (const name of readdirSync(sshDir)) { + const p = join(sshDir, name) + try { + if (!statSync(p).isFile()) continue + } catch { continue } + // Private keys MUST be 0600 (id_*, *_rsa/_ed25519/_ecdsa with no .pub) and + // `config` MUST NOT be group/world-writable or ssh silently ignores it. + // .pub / known_hosts are fine readable; clamp them to 0644 to be safe. + const isPub = name.endsWith(".pub") + const isPrivKey = !isPub && /^id_|_rsa$|_ed25519$|_ecdsa$|_dsa$|^identity$/.test(name) + if (name === "config" || isPrivKey) { + await chmod(p, 0o600).catch(() => {}) + } else { + await chmod(p, 0o644).catch(() => {}) + } + } + } catch {} +} + +/** + * Idempotent: bring the container to "running with current config". + * - missing → podman create + start + * - stopped, hash matches → start + * - stopped, hash drift → rm + create + start + * - running, hash matches → no-op + * - running, hash drift → stop + rm + create + start + */ +export async function ensureContainer(opts: ContainerOptions, progress?: { onProgress?: (msg: string) => void }): Promise<void> { + await ensureLoopatNetwork() + // Deterministically fix the vault ssh-key perms BEFORE the container that + // bind-mounts them starts, so the first in-sandbox ssh authenticates reliably + // regardless of how/when the key was git-checked-out (see ensureSandboxSshPerms). + await ensureSandboxSshPerms(opts.createdBy, opts.vaultName?.trim() || DEFAULT_VAULT) + // Resolve the image first — for loops with a composed mise.toml this + // builds (or reuses) a per-loop child image with toolchains baked in. + // For loops without mise.toml, this returns the base SANDBOX_IMAGE. + const image = opts.image ?? (await ensureLoopImage(opts.loopId, progress)) + const resolvedOpts: ContainerOptions = { ...opts, image } + + // Pre-create every bind-destination's parent dir on the host. Otherwise + // podman auto-creates them at container start as root-in-userns, which + // maps to subuid 100000 outside — and then the host user can't delete + // them. The bind targets under V_HOME (e.g. .claude/plugins/) and the + // host-upper itself are the typical culprits. + await mkdir(loopHomeUpper(opts.loopId), { recursive: true }) + await mkdir(join(loopHomeUpper(opts.loopId), ".claude", "plugins"), { recursive: true }) + await mkdir(join(loopHomeUpper(opts.loopId), ".local", "share"), { recursive: true }) + await mkdir(loopDir(opts.loopId), { recursive: true }) + + const createArgs = await buildPodmanCreateArgs(resolvedOpts) + // Extract hash from the args we just built. + const hashIdx = createArgs.findIndex((a, i) => + createArgs[i - 1] === "--label" && a.startsWith(`${LABEL_CONFIG_HASH}=`), + ) + const desiredHash = hashIdx >= 0 ? createArgs[hashIdx].split("=")[1] : "" + + // Include image ID in the drift check so a rebuilt image (mise tools + // added, base layer changed, etc.) triggers container recreation even + // when the config hash hasn't changed. + const curImageId = (await runPodman(["image", "inspect", "--format", "{{.Id}}", image])).stdout.trim() + + const cur = await inspectContainer(opts.loopId) + if (cur.running && cur.configHash === desiredHash && cur.imageId === curImageId) return + const tag = opts.loopId.slice(0, 8) + if (cur.exists) { + if (cur.configHash !== desiredHash || cur.imageId !== curImageId) { + // Spec or image drift — container has to be torn down and recreated. + // This kills any process exec'd into the old container (PTY shells, an + // active claude CLI). Log loudly so the cause is obvious if the user + // reports "my terminal disconnected when I sent a chat". + const reason = cur.configHash !== desiredHash ? "config hash drift" : "image drift (rebuilt)" + console.warn(`[podman:${tag}] ${reason} — recreating container; any in-flight exec'd processes will be killed`) + if (cur.running) await runPodman(["stop", "--time", "5", containerName(opts.loopId)]) + await runPodman(["rm", "--force", containerName(opts.loopId)]) + } else { + // Hash matches; just (re)start. + console.log(`[podman:${tag}] restarting stopped container`) + await runPodman(["start", containerName(opts.loopId)]) + return + } + } + console.log(`[podman:${tag}] creating + starting container (hash=${desiredHash})`) + progress?.onProgress?.("Creating sandbox container…") + await runPodman(["create", ...createArgs]) + progress?.onProgress?.("Starting sandbox container…") + await runPodman(["start", containerName(opts.loopId)]) +} + +export async function stopContainer(loopId: string): Promise<void> { + const r = await runPodman(["stop", "--time", "5", containerName(loopId)], { allowFail: true }) + if (r.code !== 0 && !r.stderr.includes("no such container")) { + console.warn(`[podman] stop ${loopId} non-zero exit (${r.code}): ${r.stderr.trim()}`) + } +} + +export async function removeContainer(loopId: string): Promise<void> { + await runPodman(["rm", "--force", containerName(loopId)], { allowFail: true }) +} + +/** + * Stop ALL loopat containers for this workspace. Called on server shutdown + * so the host isn't left with hundreds of idle sandbox containers. + */ +export async function stopAllWorkspaceContainers(): Promise<void> { + const r = await runPodman( + ["ps", "--all", "--filter", `label=${LABEL_WORKSPACE}=${WORKSPACE}`, "--format", "{{.Names}}"], + { allowFail: true }, + ) + if (r.code !== 0) return + const names = r.stdout.split("\n").map((s) => s.trim()).filter(Boolean) + await Promise.all(names.map((n) => runPodman(["stop", "--time", "5", n], { allowFail: true }))) +} + +// ── idle stop scheduler ─────────────────────────────────────────────────── +// Each loop can have multiple activity "sources" — e.g. "sdk" (active SDK +// session) and "pty" (active terminal subscribers). The container stays up +// as long as ANY source is active. When the last source goes inactive, we +// arm an idle timer; if no source re-activates within the window, we +// `podman stop` the container so the namespace + overlay get released. +// User-launched background daemons (e.g. nohup server.py &) that linger +// past all SDK/PTY sources WILL be killed when idle stop fires — this is +// the explicit v1 trade-off (consistent with "idle = sandbox dies"). + +function containerIdleMs(): number { + // Read env each call so tests can override per-spec (paths.ts captures + // its env at module load, but lifecycle timing is OK to re-read). + return Number(process.env.LOOPAT_CONTAINER_IDLE_MS) || 30 * 60 * 1000 +} + +type ActivityRegistry = { + /** Per-loop set of active source ids. Empty / missing = nothing active. */ + active: Map<string, Set<string>> + /** Per-loop idle timer; clears when any source becomes active again. */ + idleTimers: Map<string, ReturnType<typeof setTimeout>> +} + +const registry: ActivityRegistry = { + active: new Map(), + idleTimers: new Map(), +} + +export function markActive(loopId: string, source: string): void { + let set = registry.active.get(loopId) + if (!set) { + set = new Set() + registry.active.set(loopId, set) + } + set.add(source) + const t = registry.idleTimers.get(loopId) + if (t) { + clearTimeout(t) + registry.idleTimers.delete(loopId) + } +} + +export function markInactive(loopId: string, source: string): void { + const set = registry.active.get(loopId) + if (set) { + set.delete(source) + if (set.size === 0) registry.active.delete(loopId) + } + // If anything else is still active, no idle timer needed. + if ((registry.active.get(loopId)?.size ?? 0) > 0) return + scheduleIdleStop(loopId) +} + +function scheduleIdleStop(loopId: string): void { + const existing = registry.idleTimers.get(loopId) + if (existing) clearTimeout(existing) + const t = setTimeout(async () => { + registry.idleTimers.delete(loopId) + // Re-check: someone may have grabbed activity between scheduling and firing. + if ((registry.active.get(loopId)?.size ?? 0) > 0) return + try { + await stopContainer(loopId) + console.log(`[podman] idle-stopped container for loop ${loopId.slice(0, 8)}`) + } catch (e: any) { + console.warn(`[podman] idle stop failed for loop ${loopId.slice(0, 8)}: ${e?.message ?? e}`) + } + }, containerIdleMs()) + registry.idleTimers.set(loopId, t) +} + +/** Test-only helper: clear all activity state + timers. */ +export function _resetActivityRegistryForTests(): void { + for (const t of registry.idleTimers.values()) clearTimeout(t) + registry.idleTimers.clear() + registry.active.clear() +} + +/** Test-only: read current active sources for a loop. */ +export function _getActiveSourcesForTests(loopId: string): string[] { + return [...(registry.active.get(loopId) ?? [])] +} + +// ── exec into the container ─────────────────────────────────────────────── + +export type ExecOptions = { + loopId: string + command: string + args: string[] + env?: Record<string, string> + tty?: boolean + interactive?: boolean + workdir?: string +} + +/** + * Build the `podman exec` argv (after "podman exec"). Pure: no I/O. Caller + * spawns "podman" with the returned args. + * + * Note: when both `interactive` and `tty` are set, callers typically use + * bun-pty to provide a real PTY master; podman exec passes through. + */ +export function buildPodmanExecArgs(opts: ExecOptions): string[] { + const args: string[] = ["exec"] + if (opts.interactive) args.push("--interactive") + if (opts.tty) args.push("--tty") + if (opts.workdir) args.push("--workdir", opts.workdir) + for (const [k, v] of Object.entries(opts.env ?? {})) { + args.push("--env", `${k}=${v}`) + } + args.push(containerName(opts.loopId), opts.command, ...opts.args) + return args +} diff --git a/server/src/port-proxy-rs/Cargo.lock b/server/src/port-proxy-rs/Cargo.lock new file mode 100644 index 00000000..eac98d75 --- /dev/null +++ b/server/src/port-proxy-rs/Cargo.lock @@ -0,0 +1,368 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "loopat-port-proxy" +version = "0.1.0" +dependencies = [ + "notify", + "serde", + "serde_json", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.1", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/server/src/port-proxy-rs/Cargo.toml b/server/src/port-proxy-rs/Cargo.toml new file mode 100644 index 00000000..ec09ae8f --- /dev/null +++ b/server/src/port-proxy-rs/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "loopat-port-proxy" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +notify = "7" + +[profile.release] +opt-level = "s" +lto = true +strip = true diff --git a/server/src/port-proxy-rs/src/main.rs b/server/src/port-proxy-rs/src/main.rs new file mode 100644 index 00000000..229398ba --- /dev/null +++ b/server/src/port-proxy-rs/src/main.rs @@ -0,0 +1,319 @@ +use notify::{EventKind, RecursiveMode, Watcher}; +use serde::Deserialize; +use std::collections::HashMap; +use std::io; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream, UdpSocket}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +// ── Config ─────────────────────────────────────────────────────── +fn loops_dir() -> PathBuf { + PathBuf::from(std::env::var("LOOPAT_LOOPS_DIR").unwrap_or_else(|_| "/loopat/loops".into())) +} + +fn workspace() -> String { + std::env::var("LOOPAT_WORKSPACE").unwrap_or_else(|_| "loopat".into()) +} + +fn container_name(loop_id: &str) -> String { + format!("loopat-{}-{}", workspace(), loop_id) +} + +// ── Loop metadata ───────────────────────────────────────────────── +#[derive(Deserialize, Debug, Clone)] +struct LoopMeta { + #[serde(rename = "shareEnabled", default)] + share_enabled: bool, + #[serde(rename = "sharePort")] + share_port: Option<u16>, + #[serde(rename = "shareExternalPort")] + share_external_port: Option<u16>, + #[serde(rename = "shareProtocol", default)] + share_protocol: String, +} + +fn load_meta(loop_id: &str) -> Option<LoopMeta> { + let p = loops_dir().join(loop_id).join("meta.json"); + let bytes = std::fs::read(p).ok()?; + serde_json::from_slice(&bytes).ok() +} + +// ── Port mapping ────────────────────────────────────────────────── +#[derive(Debug, Clone, PartialEq, Eq)] +struct PortMapping { + external_port: u16, + loop_id: String, + container: String, + internal_port: u16, + protocol: String, +} + +fn collect_mappings() -> Vec<PortMapping> { + let dir = loops_dir(); + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return vec![], + }; + let mut out = Vec::new(); + for entry in entries.flatten() { + let loop_id = entry.file_name().to_string_lossy().to_string(); + if let Some(meta) = load_meta(&loop_id) { + if meta.share_enabled { + if let (Some(ext), Some(int)) = (meta.share_external_port, meta.share_port) { + let proto = match meta.share_protocol.as_str() { + "udp" => "udp", + "static" => "static", + _ => "tcp", + }; + out.push(PortMapping { + external_port: ext, + loop_id: loop_id.clone(), + container: container_name(&loop_id), + internal_port: int, + protocol: proto.to_string(), + }); + } + } + } + } + out.sort_by_key(|m| m.external_port); + out +} + +// ── TCP relay ───────────────────────────────────────────────────── +fn spawn_tcp_relay(port: u16, container: String, internal_port: u16) { + thread::spawn(move || { + let addr = format!("0.0.0.0:{port}"); + let listener = match TcpListener::bind(&addr) { + Ok(l) => l, + Err(e) => { + eprintln!("[port-proxy] TCP bind {addr} failed: {e}"); + return; + } + }; + eprintln!("[port-proxy] TCP {port} → {container}:{internal_port}"); + + for conn in listener.incoming() { + let mut client = match conn { + Ok(c) => c, + Err(_) => continue, + }; + let peer = client.peer_addr().map(|a| a.to_string()).unwrap_or_default(); + eprintln!("[port-proxy] TCP {port} connect from {peer}"); + let target = format!("{container}:{internal_port}"); + let mut upstream = match TcpStream::connect(&target) { + Ok(u) => u, + Err(e) => { + eprintln!("[port-proxy] TCP {port} connect upstream {target} failed: {e}"); + continue; + } + }; + let mut c2 = client.try_clone().unwrap(); + let mut u2 = upstream.try_clone().unwrap(); + let t1 = thread::spawn(move || { let n = io::copy(&mut client, &mut upstream).unwrap_or(0); eprintln!("[port-proxy] TCP {port} client→upstream {n}B"); }); + let t2 = thread::spawn(move || { let n = io::copy(&mut u2, &mut c2).unwrap_or(0); eprintln!("[port-proxy] TCP {port} upstream→client {n}B"); }); + t1.join().ok(); + t2.join().ok(); + eprintln!("[port-proxy] TCP {port} disconnect {peer}"); + } + }); +} + +// ── UDP relay ───────────────────────────────────────────────────── +fn spawn_udp_relay(port: u16, container: String, internal_port: u16) { + thread::spawn(move || { + let addr = format!("0.0.0.0:{port}"); + let sock = match UdpSocket::bind(&addr) { + Ok(s) => s, + Err(e) => { + eprintln!("[port-proxy] UDP bind {addr} failed: {e}"); + return; + } + }; + let target = format!("{container}:{internal_port}"); + eprintln!("[port-proxy] UDP {port} → {target}"); + sock.set_read_timeout(Some(Duration::from_secs(1))).ok(); + let mut buf = [0u8; 65536]; + loop { + match sock.recv_from(&mut buf) { + Ok((n, src)) => { + if let Ok(upstream) = UdpSocket::bind("0.0.0.0:0") { + upstream.set_read_timeout(Some(Duration::from_millis(500))).ok(); + if let Ok(_) = upstream.send_to(&buf[..n], &target) { + let mut rbuf = [0u8; 65536]; + if let Ok((rn, _)) = upstream.recv_from(&mut rbuf) { + sock.send_to(&rbuf[..rn], src).ok(); + } + } + } + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => continue, + Err(_) => break, + } + } + }); +} + +// ── Static file server ───────────────────────────────────────────── +fn spawn_static_server(port: u16, loop_id: String) { + thread::spawn(move || { + let addr = format!("0.0.0.0:{port}"); + let listener = match TcpListener::bind(&addr) { + Ok(l) => l, + Err(e) => { + eprintln!("[port-proxy] static bind {addr} failed: {e}"); + return; + } + }; + let workdir = loops_dir().join(&loop_id).join("workdir"); + eprintln!("[port-proxy] static {port} → {loop_id}"); + + for conn in listener.incoming() { + let mut stream = match conn { + Ok(c) => c, + Err(_) => continue, + }; + stream.set_read_timeout(Some(Duration::from_secs(10))).ok(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { continue; } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { continue; } + let path = parts[1]; + + loop { + let mut h = String::new(); + if reader.read_line(&mut h).is_err() { break; } + if h.trim().is_empty() { break; } + } + + let rel = path.trim_start_matches('/'); + let rel = if rel.is_empty() { "index.html" } else { rel }; + let full = workdir.join(rel); + let canonical = match full.canonicalize() { + Ok(c) => c, + Err(_) => { respond(&mut stream, 404, "Not Found", b"Not found"); continue; } + }; + let canonical_wd = match workdir.canonicalize() { + Ok(c) => c, + Err(_) => { respond(&mut stream, 500, "Internal Server Error", b"Workdir error"); continue; } + }; + if !canonical.starts_with(&canonical_wd) { + respond(&mut stream, 403, "Forbidden", b"Forbidden"); continue; + } + if canonical.is_dir() { + let index = canonical.join("index.html"); + if index.is_file() { + match std::fs::read(&index) { + Ok(data) => respond(&mut stream, 200, "OK", &data), + Err(_) => respond(&mut stream, 500, "Internal Server Error", b"Read error"), + } + } else { + respond(&mut stream, 403, "Forbidden", b"Directory listing not allowed"); + } + continue; + } + match std::fs::read(&canonical) { + Ok(data) => respond(&mut stream, 200, "OK", &data), + Err(_) => respond(&mut stream, 500, "Internal Server Error", b"Read error"), + } + } + }); +} + +fn respond(stream: &mut TcpStream, code: u16, reason: &str, body: &[u8]) { + let _ = write!(stream, "HTTP/1.1 {code} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + let _ = stream.write_all(body); +} + +// ── Listener lifecycle ──────────────────────────────────────────── +type ListenerMap = Arc<Mutex<HashMap<u16, thread::JoinHandle<()>>>>; + +fn sync_listeners(listeners: &ListenerMap, mappings: &[PortMapping]) { + let mut guard = listeners.lock().unwrap(); + let desired: HashMap<u16, &PortMapping> = mappings.iter().map(|m| (m.external_port, m)).collect(); + + let before = guard.len(); + guard.retain(|port, _handle| { + if desired.contains_key(port) { + true + } else { + eprintln!("[port-proxy] removing listener on port {port}"); + false + } + }); + + for m in mappings { + if guard.contains_key(&m.external_port) { + continue; + } + let handle = match m.protocol.as_str() { + "udp" => { + let (port, container, internal) = (m.external_port, m.container.clone(), m.internal_port); + thread::spawn(move || spawn_udp_relay(port, container, internal)) + } + "static" => { + let (port, loop_id) = (m.external_port, m.loop_id.clone()); + thread::spawn(move || spawn_static_server(port, loop_id)) + } + _ => { + let (port, container, internal) = (m.external_port, m.container.clone(), m.internal_port); + thread::spawn(move || spawn_tcp_relay(port, container, internal)) + } + }; + guard.insert(m.external_port, handle); + } + + let after = guard.len(); + if before != after || !mappings.is_empty() { + let active: Vec<u16> = guard.keys().copied().collect(); + eprintln!("[port-proxy] sync {before}→{after} listeners: {active:?}"); + } +} + +// ── File watcher ────────────────────────────────────────────────── +fn watch_loops(listeners: ListenerMap) -> notify::Result<()> { + let (tx, rx) = std::sync::mpsc::channel(); + let mut watcher = notify::recommended_watcher(tx)?; + // NonRecursive avoids permission errors from workdir subdirectories. + // Catches: loop dir create/delete, plus .port-proxy-trigger touches. + watcher.watch(&loops_dir(), RecursiveMode::NonRecursive)?; + + eprintln!("[port-proxy] watching {}", loops_dir().display()); + + // Initial sync + sync_listeners(&listeners, &collect_mappings()); + + for event in rx { + let Ok(event) = event else { continue }; + match event.kind { + EventKind::Create(_) | EventKind::Remove(_) | EventKind::Modify(_) => { + // Trigger on: loop dir create/remove, .port-proxy-trigger touched, + // or any change directly inside the watched loops dir. + let relevant = event.paths.iter().any(|p| { + *p == loops_dir() || p.parent().map_or(false, |par| par == loops_dir()) + }); + if relevant { + eprintln!("[port-proxy] trigger: {}", event.paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")); + thread::sleep(Duration::from_millis(200)); + sync_listeners(&listeners, &collect_mappings()); + } + } + _ => {} + } + } + Ok(()) +} + +// ── Main ────────────────────────────────────────────────────────── +fn main() { + eprintln!("[port-proxy] starting"); + let listeners: ListenerMap = Arc::new(Mutex::new(HashMap::new())); + if let Err(e) = watch_loops(listeners) { + eprintln!("[port-proxy] watcher error: {e}"); + } +} diff --git a/server/src/presets.ts b/server/src/presets.ts new file mode 100644 index 00000000..3cc86ebd --- /dev/null +++ b/server/src/presets.ts @@ -0,0 +1,30 @@ +export const DEFAULT_PROVIDER_PRESETS: Array<{ name: string; baseUrl: string; models: string[] }> = [ + { name: "Anthropic", baseUrl: "https://api.anthropic.com", + models: ["claude-sonnet-4-20250514", "claude-opus-4-7-20251101"] }, + { name: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", + models: ["deepseek-v4-pro[1m]", "deepseek-v4-flash[1m]"] }, + { name: "Kimi", baseUrl: "https://api.moonshot.cn/anthropic", + models: ["kimi-k2.6"] }, + { name: "MiniMax", baseUrl: "https://api.minimaxi.com/anthropic", + models: ["MiniMax-M2.7"] }, + { name: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", + models: ["anthropic/claude-sonnet-4", "openai/gpt-4o", "google/gemini-2.5-flash"] }, +] + +export const DEFAULT_MISE_TOOL_PRESETS: Array<{ name: string; suggestedVersion: string; description?: string; backend?: string }> = [ + { name: "node", suggestedVersion: "22", description: "Node.js runtime" }, + { name: "python", suggestedVersion: "3.12", description: "Python runtime" }, + { name: "go", suggestedVersion: "1.22", description: "Go programming language" }, + { name: "rust", suggestedVersion: "stable", description: "Rust programming language" }, + { name: "bun", suggestedVersion: "latest", description: "Bun all-in-one runtime" }, + { name: "java", suggestedVersion: "21", description: "Java Development Kit" }, + { name: "terraform", suggestedVersion: "1.9", description: "Infrastructure as code", backend: "aqua:hashicorp/terraform" }, + { name: "lua", suggestedVersion: "5.1", description: "Lua scripting language" }, + { name: "zig", suggestedVersion: "0.13", description: "Zig general-purpose language" }, + { name: "ripgrep", suggestedVersion: "14.1", description: "Line-oriented search tool", backend: "aqua:BurntSushi/ripgrep" }, + { name: "fd", suggestedVersion: "10.2", description: "Fast file finder", backend: "aqua:sharkdp/fd" }, + { name: "jq", suggestedVersion: "1.7", description: "Command-line JSON processor", backend: "aqua:jqlang/jq" }, +] + +/** @deprecated — use DEFAULT_PROVIDER_PRESETS */ +export const PROVIDER_PRESETS = DEFAULT_PROVIDER_PRESETS diff --git a/server/src/profiles.ts b/server/src/profiles.ts new file mode 100644 index 00000000..79489e5c --- /dev/null +++ b/server/src/profiles.ts @@ -0,0 +1,178 @@ +/** + * Profile resolver — CC-native model (post-2026-05 refactor). + * + * A "profile" in loopat is a directory under `.loopat/profiles/<name>/` + * that contains a `.claude/` subdir (the same shape CC's project-tier uses: + * settings.json + CLAUDE.md + skills/ + agents/). No loopat-invented schema. + * + * On loop spawn, loopat: + * 1. Determines active profiles (user defaults + CLI flags) + * 2. Merges team's `.loopat/.claude/` + each active profile's `.claude/` + * + personal layer into loop's `.claude/` (handled by compose.ts) + * 3. Reads merged settings.json's `enabledPlugins` + `extraKnownMarketplaces` + * to drive plugin installation (handled by plugin-installer.ts) + * + * See docs/composition.md. + */ + +import { existsSync } from "node:fs" +import { readFile, readdir } from "node:fs/promises" +import { join } from "node:path" +import { + personalClaudeDir, + personalLoopatConfigPath, + personalVaultDir, + personalKnowledgeProfileClaudeDir, + personalKnowledgeProfilesDir, + personalKnowledgeTeamClaudeDir, +} from "./paths" + +/** personal/<u>/.loopat/config.json fields relevant to profile resolution. */ +export type PersonalProfileConfig = { + default_profiles?: string[] + default_vault?: string + prefs?: Record<string, unknown> +} + +/** Output of resolveLoopPlan — describes the materialization sources. */ +export type LoopPlan = { + user: string + /** `.claude/` dirs to merge into the loop's .claude/, in load order + * (later sources win on conflicts; team first, profiles in declared order, + * personal last). */ + claudeSources: Array<{ source: string; dir: string }> + /** Active profile names (excludes team & personal). */ + profiles: string[] + /** Vault selection (from personal config or override). */ + vault?: string + /** Resolved vault dir on host (if exists). */ + vaultDir?: string +} + +export type ResolveInput = { + user: string + /** Profiles added via CLI (+name). */ + cliAdded?: string[] + /** Profiles removed via CLI (-name). */ + cliRemoved?: string[] + /** Hard override — replaces default_profiles. */ + overrideProfiles?: string[] + /** Override vault selection. */ + vaultOverride?: string + /** @deprecated Workdir is the SDK's project tier, read directly via + * settingSources='project'. It is NOT merged into the user tier any more + * (otherwise edits to workdir/.claude/ would change a frozen loop's + * user-tier snapshot, violating principle 1). The field is kept for the + * loopat CLI which still bundles workdir into a one-shot compose; remove + * once that path is gone. */ + workdir?: string +} + +async function readPersonalConfig(user: string): Promise<PersonalProfileConfig> { + const path = personalLoopatConfigPath(user) + if (!existsSync(path)) return {} + try { + return JSON.parse(await readFile(path, "utf8")) as PersonalProfileConfig + } catch { + return {} + } +} + +/** + * Compute active profile set: (default_profiles ∪ cliAdded) − cliRemoved, + * with overrideProfiles replacing default_profiles when set. Order preserved: + * defaults first, then cliAdded. Team-tier is always implicit (handled by + * compose); no need to include it here. + */ +function computeActiveProfiles( + cfg: PersonalProfileConfig, + cliAdded: string[], + cliRemoved: string[], + overrideProfiles?: string[], +): string[] { + const base = overrideProfiles ?? cfg.default_profiles ?? [] + const removed = new Set(cliRemoved) + const all = [...base, ...cliAdded] + const out: string[] = [] + const seen = new Set<string>() + for (const p of all) { + if (removed.has(p) || seen.has(p)) continue + seen.add(p) + out.push(p) + } + return out +} + +/** + * Main entry: produce a LoopPlan from inputs. Pure / no side effects. + * Validates that named profiles actually have `.claude/` subdirs (otherwise + * they'd be silently invisible). + */ +export async function resolveLoopPlan(input: ResolveInput): Promise<LoopPlan> { + const { user, cliAdded = [], cliRemoved = [], overrideProfiles, vaultOverride, workdir } = input + + const cfg = await readPersonalConfig(user) + const activeNames = computeActiveProfiles(cfg, cliAdded, cliRemoved, overrideProfiles) + + // Validate — profiles live in the user's PER-USER knowledge repo. + const profilesRoot = personalKnowledgeProfilesDir(user) + if (activeNames.length > 0 && !existsSync(profilesRoot)) { + throw new Error(`profiles dir not found: ${profilesRoot}`) + } + for (const name of activeNames) { + const cdir = personalKnowledgeProfileClaudeDir(user, name) + if (!existsSync(cdir)) { + throw new Error(`profile "${name}" has no .claude/ dir at ${cdir}`) + } + } + + // Build claudeSources in merge order + const claudeSources: LoopPlan["claudeSources"] = [] + const teamDir = personalKnowledgeTeamClaudeDir(user) + if (existsSync(teamDir)) { + claudeSources.push({ source: "team", dir: teamDir }) + } + for (const name of activeNames) { + claudeSources.push({ source: `profile:${name}`, dir: personalKnowledgeProfileClaudeDir(user, name) }) + } + // Personal `.claude/` — 4th layer. Same CC-native shape as workspace + profile. + const personalCdir = personalClaudeDir(user) + if (existsSync(personalCdir)) { + claudeSources.push({ source: `personal:${user}`, dir: personalCdir }) + } + + // Repo `.claude/` — 5th (highest) layer. CC project-tier from workdir. + // Optional; only if workdir is set AND has a .claude/ subdir. + if (workdir) { + const repoCdir = join(workdir, ".claude") + if (existsSync(repoCdir)) { + claudeSources.push({ source: `repo:${workdir}`, dir: repoCdir }) + } + } + + const vault = vaultOverride ?? cfg.default_vault + const vaultDir = vault ? personalVaultDir(user, vault) : undefined + + return { + user, + claudeSources, + profiles: activeNames, + vault, + vaultDir: vaultDir && existsSync(vaultDir) ? vaultDir : undefined, + } +} + +/** List a user's available profile names = direct subdirs of THEIR per-user + * knowledge `profiles/` that contain `.claude/`. */ +export async function listProfiles(user: string): Promise<string[]> { + const root = personalKnowledgeProfilesDir(user) + if (!existsSync(root)) return [] + const entries = await readdir(root, { withFileTypes: true }) + const out: string[] = [] + for (const e of entries) { + if (!e.isDirectory() || e.name.startsWith(".")) continue + if (!existsSync(personalKnowledgeProfileClaudeDir(user, e.name))) continue + out.push(e.name) + } + return out.sort() +} diff --git a/server/src/providers.ts b/server/src/providers.ts new file mode 100644 index 00000000..266b78ba --- /dev/null +++ b/server/src/providers.ts @@ -0,0 +1,70 @@ +/** + * Git-host provider registry bootstrap. + * + * - Built-in (open-source) providers self-register via the static imports below. + * - External / internal providers live OUTSIDE the repo, in + * `LOOPAT_HOME/extensions/providers/*.{ts,js,mjs}`. `loadExtensionProviders()` + * dynamically imports each file and registers its default export. An extension + * is a plain object shaped like GitHostProvider — it does NOT import loopat, so + * an internal platform's adapter never has to enter the open-source core. + */ +import { join } from "node:path" +import { existsSync } from "node:fs" +import { readdir } from "node:fs/promises" +import { pathToFileURL } from "node:url" +import { registerProvider, getProvider, type GitHostProvider } from "./git-host" +import { extensionsProvidersDir } from "./paths" + +import "./github" // built-in, open-source + +let extLoaded = false +// Ids of providers loaded from extension files (NOT the built-in github). A +// dropped-in extension IS the active provider — see resolveProviderId(). +const extensionProviderIds: string[] = [] + +/** Idempotently load external provider extensions from the extensions dir. */ +export async function loadExtensionProviders(): Promise<void> { + if (extLoaded) return + extLoaded = true + const dir = extensionsProvidersDir() + if (!existsSync(dir)) return + let files: string[] = [] + try { files = await readdir(dir) } catch { return } + for (const f of files) { + if (!/\.(ts|js|mjs)$/.test(f)) continue + try { + const mod: any = await import(pathToFileURL(join(dir, f)).href) + const p = mod.default ?? mod.provider + if (p?.id && typeof p.authenticate === "function" && typeof p.ensureRepo === "function") { + registerProvider(p as GitHostProvider) + if (!extensionProviderIds.includes(p.id)) extensionProviderIds.push(p.id) + console.log(`[loopat] loaded git-host extension: ${p.id}`) + } else { + console.warn(`[loopat] ${f}: not a valid GitHostProvider (need id / authenticate / ensureRepo)`) + } + } catch (e: any) { + console.warn(`[loopat] failed to load provider extension ${f}: ${e?.message ?? e}`) + } + } +} + +/** + * Resolve the active git-host provider id. + * + * A provider extension dropped into `LOOPAT_HOME/extensions/providers/` IS the + * provider: if any extension is present it wins outright — no `config.json` + * `gitHost.provider` needed. Multiple extensions → any one of them (undefined + * behavior). With no extension present, fall back to the explicitly-requested + * id, then the built-in `github`. + */ +export async function resolveProviderId(requested?: string): Promise<string> { + await loadExtensionProviders() + if (extensionProviderIds.length > 0) return extensionProviderIds[0] + return requested || "github" +} + +/** Resolve and return the active provider object (see resolveProviderId). Its + * baseUrl / defaultRepo / tokenHelp let loopat run with no config.json. */ +export async function resolveProvider(requested?: string): Promise<GitHostProvider | undefined> { + return getProvider(await resolveProviderId(requested)) +} diff --git a/server/src/serve-rs/Cargo.lock b/server/src/serve-rs/Cargo.lock new file mode 100644 index 00000000..e9baf730 --- /dev/null +++ b/server/src/serve-rs/Cargo.lock @@ -0,0 +1,258 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "loopat-serve" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tiny_http", + "ureq", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/server/src/serve-rs/Cargo.toml b/server/src/serve-rs/Cargo.toml new file mode 100644 index 00000000..d59971fc --- /dev/null +++ b/server/src/serve-rs/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "loopat-serve" +version = "0.1.0" +edition = "2024" + +[dependencies] +tiny_http = "0.12" +ureq = { version = "3", default-features = false, features = ["gzip"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true diff --git a/server/src/serve-rs/src/main.rs b/server/src/serve-rs/src/main.rs new file mode 100644 index 00000000..a81cdb48 --- /dev/null +++ b/server/src/serve-rs/src/main.rs @@ -0,0 +1,543 @@ +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +// ── Config ─────────────────────────────────────────────────────── +static SERVE_PORT: LazyLock<u16> = + LazyLock::new(|| std::env::var("LOOPAT_SERVE_PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(7788)); +const SERVE_HOST: &str = "0.0.0.0"; + +fn loops_dir() -> PathBuf { + PathBuf::from( + std::env::var("LOOPAT_LOOPS_DIR").unwrap_or_else(|_| "/loopat/loops".into()), + ) +} + +fn workspace() -> String { + std::env::var("LOOPAT_WORKSPACE").unwrap_or_else(|_| "loopat".into()) +} + +fn container_name(loop_id: &str) -> String { + format!("loopat-{}-{}", workspace(), loop_id) +} + +// ── Blocked paths ───────────────────────────────────────────────── +const BLOCKED: &[&str] = &[ + ".git", ".ssh", ".env", "node_modules", ".DS_Store", ".bun", ".claude", ".vscode", + ".idea", +]; + +fn is_blocked(path: &str) -> bool { + path.split('/').any(|p| BLOCKED.contains(&p) || p.starts_with(".env")) +} + +// ── MIME ────────────────────────────────────────────────────────── +fn mime_type(path: &str) -> &'static str { + let ext = Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + match ext { + "html" => "text/html", + "css" => "text/css", + "js" => "application/javascript", + "json" => "application/json", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "eot" => "application/vnd.ms-fontobject", + "otf" => "font/otf", + "txt" => "text/plain", + "md" => "text/markdown", + "xml" => "application/xml", + "pdf" => "application/pdf", + "zip" => "application/zip", + "tar" => "application/x-tar", + "gz" => "application/gzip", + "wasm" => "application/wasm", + "webp" => "image/webp", + "mp4" => "video/mp4", + "webm" => "video/webm", + "mp3" => "audio/mpeg", + "ogg" => "audio/ogg", + "wav" => "audio/wav", + "csv" => "text/csv", + "yaml" | "yml" => "text/yaml", + "toml" => "text/toml", + _ => "application/octet-stream", + } +} + +// ── Loop metadata ───────────────────────────────────────────────── +#[derive(Deserialize)] +struct LoopMeta { + #[serde(default)] + #[allow(dead_code)] + title: String, + #[serde(rename = "shareEnabled", default)] + share_enabled: bool, + #[serde(rename = "shareMode", default)] + share_mode: String, + #[serde(rename = "shareAlias", default)] + share_alias: Option<String>, + #[serde(rename = "sharePort")] + share_port: Option<u16>, + #[serde(rename = "shareExternalPort")] + #[allow(dead_code)] + share_external_port: Option<u16>, + #[serde(rename = "shareProtocol", default)] + #[allow(dead_code)] + share_protocol: String, +} + +fn load_meta(loop_id: &str) -> Option<LoopMeta> { + let p = loops_dir().join(loop_id).join("meta.json"); + let bytes = fs::read(p).ok()?; + serde_json::from_slice(&bytes).ok() +} + +// ── Alias cache ─────────────────────────────────────────────────── +struct Cache { + aliases: HashMap<String, String>, + last_rebuild: Instant, +} + +impl Cache { + fn new() -> Self { + let mut c = Self { aliases: HashMap::new(), last_rebuild: Instant::now() }; + c.rebuild(); + c + } + + fn rebuild(&mut self) { + self.aliases.clear(); + let dir = loops_dir(); + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let loop_id = entry.file_name().to_string_lossy().to_string(); + if let Some(meta) = load_meta(&loop_id) { + if meta.share_enabled { + if let Some(ref alias) = meta.share_alias { + self.aliases.insert(alias.clone(), loop_id.clone()); + } + } + } + } + self.last_rebuild = Instant::now(); + } + + fn maybe_rebuild(&mut self) { + if self.last_rebuild.elapsed() > Duration::from_secs(30) { + self.rebuild(); + } + } + + fn resolve(&mut self, subdomain: &str) -> Option<String> { + // Check alias cache + if let Some(id) = self.aliases.get(subdomain).cloned() { + let meta = load_meta(&id); + if meta.map_or(false, |m| m.share_enabled) { + return Some(id); + } + self.aliases.remove(subdomain); + } + + // Scan loops + let dir = loops_dir(); + for entry in fs::read_dir(&dir).ok()?.flatten() { + let loop_id = entry.file_name().to_string_lossy().to_string(); + let short_id = &loop_id[..loop_id.len().min(8)]; + if short_id != subdomain { + continue; + } + let meta = load_meta(&loop_id)?; + if !meta.share_enabled { + return None; + } + if let Some(ref alias) = meta.share_alias { + self.aliases.insert(alias.clone(), loop_id.clone()); + } + return Some(loop_id); + } + // Fallback: scan by alias + for entry in fs::read_dir(&dir).ok()?.flatten() { + let loop_id = entry.file_name().to_string_lossy().to_string(); + let meta = load_meta(&loop_id)?; + if !meta.share_enabled { + continue; + } + if meta.share_alias.as_deref() == Some(subdomain) { + self.aliases.insert(subdomain.to_string(), loop_id.clone()); + return Some(loop_id); + } + } + None + } +} + +type CacheRef = Arc<Mutex<Cache>>; + +// ── HTTP response helpers ───────────────────────────────────────── + +/// HTML error page with inline styles — works in any browser, no external deps. +fn error_page(code: u16, title: &str, detail: &str) -> String { + let color = if code >= 500 { "#dc2626" } else { "#d97706" }; + format!( + r#"<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>{code} — {title}</title> +<style> + * {{ margin:0; padding:0; box-sizing:border-box }} + body {{ font-family: system-ui,-apple-system,sans-serif; background:#fafafa; color:#1f2937; display:flex; align-items:center; justify-content:center; min-height:100vh }} + .card {{ background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:40px 48px; max-width:520px; width:90%; text-align:center; box-shadow:0 1px 3px rgba(0,0,0,.04) }} + .code {{ font-size:64px; font-weight:800; color:{color}; line-height:1; margin-bottom:8px }} + .title {{ font-size:18px; font-weight:600; margin-bottom:12px }} + .detail {{ font-size:14px; color:#6b7280; line-height:1.6 }} + .hint {{ font-size:12px; color:#9ca3af; margin-top:20px; padding-top:16px; border-top:1px solid #f3f4f6 }} + .hint code {{ font-size:11px; background:#f3f4f6; padding:2px 6px; border-radius:4px }} +</style> +</head> +<body> +<div class="card"> + <div class="code">{code}</div> + <div class="title">{title}</div> + <div class="detail">{detail}</div> + <div class="hint">loopat workspace serve</div> +</div> +</body> +</html>"# + ) +} + +fn reply_html(stream: &mut dyn Write, code: u16, reason: &str, html: &str) { + let _ = write!(stream, "HTTP/1.1 {code} {reason}\r\n"); + let _ = write!(stream, "Content-Type: text/html; charset=utf-8\r\n"); + let _ = write!(stream, "Content-Length: {}\r\n", html.len()); + let _ = write!(stream, "Connection: close\r\n\r\n"); + let _ = stream.write_all(html.as_bytes()); +} + +fn reply_error(stream: &mut dyn Write, code: u16, reason: &str, title: &str, detail: &str) { + let html = error_page(code, title, detail); + reply_html(stream, code, reason, &html); +} + +// ── Static file serving ─────────────────────────────────────────── +fn serve_static(stream: &mut dyn Write, workdir: &Path, url_path: &str) { + let rel = url_path.trim_start_matches('/'); + let rel = if rel.is_empty() { "index.html" } else { rel }; + + let full = workdir.join(rel); + let canonical = match full.canonicalize() { + Ok(c) => c, + Err(_) => { + let with_index = full.join("index.html"); + if with_index.exists() { + return serve_static(stream, workdir, &format!("/{rel}/index.html")); + } + return reply_error(stream, 404, "Not Found", "File not found", "The requested path does not exist in this workspace."); + } + }; + let canonical_workdir = match workdir.canonicalize() { + Ok(c) => c, + Err(_) => return reply_error(stream, 500, "Internal Server Error", "Workdir error", "Cannot resolve the workspace directory."), + }; + if !canonical.starts_with(&canonical_workdir) { + return reply_error(stream, 403, "Forbidden", "Access denied", "Path traversal is not allowed."); + } + + if is_blocked(rel) { + return reply_error(stream, 403, "Forbidden", "Access denied", "This path is blocked for security reasons."); + } + + if canonical.is_dir() { + let index = canonical.join("index.html"); + if index.exists() { + return serve_static(stream, workdir, &format!("/{rel}/index.html")); + } + return reply_error(stream, 403, "Forbidden", "Directory listing disabled", "Directory listings are not allowed. Append /index.html if the file exists."); + } + + let data = match fs::read(&canonical) { + Ok(d) => d, + Err(_) => return reply_error(stream, 500, "Internal Server Error", "Read error", "Failed to read the requested file."), + }; + let mime = mime_type(rel); + + let _ = write!(stream, "HTTP/1.1 200 OK\r\n"); + let _ = write!(stream, "Content-Type: {mime}\r\n"); + let _ = write!(stream, "Content-Length: {}\r\n", data.len()); + let _ = write!(stream, "Cache-Control: no-cache\r\n"); + let _ = write!(stream, "Connection: close\r\n\r\n"); + let _ = stream.write_all(&data); +} + +// ── HTTP proxy ──────────────────────────────────────────────────── +fn apply_headers<B>( + mut req: ureq::RequestBuilder<B>, + headers: &[(String, String)], + target: &str, + port: u16, +) -> ureq::RequestBuilder<B> { + for (key, value) in headers { + match key.to_lowercase().as_str() { + "host" | "connection" | "keep-alive" | "transfer-encoding" | "te" + | "trailer" | "upgrade" => continue, + _ => { + req = req.header(key, value); + } + } + } + req = req.header("host", &format!("{target}:{port}")); + req = req.header("x-forwarded-proto", "http"); + req +} + +fn proxy_to_port( + stream: &mut dyn Write, + loop_id: &str, + port: u16, + method: &str, + path: &str, + headers: &[(String, String)], + body: Option<&[u8]>, +) { + let target = container_name(loop_id); + let url = format!("http://{target}:{port}{path}"); + eprintln!("[serve] proxy → {target}:{port}{path}"); + + let agent = ureq::Agent::new_with_config( + ureq::Agent::config_builder() + .proxy(None) + .timeout_per_call(Some(Duration::from_secs(30))) + .build(), + ); + + let has_body = body.map_or(false, |b| !b.is_empty()); + + let result = match (method, has_body) { + ("GET", _) | ("HEAD", _) | ("OPTIONS", _) | ("DELETE", _) => { + let req = match method { + "GET" => agent.get(&url), + "HEAD" => agent.head(&url), + "OPTIONS" => agent.options(&url), + "DELETE" => agent.delete(&url), + _ => agent.get(&url), + }; + apply_headers(req, headers, &target, port).call() + } + _ => { + let req = match method { + "POST" => agent.post(&url), + "PUT" => agent.put(&url), + "PATCH" => agent.patch(&url), + _ => agent.post(&url), + }; + let b = body.unwrap_or(&[]); + apply_headers(req, headers, &target, port).send(b) + } + }; + + let resp = match result { + Ok(r) => r, + Err(e) => { + eprintln!("[serve] proxy error → {target}:{port}: {e}"); + let detail = format!("The service on port {port} is not responding. Make sure your app is running inside the sandbox."); + return reply_error(stream, 502, "Bad Gateway", "Service not reachable", &detail); + } + }; + + let status = resp.status().as_u16(); + let reason = resp.status().canonical_reason().unwrap_or("Unknown"); + + let _ = write!(stream, "HTTP/1.1 {status} {reason}\r\n"); + + let skip: HashSet<&str> = [ + "connection", "keep-alive", "transfer-encoding", "content-encoding", + "content-length", + ] + .into(); + + for (key, value) in resp.headers().iter() { + let key_str = key.as_str(); + if !skip.contains(key_str.to_lowercase().as_str()) { + let v = value.to_str().unwrap_or(""); + let _ = write!(stream, "{key_str}: {v}\r\n"); + } + } + + let resp_body = resp.into_body().read_to_vec().unwrap_or_default(); + let _ = write!(stream, "Content-Length: {}\r\n", resp_body.len()); + let _ = write!(stream, "Connection: close\r\n\r\n"); + let _ = stream.write_all(&resp_body); +} + +// ── Request parsing ─────────────────────────────────────────────── +struct Request { + method: String, + path: String, + headers: Vec<(String, String)>, + body: Option<Vec<u8>>, +} + +fn parse_request(stream: &mut dyn Read) -> Option<Request> { + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).ok()?; + if n == 0 { + return None; + } + let raw = String::from_utf8_lossy(&buf[..n]); + + let parts: Vec<&str> = raw.split("\r\n\r\n").collect(); + let header_section = parts.first()?; + let body_offset = header_section.len() + 4; + + let mut lines = header_section.lines(); + let first_line = lines.next()?; + let mut fl_parts = first_line.split_whitespace(); + let method = fl_parts.next()?.to_string(); + let path = fl_parts.next()?.to_string(); + + let mut headers = Vec::new(); + for line in lines { + if let Some((k, v)) = line.split_once(": ") { + headers.push((k.to_string(), v.to_string())); + } + } + + let body = headers + .iter() + .find(|(k, _)| k.to_lowercase() == "content-length") + .and_then(|(_, v)| v.parse::<usize>().ok()) + .and_then(|len| { + if raw.len() >= body_offset + len { + Some(raw[body_offset..body_offset + len].as_bytes().to_vec()) + } else { + None + } + }); + + Some(Request { method, path, headers, body }) +} + +// ── Request handler ─────────────────────────────────────────────── +fn handle(stream: &mut dyn Write, req: &Request, cache: &CacheRef) { + let host = req + .headers + .iter() + .find(|(k, _)| k.to_lowercase() == "host") + .map(|(_, v)| v.as_str()) + .unwrap_or("") + .split(':') + .next() + .unwrap_or("") + .to_lowercase(); + + let subdomain = host.split('.').next().unwrap_or(""); + + let mut cache = cache.lock().unwrap(); + cache.maybe_rebuild(); + let resolved = cache.resolve(subdomain); + drop(cache); + + let loop_id = match resolved { + Some(id) => id, + None => { + eprintln!("[serve] 404 host={host} — no matching loop"); + return reply_error(stream, 404, "Not Found", "No workspace found", + "No shared workspace matches this domain. Check the URL or make sure sharing is enabled for this loop."); + } + }; + + let meta = match load_meta(&loop_id) { + Some(m) => m, + None => return reply_error(stream, 404, "Not Found", "Loop not found", "The workspace metadata is missing. The loop may have been deleted."), + }; + + if !meta.share_enabled { + return reply_error(stream, 403, "Forbidden", "Sharing disabled", "Sharing is currently turned off for this workspace. Enable it in the Share Artifact dialog."); + } + + let workdir = loops_dir().join(&loop_id).join("workdir"); + if !workdir.is_dir() { + return reply_error(stream, 404, "Not Found", "Workdir not found", "The workspace workdir does not exist. Try sending a message to initialize it."); + } + + let mode = meta.share_mode.as_str(); + eprintln!("[serve] {host} → {:.8} mode={mode} path={}", loop_id, req.path); + + if mode == "port" { + if let Some(port) = meta.share_port { + if port < 1024 { + return reply_error(stream, 400, "Bad Request", "Invalid port", "Port must be 1024 or higher. Update the port in the Share Artifact dialog."); + } + proxy_to_port( + stream, + &loop_id, + port, + &req.method, + &req.path, + &req.headers, + req.body.as_deref(), + ); + return; + } + } + serve_static(stream, &workdir, &req.path); +} + +// ── Main ────────────────────────────────────────────────────────── +fn main() { + let cache: CacheRef = Arc::new(Mutex::new(Cache::new())); + + let addr = format!("{SERVE_HOST}:{}", *SERVE_PORT); + let listener = TcpListener::bind(&addr).unwrap_or_else(|e| { + eprintln!("[serve] failed to bind {addr}: {e}"); + std::process::exit(1); + }); + eprintln!("[serve] listening on http://{addr}"); + + for conn in listener.incoming() { + let cache = cache.clone(); + thread::spawn(move || { + let mut stream = match conn { + Ok(s) => s, + Err(_) => return, + }; + stream.set_read_timeout(Some(Duration::from_secs(30))).ok(); + + let req = match parse_request(&mut &stream) { + Some(r) => r, + None => return, + }; + + eprintln!( + "[serve] {} - \"{} {}\"", + stream.peer_addr().map(|a| a.to_string()).unwrap_or_default(), + req.method, + req.path + ); + + let mut buf = Vec::new(); + handle(&mut buf, &req, &cache); + let _ = stream.write_all(&buf); + }); + } +} diff --git a/server/src/serve.ts b/server/src/serve.ts new file mode 100644 index 00000000..3a35f064 --- /dev/null +++ b/server/src/serve.ts @@ -0,0 +1,275 @@ +/** + * Standalone workspace serve service. + * Listens on port 7788, serves loop workdirs via subdomain routing. + * Supports static file serving and HTTP port forwarding. + */ +import { createServer, request as httpRequest } from "node:http" +import { existsSync, statSync, createReadStream, readdirSync, readFileSync as readFileSyncFs } from "node:fs" +import { join, normalize } from "node:path" +import { loopsDir, loopWorkdir, loopMetaPath } from "./paths" + +const SERVE_PORT = Number(process.env.LOOPAT_SERVE_PORT ?? 7788) +const SERVE_HOST = process.env.LOOPAT_SERVE_HOST ?? "127.0.0.1" + +// Blocked paths — never served +const BLOCKED = new Set([ + ".git", ".ssh", ".env", "node_modules", ".DS_Store", + ".bun", ".claude", ".vscode", ".idea", +]) + +function isBlocked(filePath: string): boolean { + const parts = filePath.split("/").filter(Boolean) + return parts.some((p) => BLOCKED.has(p) || p.startsWith(".env")) +} + +const MIME_TYPES: Record<string, string> = { + ".html": "text/html", + ".css": "text/css", + ".js": "application/javascript", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject", + ".otf": "font/otf", + ".txt": "text/plain", + ".md": "text/markdown", + ".xml": "application/xml", + ".pdf": "application/pdf", + ".zip": "application/zip", + ".tar": "application/x-tar", + ".gz": "application/gzip", + ".wasm": "application/wasm", + ".webp": "image/webp", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".wav": "audio/wav", + ".csv": "text/csv", + ".yaml": "text/yaml", + ".yml": "text/yaml", + ".toml": "text/toml", +} + +function getMime(path: string): string { + return MIME_TYPES[normalize(path).split(".").pop() ? `.${normalize(path).split(".").pop()}`.toLowerCase() : ""] || "application/octet-stream" +} + +type LoopMeta = { + id: string + title: string + shareEnabled?: boolean + shareMode?: "static" | "port" + shareAlias?: string + sharePort?: number +} + +// Cache: alias -> loop_id +const aliasCache = new Map<string, string>() + +function loadMeta(loopId: string): LoopMeta | null { + const p = loopMetaPath(loopId) + if (!existsSync(p)) return null + try { + return JSON.parse(readFileSyncFs(p, "utf8")) + } catch { + return null + } +} + +function resolveLoop(host: string): { loopId: string; meta: LoopMeta } | null { + const parts = host.split(".") + if (parts.length < 2) return null + const subdomain = parts[0].toLowerCase() + + // Check alias cache first + if (aliasCache.has(subdomain)) { + const loopId = aliasCache.get(subdomain)! + const meta = loadMeta(loopId) + if (meta?.shareEnabled) return { loopId, meta } + aliasCache.delete(subdomain) + } + + // Scan all loops + let dirs: string[] + try { + dirs = readdirSync(loopsDir()) + } catch { + return null + } + + for (const dir of dirs) { + const meta = loadMeta(dir) + if (!meta) continue + if (!meta.shareEnabled) continue + const shortId = dir.slice(0, 8) + if (shortId === subdomain || meta.shareAlias === subdomain) { + if (meta.shareAlias) aliasCache.set(meta.shareAlias, dir) + return { loopId: dir, meta } + } + } + return null +} + +function rebuildAliasCache() { + aliasCache.clear() + let dirs: string[] + try { + dirs = readdirSync(loopsDir()) + } catch { + return + } + for (const dir of dirs) { + const meta = loadMeta(dir) + if (meta?.shareEnabled && meta.shareAlias) { + aliasCache.set(meta.shareAlias, dir) + } + } +} + +rebuildAliasCache() +setInterval(rebuildAliasCache, 30_000) + +function serveStaticFile(workdir: string, urlPath: string, res: any): boolean { + let rel = decodeURIComponent(urlPath) + if (rel.startsWith("/")) rel = rel.slice(1) + if (!rel) rel = "index.html" + + const full = normalize(join(workdir, rel)) + if (!full.startsWith(normalize(workdir))) { + res.writeHead(403) + res.end("Forbidden") + return true + } + + if (isBlocked(rel)) { + res.writeHead(403) + res.end("Forbidden") + return true + } + + if (!existsSync(full)) { + if (existsSync(join(full, "index.html"))) { + return serveStaticFile(workdir, rel + "/index.html", res) + } + res.writeHead(404) + res.end("Not found") + return true + } + + const s = statSync(full) + if (s.isDirectory()) { + if (existsSync(join(full, "index.html"))) { + return serveStaticFile(workdir, rel + "/index.html", res) + } + res.writeHead(403) + res.end("Directory listing not allowed") + return true + } + + if (!s.isFile()) { + res.writeHead(403) + res.end("Forbidden") + return true + } + + res.writeHead(200, { + "Content-Type": getMime(full), + "Content-Length": s.size, + "Cache-Control": "no-cache", + }) + createReadStream(full).pipe(res) + return true +} + +function proxyToPort(port: number, req: any, res: any): void { + const headers: Record<string, string> = { ...req.headers } + delete headers["host"] + headers["host"] = `localhost:${port}` + if (req.socket.remoteAddress) headers["x-forwarded-for"] = req.socket.remoteAddress + if (req.headers["host"]) headers["x-forwarded-host"] = req.headers["host"] + + const proxyReq = httpRequest({ + hostname: "127.0.0.1", + port, + method: req.method, + path: req.url, + headers, + timeout: 30_000, + }, (proxyRes: any) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers) + proxyRes.pipe(res) + }) + + proxyReq.on("error", () => { + res.writeHead(502) + res.end("Port forwarding error - is the service running?") + }) + + proxyReq.on("timeout", () => { + proxyReq.destroy() + res.writeHead(504) + res.end("Gateway timeout") + }) + + req.pipe(proxyReq) +} + +const server = createServer((req, res) => { + const host = (req.headers["host"] ?? "").split(":")[0].toLowerCase() + const resolved = resolveLoop(host) + + if (!resolved) { + res.writeHead(404, { "Content-Type": "text/plain" }) + res.end("No workspace found for this domain") + return + } + + const { meta, loopId } = resolved + + if (!meta.shareEnabled) { + res.writeHead(403) + res.end("Workspace sharing is disabled") + return + } + + const workdir = loopWorkdir(loopId) + if (!existsSync(workdir)) { + res.writeHead(404) + res.end("Workdir not found") + return + } + + if (meta.shareMode === "port" && meta.sharePort) { + if (meta.sharePort < 1024 || meta.sharePort > 65535) { + res.writeHead(400) + res.end("Invalid port — must be 1024-65535") + return + } + proxyToPort(meta.sharePort, req, res) + } else { + serveStaticFile(workdir, req.url ?? "/", res) + } +}) + +server.on("error", (e: any) => { + if (e.code === "EADDRINUSE") { + console.error(`[loopat] workspace serve port ${SERVE_PORT} already in use`) + } else { + console.error(`[loopat] workspace serve error:`, e) + } +}) + +console.log(`[loopat] workspace serve starting on http://${SERVE_HOST}:${SERVE_PORT}`) +server.listen(SERVE_PORT, SERVE_HOST, () => { + console.log(`[loopat] workspace serve listening on http://${SERVE_HOST}:${SERVE_PORT}`) +}) + +export { server } diff --git a/server/src/session.ts b/server/src/session.ts new file mode 100644 index 00000000..91628ceb --- /dev/null +++ b/server/src/session.ts @@ -0,0 +1,1529 @@ +import { query, type Query, type SDKMessage, type SDKUserMessage, type PermissionMode as SdkPermissionMode, type StopHookInput } from "@anthropic-ai/claude-agent-sdk" +import type { WSContext } from "hono/ws" +import { appendFile, readFile, readdir, rm, writeFile, mkdir } from "node:fs/promises" +import { createWriteStream, mkdirSync, existsSync } from "node:fs" +import { randomUUID } from "node:crypto" +import { join } from "node:path" +import { loopClaudeDir, loopDir, loopHistoryPath, personalSkillsDir, workspaceTeamSkillsDir } from "./paths" +import { resolveSandboxClaudeBinary } from "./claude-binary" +import { loadConfig, loadPersonalConfig, parseDefault, type ProviderConfig } from "./config" +import { buildLoopatAppend } from "./system-prompt" +import { composeLoopClaudeConfig, writeLoopSettings } from "./compose" +import { ensureLoopPluginsInstalled, lookupPluginInstallPath, BUILTIN_LOOPAT_PLUGIN_PATH } from "./plugin-installer" +import { effectiveDriver, getLoop, loopEphemeralPorts, patchLoopMeta } from "./loops" +import { spawn as nodeSpawn } from "node:child_process" +import { ensureContainer, buildPodmanExecArgs, markActive, markInactive, V_LOOP_WORKDIR, V_LOOP_CLAUDE } from "./podman" +import { updateLoopStatus, setLoopPhase } from "./loop-status" + +// Tests override LOOPAT_CLAUDE_BIN to point at a mock binary (a script that +// reads stream-json from stdin and writes canned messages back) so we can +// exercise the full chat pipeline without burning real API credits. +// Resolved lazily — each spawn re-reads the env var so the full-suite test +// run, where module load order isn't guaranteed, sees the test's override +// even if session.ts was imported earlier with the env var unset. +// The AI runs inside the linux sandbox, so it needs the linux claude binary +// (resolveSandboxClaudeBinary) — on a linux host that's the host claude; on a +// darwin/win host it's the linux binary postinstall fetched into sandbox-claude. +function getClaudeBinary(): string { + return process.env.LOOPAT_CLAUDE_BIN || resolveSandboxClaudeBinary() +} +const DEBUG = !!process.env.LOOPAT_DEBUG || !!process.env.LOOPAT_DEBUG_SPAWN + +function parseSkillDescription(content: string): string | undefined { + const fm = content.match(/^---\s*\n([\s\S]*?)\n---/) + if (!fm) return undefined + const desc = fm[1].match(/^description:\s*(.+)$/m) + return desc ? desc[1].trim() : undefined +} + +async function readSkillDescription(skillsDir: string, skillName: string): Promise<string> { + try { + const content = await readFile(join(skillsDir, skillName, "SKILL.md"), "utf-8") + return parseSkillDescription(content) ?? "" + } catch { + return "" + } +} + +/** + * Pick a provider from personal + workspace configs given a candidate list, + * applying the priority order: + * 1. explicit candidates (caller-supplied: WS override, loop.meta.config) + * 2. personal config's `default` field + * 3. workspace config's `default` field + * 4. enumeration (personal first, then workspace) + * + * `requireKey=true` skips providers with empty apiKey and keeps walking. + * Returns null when no match found. Pure function; tests use it directly. + */ +export function pickProvider( + pCfg: { default: string; providers: Record<string, ProviderConfig> }, + wCfg: { default?: string; providers?: Record<string, ProviderConfig> }, + candidateNames: (string | null | undefined)[], + requireKey: boolean, +): { name: string; provider: ProviderConfig } | null { + const names = [ + ...candidateNames, + pCfg.default ? parseDefault(pCfg.default).providerName : undefined, + wCfg.default ? parseDefault(wCfg.default).providerName : undefined, + ...Object.keys(pCfg.providers), + ...Object.keys(wCfg.providers ?? {}), + ].filter(Boolean) as string[] + const seen = new Set<string>() + for (const name of names) { + if (seen.has(name)) continue + seen.add(name) + const p = pCfg.providers[name] ?? wCfg.providers?.[name] + if (p && (!requireKey || p.apiKey)) return { name, provider: p } + } + return null +} + +/** + * Mirror cli's ff(): explicit override wins; otherwise [1m] tag → 1M; + * any claude opus-4-7/4-6/sonnet-4/sonnet-4-6 → still defaults to 200K + * unless tagged [1m] (1M is opt-in via beta on those). Fallback 200K. + */ +function resolveContextWindow(p: ProviderConfig, modelId?: string): number { + // Per-model override takes precedence + const model = modelId ? p.models.find(m => m.id === modelId) : undefined + if (model?.maxContextTokens && model.maxContextTokens > 0) return model.maxContextTokens + // Provider-level fallback + if (p.maxContextTokens && p.maxContextTokens > 0) return p.maxContextTokens + if (/\[1m\]/i.test(model?.id ?? p.models[0]?.id ?? "")) return 1_000_000 + return 200_000 +} + +/** Subset of SDK PermissionMode that the frontend sends. */ +const VALID_MODES = ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"] as const +type FrontendPermissionMode = (typeof VALID_MODES)[number] + +function isValidMode(m: unknown): m is FrontendPermissionMode { + return typeof m === "string" && (VALID_MODES as readonly string[]).includes(m) +} + +function maskEnv(env: Record<string, string | undefined>): Record<string, string> { + const out: Record<string, string> = {} + for (const [k, v] of Object.entries(env)) { + if (v === undefined) continue + if (/key|token|secret|password/i.test(k)) { + out[k] = v ? `<set len=${v.length}>` : "<empty>" + } else { + out[k] = v + } + } + return out +} + +function pushIterable<T>() { + const queue: T[] = [] + let resolver: ((v: IteratorResult<T>) => void) | null = null + let done = false + + const iter: AsyncIterableIterator<T> = { + [Symbol.asyncIterator]() { + return this + }, + next(): Promise<IteratorResult<T>> { + 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<IteratorResult<T>> { + 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<boolean> { + 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 } + +interface AskQuestionPending { + toolUseID: string + questions: Array<{ + question: string + header: string + options: Array<{ label: string; description: string }> + multiSelect: boolean + }> + resolve: (result: { behavior: 'allow'; updatedInput: Record<string, unknown> }) => void + reject: (err: Error) => void +} + +interface PermissionPending { + toolUseID: string + toolName: string + promptMsg: Record<string, unknown> + resolve: (result: PermissionResult) => void + reject: (err: Error) => void +} + +type PermissionResult = { behavior: 'allow'; updatedInput: Record<string, unknown> } | { behavior: 'deny'; message: string } + +/** Tools that are always safe (read-only) — auto-allowed in every mode. */ +const SAFE_TOOLS = new Set([ + "Read", "Grep", "Glob", "WebSearch", "WebFetch", + "TaskOutput", "CronList", "TodoWrite", + "EnterPlanMode", "ExitPlanMode", +]) + +/** Tools that edit files — auto-allowed in acceptEdits mode. */ +const EDIT_TOOLS = new Set(["Write", "Edit", "NotebookEdit"]) + +const IDLE_TIMEOUT_MS = Number(process.env.LOOPAT_SESSION_IDLE_MS) || 5 * 60 * 1000 + +type QueuedMessage = { text: string; permissionMode?: SdkPermissionMode } + +export type LoopSessionMessageListener = (msg: any) => void + +class LoopSession { + id: string + private q: Query | null = null + private input = pushIterable<SDKUserMessage>() + private subscribers = new Map<WSContext, SubscriberState>() + private history: SDKMessage[] = [] + private historyLoaded: Promise<void> + private pendingQuestions = new Map<string, AskQuestionPending>() + private pendingPermissions = new Map<string, PermissionPending>() + private messageListeners = new Set<LoopSessionMessageListener>() + private providerOverride: string | null = null + private currentPermissionMode: SdkPermissionMode = "bypassPermissions" + private currentGoal: string | null = null + private goalSetAt: string | null = null + private goalStatus: "active" | "completed" | null = null + /** Set of CC task_ids spawned while this goal was active. Used to gauge progress. */ + private goalTaskIds = new Set<string>() + private idleTimer: ReturnType<typeof setTimeout> | null = null + private consuming = false + private generating = false + private messageQueue: QueuedMessage[] = [] + private queueProcessing = false + + constructor(id: string) { + this.id = id + this.historyLoaded = this.loadHistoryFromDisk() + } + + private cancelIdleCleanup() { + if (this.idleTimer) { + clearTimeout(this.idleTimer) + this.idleTimer = null + } + } + + private scheduleIdleCleanup() { + if (this.idleTimer) return + if (this.subscribers.size > 0) return + if (this.consuming) return // never interrupt an active generation + const tag = this.id.slice(0, 8) + this.idleTimer = setTimeout(() => { + this.idleTimer = null + if (this.subscribers.size === 0) { + console.log(`[loop:${tag}] idle timeout — destroying session`) + this.destroy() + } + }, IDLE_TIMEOUT_MS) + } + + private async resolveProvider(meta: { createdBy: string; driver?: string; config?: { vault?: string } }, candidateNames: (string | null | undefined)[], requireKey: boolean): Promise<{ name: string; provider: ProviderConfig } | null> { + const pCfg = await loadPersonalConfig(effectiveDriver(meta), meta.config?.vault) + const wCfg = await loadConfig() + return pickProvider(pCfg, wCfg, candidateNames, requireKey) + } + + /** + * Set the active provider. Takes effect on the next user message — the + * current claude-binary child (if any) is interrupted and torn down so + * `ensureStarted` re-spawns it with the new provider's env (baseUrl / + * apiKey / model). Conversation history is preserved via `--continue`, + * which reads the existing SDK jsonl on disk, so the swap is transparent + * to the user beyond the brief pause. + * + * Always returns true — provider switching is unconditional. The setter + * is fire-and-forget; the interrupt runs in the background, and the next + * sendUserText awaits the freshly-null `q` and re-enters ensureStarted. + * + * The pushIterable is also reset: the old `Query` is still holding the + * old iter, so a fresh push would race the dying-but-not-dead loop. The + * new query takes a brand-new iter; the orphaned iter is GC'd when the + * old Query's internal loop unwinds. + */ + setProvider(name: string | null) { + this.providerOverride = name + this.restartOnNextMessage() + return true + } + + /** Set the active goal. Broadcasts to all subscribers and restarts the + * query so the next system prompt includes the goal. Pass null to clear. */ + setGoal(goal: string | null, setAt?: string, status?: "active" | "completed") { + this.currentGoal = goal + this.goalSetAt = setAt ?? (goal ? new Date().toISOString() : null) + this.goalStatus = goal ? (status ?? "active") : null + if (goal) { + this.goalTaskIds = new Set() + } + this.broadcast({ + type: "goal", + goal, + setAt: this.goalSetAt, + status: this.goalStatus, + }) + if (this.q) { + // Re-compose: the next ensureStarted picks up the goal via buildLoopatAppend. + this.restartOnNextMessage() + } + } + + /** Mark the current goal as completed. Called by the user or by detecting + * an AI self-report. */ + completeGoal() { + if (!this.currentGoal || this.goalStatus !== "active") return + this.goalStatus = "completed" + this.broadcast({ + type: "goal", + goal: this.currentGoal, + setAt: this.goalSetAt, + status: "completed", + }) + } + + getGoal(): string | null { + return this.currentGoal + } + + /** + * Interrupt the current `query()` and clear `this.q`, so the next user + * message triggers a fresh `ensureStarted()` — picking up changes to env + * vars, provider config, **mcpServers**, etc. Conversation history is + * preserved because the SDK reads its session JSONL from disk on respawn + * (`continue: true` when `hasPriorSdkSession` is true). + * + * Idempotent: calling on a session that doesn't currently hold a query is + * a no-op. Fire-and-forget; the interrupt runs in the background. + */ + restartOnNextMessage() { + if (this.q) { + const dying = this.q + this.q = null + this.input = pushIterable<SDKUserMessage>() + dying.interrupt().catch(() => {}) + } + } + + 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 meta = await getLoop(this.id) + if (!meta) { + throw new Error(`loop ${this.id} meta missing`) + } + // Effective driver — credentials, plugins, vault, env, personal mount + // all follow this user, not the immutable createdBy. Updated by the + // /api/loops/:id/drive handoff endpoint; next spawn picks it up here. + const driver = effectiveDriver(meta) + const resolved = await this.resolveProvider(meta, [ + this.providerOverride, + meta.config?.default_model, + ], true) + if (!resolved) { + throw new Error(`no provider with a valid apiKey for vault "${meta.config?.vault ?? "default"}" — set one in personal/${driver}/.loopat/vaults/${meta.config?.vault ?? "default"}/envs/`) + } + const providerName = resolved.name + const provider = resolved.provider + + const loopatAppend = await buildLoopatAppend(meta) + const loopId = this.id + + // Compose runs ONCE at loop creation (loops.ts:createLoop). At spawn we + // only re-compose if the snapshot is missing — this happens for loops + // created before the snapshot model landed, and self-heals on first spawn. + // + // The "compose once and freeze" semantics is what makes principle 1 + // (old loops never change) work: subsequent admin pushes to knowledge + // don't affect a loop that's already been materialized. + const composedSettingsPath = join(loopClaudeDir(loopId), "settings.json") + if (!existsSync(composedSettingsPath)) { + await composeLoopClaudeConfig(loopId, driver, meta.config?.profiles) + } + // Ensure host CC has every marketplace registered + every enabled plugin + // installed. We don't need the resolved paths (sandbox sees host + // ~/.claude/plugins/ via a wholesale ro-bind in bwrap, and the inner SDK + // resolves enabledPlugins natively from settings.json). This is purely + // side-effectful: drive `claude plugin marketplace add/remove` + + // `claude plugin install` as needed. See plugin-installer.ts. + await ensureLoopPluginsInstalled(loopId) + + // Nuke CC's MCP-related cache files that linger across spawns: + // `.credentials.json` — CC's ephemeral OAuth state. + // `mcp-needs-auth-cache.json` — CC's "this server needs auth" short-circuit. + // Tokens flow through vault envs/MCP_*_TOKEN now, substituted into the + // workspace `mcpServers[*].headers.Authorization` template by the spawned + // binary at startup. Stale CC cache files would shortcircuit that, so we + // clear them every spawn. + for (const f of [".credentials.json", "mcp-needs-auth-cache.json"]) { + try { + await rm(join(loopClaudeDir(loopId), f), { force: true }) + } catch {} + } + + // mcpServers come straight from the merged settings.json (workspace + + // profiles + personal — compose wrote it to loops/<id>/.claude/settings.json + // above). Any `${VAR}` references in headers / env are substituted by the + // spawned claude binary against its own process env — which inherits the + // vault envs we inject into extraEnv below. + const mergedSettingsPath = join(loopClaudeDir(loopId), "settings.json") + let mcpServers: Record<string, any> = {} + if (existsSync(mergedSettingsPath)) { + try { + const merged = JSON.parse(await readFile(mergedSettingsPath, "utf8")) + // Strip loopat-only inline metadata (e.g. `x-loopat-resource`, used by + // the MCP setup UI) so the SDK / CC only sees standard MCP fields. + mcpServers = Object.fromEntries( + Object.entries((merged.mcpServers ?? {}) as Record<string, any>).map(([name, srv]) => { + if (srv && typeof srv === "object") { + const clean: Record<string, any> = {} + for (const [k, v] of Object.entries(srv)) if (!k.startsWith("x-loopat")) clean[k] = v + return [name, clean] + } + return [name, srv] + }), + ) + } catch (e: any) { + console.warn(`[session ${loopId.slice(0,8)}] could not read merged mcpServers: ${e?.message ?? e}`) + } + } + + // Build sandbox env. Order matters: vault envs first (so the spawned binary + // can substitute ${VAR} in mcpServers headers passed via SDK options), + // then platform-controlled vars (which can't be overridden by a stray + // vault env file). + const personalCfg = await loadPersonalConfig(driver, meta.config?.vault) + const extraEnv: Record<string, string> = { + ...personalCfg.vaultEnvs, + ANTHROPIC_API_KEY: provider.apiKey, + ANTHROPIC_BASE_URL: provider.baseUrl, + CLAUDE_CONFIG_DIR: V_LOOP_CLAUDE(loopId), + } + // Override cli's hardcoded model→context-window map for gateway-routed + // models. Both env vars are required (cli checks DISABLE_COMPACT first + // to enable the override path, then reads CLAUDE_CODE_MAX_CONTEXT_TOKENS). + // Per-model override takes precedence over provider-level. + // + // Resolve the active model: loop meta override first, then personal + // config default model, then first enabled, then models[0]. + let modelId: string | undefined = meta.config?.default_model_id + if (!modelId) { + const pCfg = await loadPersonalConfig(driver, meta.config?.vault) + const defaultParsed = parseDefault(pCfg.default) + if (defaultParsed.modelId && defaultParsed.providerName === providerName) { + modelId = defaultParsed.modelId + } + } + const activeModel = (modelId ? provider.models.find(m => m.id === modelId) : undefined) + ?? provider.models.find(m => m.enabled !== false) + ?? provider.models[0] + const contextTokenOverride = activeModel?.maxContextTokens ?? provider.maxContextTokens + if (contextTokenOverride && contextTokenOverride > 0) { + extraEnv.DISABLE_COMPACT = "1" + extraEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(contextTokenOverride) + } + // Mise toolchain: baked into the per-loop image at ensureLoopImage + // build time. The image's ENV puts /opt/loopat-mise/shims on PATH, so + // every process inside the container (SDK + PTY) finds the right + // toolchain — no host-side activation needed here. + // + // Ensure the per-loop podman container exists and is running. Idempotent: + // if the container is already up with the same config-hash, no-op. Both + // this SDK driver AND the PTY (term.ts) call ensureContainer with the + // same options, so they end up sharing one container (same PID / Mount / + // IPC namespace) — that's the whole point of the podman refactor. + // Mirrors term.ts: only raise the `preparing` gate once a build/pull + // actually emits progress; clear it to `ready` once the container is up. + let building = false + await ensureContainer({ + loopId, + createdBy: driver, + vaultName: meta.config?.vault, + knowledgeRw: meta.config?.knowledge_rw, + mountAllLoops: meta.config?.mount_all_loops, + repo: meta.repo, + extraEnv, + ephemeralPorts: loopEphemeralPorts(meta), + }, { + onProgress: (msg) => { + if (!building) { building = true; setLoopPhase(loopId, "preparing") } + updateLoopStatus(loopId, msg) + }, + }) + if (building) setLoopPhase(loopId, "ready") + updateLoopStatus(loopId, "Ready") + // Tell the container lifecycle scheduler that this loop has an active + // SDK source. Released in destroy() via markInactive(loopId, "sdk"). + markActive(loopId, "sdk") + const claudeBinary = getClaudeBinary() + if (DEBUG) { + const tag = loopId.slice(0, 8) + console.error(`[sdk:${tag}] config: provider=${providerName} model=${activeModel?.id ?? "?"} baseUrl=${provider.baseUrl} apiKey=${provider.apiKey ? `<set len=${provider.apiKey.length}>` : "<empty>"}`) + console.error(`[sdk:${tag}] config: continue=${shouldContinue} cwd=${V_LOOP_WORKDIR(loopId)} CLAUDE_CONFIG_DIR=${V_LOOP_CLAUDE(loopId)}`) + console.error(`[sdk:${tag}] config: binary=${claudeBinary}`) + } + + this.q = query({ + prompt: this.input.iter, + options: { + cwd: V_LOOP_WORKDIR(loopId), + env: { + ...process.env, + CLAUDE_CONFIG_DIR: V_LOOP_CLAUDE(loopId), + ANTHROPIC_API_KEY: provider.apiKey, + ANTHROPIC_BASE_URL: provider.baseUrl, + }, + model: activeModel?.id ?? "", + permissionMode: this.currentPermissionMode, + // Required by SDK when using permissionMode: "bypassPermissions" + ...(this.currentPermissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}), + systemPrompt: { type: "preset", preset: "claude_code", append: loopatAppend }, + mcpServers, + // External marketplace plugins (enabledPlugins in settings.json) are + // resolved natively by the inner SDK now — ~/.claude/plugins/ is + // ro-bound wholesale, so installed_plugins.json + each installPath is + // reachable inside the sandbox. The only thing we still pass via + // `plugins:` is the loopat-shipped builtin, which lives under + // LOOPAT_INSTALL_DIR (not in CC's plugin cache). + plugins: [{ type: "local" as const, path: BUILTIN_LOOPAT_PLUGIN_PATH }], + stderr: (s) => console.error(`[sdk:${loopId.slice(0, 8)}] ${s.trimEnd()}`), + pathToClaudeCodeExecutable: claudeBinary, + canUseTool: async (toolName, input, { toolUseID, signal, title, displayName }) => { + // ── AskUserQuestion: always broadcast to frontend ── + if (toolName === "AskUserQuestion") { + const questions = (input as any)?.questions + if (!Array.isArray(questions) || questions.length === 0) { + return { behavior: "allow" as const, updatedInput: {} } + } + const questionMsg = { + type: "question", + tool_use_id: toolUseID, + questions, + } + this.broadcast(questionMsg) + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingQuestions.delete(toolUseID) + reject(new Error("question timed out")) + }, 300_000) + this.pendingQuestions.set(toolUseID, { + toolUseID, + questions, + resolve: (result) => { + clearTimeout(timeout) + resolve(result) + }, + reject: (err) => { + clearTimeout(timeout) + reject(err) + }, + }) + signal.addEventListener("abort", () => { + clearTimeout(timeout) + this.pendingQuestions.delete(toolUseID) + reject(new Error("question cancelled")) + }, { once: true }) + }) + } + + // ── Safe (read-only) tools: always allow ── + if (SAFE_TOOLS.has(toolName)) { + return { behavior: "allow" as const, updatedInput: {} } + } + + const mode = this.currentPermissionMode + + // ── Full-auto modes: allow everything ── + if (mode === "bypassPermissions" || mode === "auto" || mode === "dontAsk") { + return { behavior: "allow" as const, updatedInput: {} } + } + + // ── acceptEdits: auto-allow file-editing tools; prompt for the rest ── + if (mode === "acceptEdits" && EDIT_TOOLS.has(toolName)) { + return { behavior: "allow" as const, updatedInput: {} } + } + + // ── default / plan / acceptEdits(non-edit): prompt the user ── + const promptMsg = { + type: "permission_prompt", + tool_use_id: toolUseID, + tool_name: toolName, + title: title || `Claude wants to use ${toolName}`, + displayName: displayName || toolName, + } + this.broadcast(promptMsg) + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingPermissions.delete(toolUseID) + resolve({ behavior: "deny" as const, message: "Permission timed out" }) + }, 120_000) // 2 min timeout + this.pendingPermissions.set(toolUseID, { + toolUseID, + toolName, + promptMsg, + resolve: (result) => { + clearTimeout(timeout) + resolve(result) + }, + reject: (err) => { + clearTimeout(timeout) + reject(err) + }, + }) + signal.addEventListener("abort", () => { + clearTimeout(timeout) + this.pendingPermissions.delete(toolUseID) + reject(new Error("permission cancelled")) + }, { once: true }) + }) + }, + // user-tier: read autoMemoryDirectory (/loopat/context/personal/memory) + // from CLAUDE_CONFIG_DIR/settings.json (SDK auto-memory uses that path). + // project-tier: auto-load <workdir>/CLAUDE.md so per-repo conventions + // (e.g. the project's own CLAUDE.md) layer on top of platform doctrine. + // local-tier: pick up <workdir>/.claude/settings.local.json + .local.md + // agents/skills so users can drop per-checkout overrides without + // committing them. + settingSources: ["user", "project", "local"], + // Stop hook: when an active goal exists, prevent the session from + // ending if there is unfinished background work. Mirrors CC's /goal + // behavior — the hook fires on session-end and blocks the stop while + // goal-related tasks are still running. + hooks: { + Stop: [{ + hooks: [async (input) => { + const si = input as StopHookInput + const hasGoal = !!this.currentGoal && this.goalStatus === "active" + if (!hasGoal) return { continue: true } + const busy = (si.background_tasks?.length ?? 0) > 0 + if (busy) { + return { + continue: false, + stopReason: `goal "${this.currentGoal}" still in progress — background work is running`, + } + } + // No background work and model is stopping naturally (first + // invocation). Auto-complete the goal — matches CC's /goal + // behavior where finishing the task marks the goal done. + if (!si.stop_hook_active) { + this.completeGoal() + } + return { continue: true } + }], + }], + }, + // Inner SDK sandbox disabled — outer podman container wraps everything; + // bash subprocesses from the Bash tool inherit the same namespace via + // exec-in-container. No nested sandbox needed. + sandbox: { enabled: false }, + // Spawn CLI inside the per-loop podman container via `podman exec`. + spawnClaudeCodeProcess: ({ command, args, signal }) => { + // SDK has already injected the resolved plugins via its `plugins` + // option → `--plugin-dir <path>` flags in `args`. We just wrap + + // spawn here. + // + // `interactive: true` is CRITICAL — without `-i` on `podman exec`, + // stdin from the host (which the SDK uses to push user messages + // as stream-json) is NOT forwarded to the claude binary inside + // the container, so claude reads EOF and exits immediately with + // code 0 producing no output ("chat sends but never responds"). + // NO `tty: true` though — SDK speaks line-delimited stream-json + // over pipes, not via PTY. + const spawnBinary = process.env.LOOPAT_PODMAN_BIN || "podman" + const fullArgs = buildPodmanExecArgs({ + loopId, + command, + args, + env: extraEnv, + workdir: V_LOOP_WORKDIR(loopId), + interactive: true, + }) + const tag = loopId.slice(0, 8) + // Always tee stderr to a per-loop file so it survives terminal + // truncation (bun --filter, tools that elide). Path also printed + // on non-zero exit. + mkdirSync(loopDir(loopId), { recursive: true }) + const stderrLogPath = join(loopDir(loopId), "stderr.log") + const stderrFile = createWriteStream(stderrLogPath, { flags: "a" }) + stderrFile.write(`\n=== ${new Date().toISOString()} spawn ===\n`) + stderrFile.write(`binary: ${command}\n`) + stderrFile.write(`${spawnBinary} argc: ${fullArgs.length}\n`) + if (DEBUG) { + const argvLine = `${spawnBinary} ${fullArgs.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}` + console.error(`[sdk:${tag}] binary: ${command}`) + console.error(`[sdk:${tag}] spawn cmd: ${argvLine}`) + stderrFile.write(`argv: ${argvLine}\n`) + } + + const proc = nodeSpawn(spawnBinary, fullArgs, { + stdio: ["pipe", "pipe", "pipe"], + signal, + }) + + if (DEBUG) { + console.error(`[sdk:${tag}] spawned pid=${proc.pid}`) + } + proc.on("error", (e) => { + console.error(`[sdk:${tag}] spawn error:`, e?.message ?? e) + stderrFile.write(`spawn error: ${e?.message ?? e}\n`) + }) + + // pipe stderr to file (always) and to console (always, lossy if + // terminal eats it, lossless via the file). + proc.stderr?.on("data", (chunk: Buffer) => { + stderrFile.write(chunk) + const text = chunk.toString("utf8") + for (const line of text.split("\n")) { + if (line.trim()) console.error(`[sdk:${tag}:stderr] ${line}`) + } + }) + + if (DEBUG) { + // mirror stdout too — useful for seeing the SDK protocol if the + // SDK itself isn't surfacing what came back. Capped to avoid + // flooding when chat is healthy. + proc.stdout?.on("data", (chunk: Buffer) => { + const s = chunk.toString("utf8") + const head = s.length > 400 ? s.slice(0, 400) + `…+${s.length - 400}b` : s + for (const line of head.split("\n")) { + if (line.trim()) console.error(`[sdk:${tag}:stdout] ${line}`) + } + }) + } + + proc.on("exit", (code, sig) => { + stderrFile.end(`=== exit code=${code} sig=${sig ?? ""} ===\n`) + if (code !== 0 && code !== null) { + console.error(`[sdk:${tag}] child exited code=${code}${sig ? ` sig=${sig}` : ""}; full stderr at ${stderrLogPath}`) + } else if (DEBUG) { + console.error(`[sdk:${tag}] child exited code=${code}${sig ? ` sig=${sig}` : ""}`) + } + }) + return proc as any + }, + // Stream text deltas + tool progress to the UI for live visibility. + includePartialMessages: true, + ...(shouldContinue ? { continue: true } : {}), + }, + }) + this.consume(this.q) + } + + private async consume(q: Query) { + this.consuming = true + const tag = this.id.slice(0, 8) + // Set after we receive a `result` message — at that point the turn is + // semantically complete. If the SDK subsequently throws (e.g. the SIGKILL + // dance against an idle claude binary that podman exec can't forward + // SIGTERM into), the error is cleanup noise, not a real failure — we + // suppress it from the user-visible history / broadcast. + let resultReceived = false + try { + for await (const msg of q) { + if (DEBUG) { + const subtype = (msg as any).subtype ? `/${(msg as any).subtype}` : "" + const event = (msg as any).event?.type ? ` event=${(msg as any).event.type}` : "" + console.error(`[sdk:${tag}] msg ${msg.type}${subtype}${event}`) + } + // Track generating state: init → true, result → false + if (msg.type === "system" && (msg as any).subtype === "init") { + this.generating = true + } else if (msg.type === "result") { + this.generating = false + this.queueProcessing = false + this.q = null + this.processNextInQueue() + resultReceived = true + } else if ( + // Inject queued messages at tool-result boundaries — matching + // real Claude Code's per-step queue consumption. + this.messageQueue.length > 0 && + msg.type === "user" && + Array.isArray((msg as any).message?.content) && + (msg as any).message.content.some((b: any) => b?.type === "tool_result") + ) { + this.generating = false + this.queueProcessing = false + await q.interrupt().catch(() => {}) + this.q = null + this.processNextInQueue() + return + } + + // ephemeral live-feed events: don't persist or replay; just broadcast + // so already-attached clients see the streaming. + const ephemeral = msg.type === "stream_event" || msg.type === "tool_progress" + if (!ephemeral) { + this.history.push(msg) + this.persist(msg) + } + this.broadcast(msg) + this.updateStatus(msg) + } + } catch (e: any) { + const msg = e?.message ?? String(e) + // Post-result cleanup noise: the SDK kills the idle claude child after + // ~7s (SIGTERM → 5s grace → SIGKILL). With our podman-exec wrapper, the + // host-side SIGTERM doesn't propagate to claude inside the container, + // so the SIGKILL fires and podman reports exit 137. The turn already + // succeeded — don't pollute history or alarm the frontend. + if (resultReceived && /exited with code|aborted by user|terminated by signal/.test(msg)) { + console.warn(`[sdk:${tag}] post-result cleanup noise (suppressed): ${msg}`) + } else { + console.error(`[sdk:${tag}] consume error:`, msg) + if (DEBUG && e?.stack) console.error(e.stack) + const err = { type: "error", message: msg } + this.history.push(err as any) + this.persist(err) + this.broadcast(err) + } + } finally { + // If a new Query was started by processNextInQueue() above, skip cleanup — + // the new consume owns the lifecycle from here on. + if (this.q !== q) return + this.consuming = false + this.generating = false + this.queueProcessing = false + this.q = null + this.input = pushIterable<SDKUserMessage>() + // Emit a result marker so the frontend knows the run is done, + // even if the generator ended without one (e.g. after interrupt). + const result = { type: "result" as const } + this.history.push(result as any) + this.broadcast(result) + if (this.subscribers.size === 0) this.scheduleIdleCleanup() + } + } + + private persist(msg: any) { + const stamped = { ...msg, _ts: new Date().toISOString() } + appendFile(loopHistoryPath(this.id), JSON.stringify(stamped) + "\n").catch((e) => { + console.error("[loopat] persist failed", e) + }) + } + + private broadcast(msg: any) { + for (const listener of this.messageListeners) { + try { listener(msg) } catch {} + } + 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 {} + } + } + + private updateStatus(msg: any) { + // 1. 用户输入状态 + if (msg.type === "user") { + const text = typeof msg.content === "string" ? msg.content : msg.content?.[0]?.text || "" + if (text) { + updateLoopStatus(this.id, `User: ${text.slice(0, 50)}${text.length > 50 ? "..." : ""}`) + } + return + } + + // 2. AI 响应状态 (assistant 消息) + if (msg.type === "assistant") { + const content = Array.isArray(msg.content) ? msg.content : [] + // 优先捕获 tool_use 或 thinking + for (const block of content) { + if (block.type === "tool_use") { + updateLoopStatus(this.id, `Using ${block.name || "tool"}...`) + return + } + if (block.type === "thinking" || block.type === "reasoning") { + updateLoopStatus(this.id, "Thinking...") + return + } + } + // 其次捕获文本输出 + const textBlock = content.find((b: any) => b.type === "text") + if (textBlock?.text) { + const text = textBlock.text + const preview = text.trim().slice(-60).replace(/\n/g, " ") + updateLoopStatus(this.id, preview || "Generating...") + } + return + } + + // 3. Stream events (Real-time updates) + if (msg.type === "stream_event") { + const evt = msg.event || msg.data + if (evt?.type === "content_block_start") { + const block = evt.content_block || evt.data + if (block?.type === "tool_use") { + updateLoopStatus(this.id, `Using ${block.name || "tool"}...`) + return + } + if (block?.type === "thinking") { + updateLoopStatus(this.id, "Thinking...") + return + } + } + if (evt?.type === "content_block_delta") { + const delta = evt.delta || evt.data + if (delta?.type === "text" && delta.text) { + updateLoopStatus(this.id, delta.text.slice(-60).replace(/\n/g, " ")) + return + } + } + return + } + + // 4. 兼容独立事件类型 + if (msg.type === "tool_use" || msg.type === "tool_call") { + updateLoopStatus(this.id, `Using ${msg.name || msg.tool_name || "tool"}...`) + return + } + if (msg.type === "thinking" || msg.type === "reasoning") { + updateLoopStatus(this.id, "Thinking...") + return + } + if (msg.type === "content_block_start" || msg.type === "content_block_delta") { + const delta = msg.delta || msg.content_block + if (delta?.type === "tool_use") { + updateLoopStatus(this.id, `Using ${delta.name || "tool"}...`) + } else if (delta?.type === "thinking" || delta?.type === "reasoning") { + updateLoopStatus(this.id, "Thinking...") + } else if (delta?.type === "text" && delta.text) { + updateLoopStatus(this.id, delta.text.slice(-60).replace(/\n/g, " ")) + } + return + } + + // 5. 结束状态 + if (msg.type === "result" || msg.stop_reason || msg.type === "message_stop") { + updateLoopStatus(this.id, "Done") + } + } + + /** + * Read subdirectory names from a path — silently returns [] if missing. + * Includes symlinks (composeTier creates symlinks-to-dirs under + * .claude/plugins/cache/, which isDirectory() reports as false). + */ + private async listDirNames(dir: string): Promise<string[]> { + try { + const entries = await readdir(dir, { withFileTypes: true }) + return entries + .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith(".")) + .map((e) => e.name) + } catch { + return [] + } + } + + /** + * Build a best-effort list of slash commands from the loop's workspace / + * personal config. Used to seed the frontend before CC's real init arrives. + * Includes well-known CC builtins, loose skills from knowledge/personal, + * AND plugin sub-commands (<plugin>:<skill>) read from the same paths the + * SDK is about to load — so the menu is complete on first open, not after + * the first message has triggered a spawn. + */ + private async buildInitialSlashCommands(user: string): Promise<{ name: string; description: string }[]> { + const map = new Map<string, string>() + // CC built-in commands (descriptions handled by frontend's local COMMANDS) + for (const c of ["help", "model", "clear", "compress", "review", "init", "foxtrot"]) { + if (!map.has(c)) map.set(c, "") + } + // Workspace skills + for (const name of await this.listDirNames(workspaceTeamSkillsDir())) { + if (!map.has(name)) { + map.set(name, await readSkillDescription(workspaceTeamSkillsDir(), name)) + } + } + // Personal skills (higher precedence) + for (const name of await this.listDirNames(personalSkillsDir(user))) { + map.set(name, await readSkillDescription(personalSkillsDir(user), name)) + } + // Plugin sub-commands: scan each enabled plugin's skills/ dir on the host + // and surface as `<plugin>:<skill>`. Best-effort pre-spawn seed so the + // chip shows useful numbers before CC's init payload arrives; CC's init + // is the authoritative list. + try { + const settingsPath = join(loopClaudeDir(this.id), "settings.json") + if (existsSync(settingsPath)) { + const settings = JSON.parse(await readFile(settingsPath, "utf8")) as { + enabledPlugins?: Record<string, boolean> + } + const enabled = Object.entries(settings.enabledPlugins ?? {}) + .filter(([_, v]) => v) + .map(([k]) => k) + for (const spec of enabled) { + const pluginPath = await lookupPluginInstallPath(spec) + if (!pluginPath) continue + const pluginName = spec.split("@")[0] + const skillsDir = join(pluginPath, "skills") + for (const skill of await this.listDirNames(skillsDir)) { + map.set(`${pluginName}:${skill}`, await readSkillDescription(skillsDir, skill)) + } + } + } + } catch (e: any) { + console.warn(`[session ${this.id.slice(0,8)}] seed plugin scan failed: ${e?.message ?? e}`) + } + return [...map.entries()] + .map(([name, description]) => ({ name, description })) + .sort((a, b) => a.name.localeCompare(b.name)) + } + + async attach(ws: WSContext) { + await this.historyLoaded + const state: SubscriberState = { pending: [] } + this.subscribers.set(ws, state) + // Send active provider info up-front so UI can render badge + true context window. + try { + const meta = await getLoop(this.id) + if (meta) { + const resolved = await this.resolveProvider(meta, [ + this.providerOverride, + meta.config?.default_model, + ], false) + if (resolved) { + let attachModelId: string | undefined = meta.config?.default_model_id + if (!attachModelId) { + try { + const driver = effectiveDriver(meta) + const pCfg = await loadPersonalConfig(driver, meta.config?.vault) + const defaultParsed = parseDefault(pCfg.default) + if (defaultParsed.modelId && defaultParsed.providerName === resolved.name) { + attachModelId = defaultParsed.modelId + } + } catch {} + } + const activeModel = (attachModelId ? resolved.provider.models.find(m => m.id === attachModelId) : undefined) + ?? resolved.provider.models.find(m => m.enabled !== false) + ?? resolved.provider.models[0] + const activeModelId = activeModel?.id ?? "" + ws.send(JSON.stringify({ + type: "provider", + name: resolved.name, + model: activeModelId, + models: resolved.provider.models, + contextWindow: resolveContextWindow(resolved.provider, activeModelId), + })) + } else { + console.warn(`[loop:${this.id.slice(0, 8)}] no provider found in personal or workspace config`) + } + // Restore persisted permission mode + const pm = meta.config?.permission_mode + if (isValidMode(pm) && pm !== this.currentPermissionMode) { + this.currentPermissionMode = pm + if (this.q) { + try { await this.q.setPermissionMode(pm) } catch {} + } + } + // Tell frontend the current mode so it can sync its selector + ws.send(JSON.stringify({ + type: "permission_mode", + mode: this.currentPermissionMode, + })) + // Send current goal so reconnecting clients see it + if (this.currentGoal) { + try { + ws.send(JSON.stringify({ type: "goal", goal: this.currentGoal, setAt: this.goalSetAt })) + } catch {} + } + } + } catch (e: any) { + console.error(`[loop:${this.id.slice(0, 8)}] attach provider error:`, e?.message ?? e) + } + 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 + } + // history_end — signals the frontend that replay is done. + // When not generating, also embed a best-effort slash-command list + // so the / menu works immediately (before CC starts). CC's real + // system/init replaces this with the accurate list later. + const meta = await getLoop(this.id) + if (this.generating) { + try { + ws.send(JSON.stringify({ type: "history_end" })) + } catch {} + // The frontend also needs a synthetic init to show running status + // (the history-replayed init was ignored during loadingHistory). + try { + ws.send(JSON.stringify({ type: "system", subtype: "init" })) + } catch {} + } else { + const user = meta?.createdBy + const slashCommands = user ? await this.buildInitialSlashCommands(user) : undefined + try { + ws.send(JSON.stringify({ type: "history_end", slash_commands: slashCommands })) + } catch {} + } + // Re-broadcast active permission prompts that survived history replay + for (const [_, pending] of this.pendingPermissions) { + try { + ws.send(JSON.stringify(pending.promptMsg)) + } catch {} + } + // Re-broadcast active AskUserQuestion prompts + for (const [_id, pending] of this.pendingQuestions) { + try { + ws.send(JSON.stringify({ + type: "question", + tool_use_id: pending.toolUseID, + questions: pending.questions, + })) + } catch {} + } + // Send current queue status to reconnected clients + if (this.messageQueue.length > 0) { + try { ws.send(JSON.stringify({ type: "queue_update", queue: this.messageQueue.map(m => m.text) })) } catch {} + } + this.cancelIdleCleanup() + this.broadcastViewers() + console.log(`[loop:${this.id.slice(0, 8)}] attach → viewers=${this.subscribers.size}`) + } + + detach(ws: WSContext) { + this.subscribers.delete(ws) + this.broadcastViewers() + if (this.subscribers.size === 0) this.scheduleIdleCleanup() + console.log(`[loop:${this.id.slice(0, 8)}] detach → viewers=${this.subscribers.size}`) + } + + async sendUserText(text: string, permissionMode?: SdkPermissionMode) { + updateLoopStatus(this.id, `User: ${text.slice(0, 50)}${text.length > 50 ? "..." : ""}`) + if (this.generating || this.messageQueue.length > 0 || this.queueProcessing) { + this.messageQueue.push({ text, permissionMode }) + this.broadcast({ type: "queue_update", queue: this.messageQueue.map(m => m.text) }) + return + } + // _pushUserMessage can throw BEFORE the query starts (e.g. ensureStarted's + // "no provider with a valid apiKey" when the user hasn't set an AI key) — + // in which case `consume` never runs and never broadcasts. Catch it here so + // the frontend gets a visible error instead of hanging on "Reasoning…". + try { + await this._pushUserMessage(text, permissionMode) + } catch (e: any) { + this.generating = false + const message = e?.message ?? String(e) + console.error(`[loopat] sendUserText failed for ${this.id}: ${message}`) + this.broadcast({ type: "error", message }) + } + } + + isBusy(): boolean { + return this.generating || this.messageQueue.length > 0 || this.queueProcessing + } + + onMessage(listener: LoopSessionMessageListener): () => void { + this.messageListeners.add(listener) + return () => { + this.messageListeners.delete(listener) + } + } + + /** Push a synthetic message through the broadcast pipeline. Used by the + * v1 API to dispatch control events (choice_resolved / interrupted) to + * all active SSE listeners without polluting persisted history. */ + notifyListeners(msg: any): void { + for (const listener of this.messageListeners) { + try { listener(msg) } catch {} + } + } + + hasPendingPermission(toolUseId: string): boolean { + return this.pendingPermissions.has(toolUseId) + } + + hasPendingQuestion(toolUseId: string): boolean { + return this.pendingQuestions.has(toolUseId) + } + + private async _pushUserMessage(text: string, permissionMode?: SdkPermissionMode) { + if (permissionMode && permissionMode !== this.currentPermissionMode) { + this.currentPermissionMode = permissionMode + patchLoopMeta(this.id, { config: { permission_mode: permissionMode } }).catch(() => {}) + if (this.q) { + try { await this.q.setPermissionMode(permissionMode) } catch {} + } + } + // Driver-handoff preamble: if POST /api/loops/:id/drive set a one-shot + // pendingDriverNote, prepend a system-style line to this user message so + // the model knows the human it's talking to has just changed. Cleared + // atomically before ensureStarted so a transient crash doesn't leak it + // into a second message. + const meta = await getLoop(this.id) + if (meta?.pendingDriverNote) { + const { from, to, at } = meta.pendingDriverNote + text = `[loopat] Driver handoff: this loop was previously driven by ${from}; from now on the active driver is ${to} (handoff at ${at}). The user you're now talking to may differ from the one who started the conversation.\n\n${text}` + await patchLoopMeta(this.id, { pendingDriverNote: undefined }).catch(() => {}) + } + await this.ensureStarted() + const userMsg: SDKUserMessage = { + type: "user", + message: { role: "user", content: text }, + parent_tool_use_id: null, + uuid: randomUUID(), + } + this.history.push(userMsg) + this.persist(userMsg) + this.broadcast(userMsg) + this.input.push(userMsg) + } + + /** Process the next queued message. Called from consume()'s finally block + * after each generation completes. Only starts the next message; subsequent + * messages are handled recursively by consume()'s finally. */ + private processNextInQueue() { + if (this.queueProcessing) return // already processing + if (this.messageQueue.length === 0) { + this.broadcast({ type: "queue_update", queue: [] }) + return + } + this.queueProcessing = true + const next = this.messageQueue.shift()! + this.broadcast({ type: "queue_update", queue: this.messageQueue.map(m => m.text) }) + this._pushUserMessage(next.text, next.permissionMode).catch((e) => { + console.error("[loopat] queued message failed:", e) + this.queueProcessing = false + // Try next message on failure + if (this.messageQueue.length > 0) this.processNextInQueue() + else this.broadcast({ type: "queue_update", queue: [] }) + }) + } + + async answerQuestions(toolUseID: string, answers: Record<string, string>) { + const pending = this.pendingQuestions.get(toolUseID) + if (!pending) return + this.pendingQuestions.delete(toolUseID) + // Include original questions alongside answers so the CLI tool receives both + pending.resolve({ behavior: "allow", updatedInput: { questions: pending.questions, answers } }) + } + + async answerPermission(toolUseID: string, allow: boolean) { + const pending = this.pendingPermissions.get(toolUseID) + if (!pending) return + this.pendingPermissions.delete(toolUseID) + if (allow) { + pending.resolve({ behavior: "allow", updatedInput: {} }) + } else { + pending.resolve({ behavior: "deny", message: "User denied permission" }) + } + } + + async setMaxThinkingTokens(tokens: number | null) { + if (this.q) { + try { await this.q.setMaxThinkingTokens(tokens) } catch {} + } + } + + async getContextUsage() { + if (!this.q) return null + try { + return await this.q.getContextUsage() + } catch { + return null + } + } + + async interrupt() { + this.generating = false + if (this.q) await this.q.interrupt().catch(() => {}) + } + + getQueueLength(): number { + return this.messageQueue.length + } + + removeQueueItem(index: number) { + if (index >= 0 && index < this.messageQueue.length) { + this.messageQueue.splice(index, 1) + this.broadcast({ type: "queue_update", queue: this.messageQueue.map(m => m.text) }) + } + } + + clearQueue() { + this.messageQueue = [] + this.queueProcessing = false + this.broadcast({ type: "queue_update", queue: [] }) + } + + /** Tear down the SDK process and disconnect all subscribers. Used when a + * loop is archived so no orphaned processes remain. */ + async destroy() { + this.cancelIdleCleanup() + this.generating = false + this.queueProcessing = false + this.messageQueue = [] + sessions.delete(this.id) + // Release the SDK side of the container activity registry; the container + // will be `podman stop`'d after CONTAINER_IDLE_MS unless something else + // (e.g. PTY subscribers) keeps it active. + markInactive(this.id, "sdk") + if (this.q) { + try { await this.q.interrupt() } catch {} + this.q = null + } + for (const [, pending] of this.pendingQuestions) { + pending.reject(new Error("loop archived")) + } + this.pendingQuestions.clear() + const closeMsg = JSON.stringify({ type: "error", message: "loop archived" }) + for (const [ws] of this.subscribers) { + try { ws.send(closeMsg) } catch {} + try { ws.close() } catch {} + } + this.subscribers.clear() + } + + /** + * Equivalent to CC TUI's `/clear`: ends the in-flight SDK conversation + * and makes the next message start with zero AI context — while keeping + * old session jsonls intact (still resumable via `claude --resume`). + * + * Mechanism: touch a fresh empty `<new-uuid>.jsonl` in the same + * `projects/<encoded-cwd>/` dir(s) the SDK uses. `claude --continue` + * picks "the most recent" jsonl by mtime, so on the next query it finds + * this empty file and resumes with 0 prior turns. Older jsonls stay in + * place — `claude --resume` still lists them, matching CC behavior. No + * persistent session-id state is needed. + * + * messages.jsonl (our chat record) is NOT modified beyond appending a + * `clear-boundary` marker. Marker broadcasts to clients (UI divider), + * persists to disk (segments the log into per-session ranges), and is + * visible to future readers (humans + AI) so they can tell which + * messages belong to which SDK session window. + */ + /** + * Strip all `thinking` / `redacted_thinking` content blocks from every + * SDK jsonl in this loop. Used before swapping to a provider that won't + * recognize the existing thinking signatures (different baseUrl / account + * / gateway). The plain user/assistant text stays — the AI's context is + * preserved minus the cryptographically-signed reasoning chains, which + * are useless to the new provider anyway. + * + * Originals backed up to `.claude/projects-archive/<ts>/<sub>/<file>`. + * Returns the number of blocks stripped across all sessions. + * + * Side effects: interrupts current query and resets the pushIterable so + * the next sendUserText spawns fresh against the rewritten jsonl. + */ + async stripThinkingBlocks(): Promise<{ stripped: number; sessionsTouched: number }> { + if (this.q) { + try { await this.q.interrupt() } catch {} + this.q = null + this.input = pushIterable<SDKUserMessage>() + } + const projectsDir = join(loopClaudeDir(this.id), "projects") + let stripped = 0 + let sessionsTouched = 0 + const ts = new Date().toISOString().replace(/[:.]/g, "-") + const archiveDir = join(loopClaudeDir(this.id), "projects-archive", ts) + try { + const subdirs = await readdir(projectsDir) + for (const sub of subdirs) { + const subPath = join(projectsDir, sub) + const files = await readdir(subPath).catch(() => []) + for (const f of files) { + if (!f.endsWith(".jsonl")) continue + const filePath = join(subPath, f) + const raw = await readFile(filePath, "utf8") + const lines = raw.split("\n") + const out: string[] = [] + let changed = false + for (const line of lines) { + if (!line) { out.push(line); continue } + try { + const obj = JSON.parse(line) + const content = obj?.message?.content + if (Array.isArray(content)) { + const filtered = content.filter((c: any) => c?.type !== "thinking" && c?.type !== "redacted_thinking") + if (filtered.length !== content.length) { + stripped += content.length - filtered.length + obj.message.content = filtered + changed = true + out.push(JSON.stringify(obj)) + continue + } + } + out.push(line) + } catch { + out.push(line) + } + } + if (changed) { + sessionsTouched++ + await mkdir(join(archiveDir, sub), { recursive: true }) + await writeFile(join(archiveDir, sub, f), raw) + await writeFile(filePath, out.join("\n")) + } + } + } + } catch {} + return { stripped, sessionsTouched } + } + + async clear(by: string) { + // 1. Stop in-flight generation if any. + if (this.q) { + try { await this.q.interrupt() } catch {} + this.q = null + } + // 2. Drop SDK context without deleting history. Touch an empty new + // jsonl in each existing encoded-cwd subdir so --continue picks it. + // If no subdir exists yet (no SDK has spawned in this loop), the + // first post-clear message creates one naturally and starts fresh. + const projectsDir = join(loopClaudeDir(this.id), "projects") + try { + const subdirs = await readdir(projectsDir) + for (const sub of subdirs) { + const newPath = join(projectsDir, sub, randomUUID() + ".jsonl") + try { await writeFile(newPath, "") } catch {} + } + } catch { + // projects/ doesn't exist yet — nothing to do; SDK state is already empty + } + // 3. Append boundary marker (in-memory + jsonl + broadcast). + const marker = { type: "clear-boundary" as const, ts: new Date().toISOString(), by } + this.history.push(marker as any) + this.persist(marker) + this.broadcast(marker) + } +} + +const sessions = new Map<string, LoopSession>() + +/** + * Snapshot of in-memory session activity for the admin dashboard. + * Only includes loops whose `LoopSession` has been instantiated (i.e. someone + * touched them via attach / sendUserText / etc.). Idle loops aren't here. + */ +export function getActivitySnapshot(): Array<{ + id: string + wsCount: number + generating: boolean +}> { + return [...sessions.entries()].map(([id, s]) => ({ + id, + wsCount: (s as any).subscribers.size as number, + generating: (s as any).generating as boolean, + })) +} + +export function getSession(id: string): LoopSession { + let s = sessions.get(id) + if (!s) { + s = new LoopSession(id) + sessions.set(id, s) + } + return s +} + +/** Destroy a loop's session if one exists. No-op if there is no active session. */ +export function destroySession(id: string): boolean { + const s = sessions.get(id) + if (!s) return false + s.destroy() + return true +} + +/** + * Restart the in-memory LoopSession for one loop, if it exists. + * + * "Restart" means: interrupt the current `query()` so the next user message + * re-runs `ensureStarted` — which re-reads vault tokens, `mcpServers`, + * provider env, etc. The SDK reads its session JSONL on respawn + * (`continue: true`), so conversation history is preserved. + * + * Returns true if a session was restarted, false if the loop had no active + * session (no-op). + */ +export function restartSession(id: string): boolean { + const s = sessions.get(id) + if (!s) return false + s.restartOnNextMessage() + return true +} + diff --git a/server/src/system-prompt.ts b/server/src/system-prompt.ts new file mode 100644 index 00000000..43bac9cd --- /dev/null +++ b/server/src/system-prompt.ts @@ -0,0 +1,91 @@ +/** + * System prompt composition. Layers: + * L1 (preset) Claude Code preset — built-in + * L2 (doctrine) bundled platform doctrine (server/templates/CLAUDE.md): + * sandbox layout, virtual paths, memory model. Always loaded. + * Injected via `systemPrompt.append`. + * L2+ (workspace) optional workspace supplement at knowledge/.loopat/claude/CLAUDE.md. + * Bound into CLAUDE_CONFIG_DIR/CLAUDE.md and auto-loaded by + * Claude Code as user-tier (settingSources: ["user", ...]). + * See bwrap.ts for the bind. + * L2++ (project) optional <workdir>/CLAUDE.md auto-loaded by Claude Code + * itself (enabled via `settingSources: [..., "project"]`). + * L3 (runtime) per-loop dynamic info (title/id/branch/repo). + * Injected via `systemPrompt.append`. + * + * Doctrine uses **virtual paths** (/loopat/loop/<id>/, /loopat/context/*) since + * the loop runs inside the outer bwrap sandbox and that's what Claude sees. + */ +import { readFile } from "node:fs/promises" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { effectiveDriver, type LoopMeta } from "./loops" +import { bundledDoctrinePath, personalNotesDir, personalKnowledgeDir } from "./paths" + +const execFileP = promisify(execFile) + +let cachedBundled: string | null = null + +async function loadBundled(): Promise<string> { + if (cachedBundled !== null) return cachedBundled + cachedBundled = await readFile(bundledDoctrinePath(), "utf8") + return cachedBundled +} + +export function invalidateDoctrineCache(): void { + cachedBundled = null +} + +async function detectTrunkBranch(repoDir: string): Promise<string> { + try { + const { stdout } = await execFileP("git", ["-C", repoDir, "symbolic-ref", "--short", "HEAD"]) + return stdout.trim() || "main" + } catch { + return "main" + } +} + +async function buildRuntimeBlock(loop: LoopMeta): Promise<string> { + const repoLine = loop.repo ? `${loop.repo} (branch ${loop.branch ?? "main"})` : "(no repo bound — empty workdir)" + const driver = effectiveDriver(loop) + const [notesTrunk, knowledgeTrunk] = await Promise.all([ + detectTrunkBranch(personalNotesDir(driver)), + detectTrunkBranch(personalKnowledgeDir(driver)), + ]) + const lines = [ + `## Runtime context (this loop)`, + ``, + `- title: ${loop.title}`, + `- id: ${loop.id}`, + `- driver: ${effectiveDriver(loop)}`, + `- workdir: /loopat/loop/${loop.id}/workdir`, + `- repo: ${repoLine}`, + `- context worktrees: notes on branch \`loop/${loop.id}\` (trunk \`${notesTrunk}\`), knowledge on branch \`loop/${loop.id}\` (trunk \`${knowledgeTrunk}\`)`, + `- created: ${loop.createdAt}`, + ] + if (loop.config?.goal) { + const status = (loop.config as any).goalStatus === "completed" ? "completed" : "active" + const statusNote = status === "completed" + ? `(marked complete)` + : `(active — work persistently; when done, tell the user so they can mark it complete)` + lines.push(``) + lines.push(`## Goal ${statusNote}`) + lines.push(``) + lines.push(loop.config.goal) + lines.push(``) + if (status === "active") { + lines.push(`This is your top priority. Every action you take should move this goal forward.`) + lines.push(`Break it into concrete steps, execute them, and verify the results.`) + lines.push(`When you believe the goal is fully accomplished, explicitly tell the user what you did and ask them to confirm completion.`) + } else { + lines.push(`This goal has been marked complete. The user may set a new goal with /goal.`) + } + } + return lines.join("\n").trim() +} + +export async function buildLoopatAppend(loop: LoopMeta): Promise<string> { + const bundled = await loadBundled() + const runtime = await buildRuntimeBlock(loop) + return `${bundled}\n\n${runtime}\n`.trim() +} diff --git a/server/src/term.ts b/server/src/term.ts new file mode 100644 index 00000000..1b43aa71 --- /dev/null +++ b/server/src/term.ts @@ -0,0 +1,222 @@ +import { spawn, type IPty } from "bun-pty" +import type { WSContext } from "hono/ws" +import { mkdir, chmod } from "node:fs/promises" +import { join } from "node:path" +import { ensureContainer, buildPodmanExecArgs, markActive, markInactive, V_LOOP_WORKDIR, getLoopWarning } from "./podman" +import { updateLoopStatus, setLoopPhase } from "./loop-status" +import { effectiveDriver, getLoop, loopEphemeralPorts } from "./loops" +import { loadPersonalConfig } from "./config" + +type Term = { + proc: IPty + subscribers: Set<WSContext> + /** + * Rolling buffer of recent PTY output. Replayed to each new subscriber + * so the initial prompt (emitted before the first ws joined) and history + * since term spawn are visible on attach. Capped by SCROLLBACK_MAX_BYTES. + */ + scrollback: string[] + scrollbackBytes: number +} + +const SCROLLBACK_MAX_BYTES = 64 * 1024 + +const terms = new Map<string, Term>() +const pending = new Map<string, Promise<Term>>() + +async function getOrSpawn(loopId: string, initCols = 80, initRows = 24): Promise<Term> { + const existing = terms.get(loopId) + if (existing) return existing + const inflight = pending.get(loopId) + if (inflight) return inflight + + const tag = loopId.slice(0, 8) + const p = (async () => { + const meta = await getLoop(loopId) + if (!meta) throw new Error(`loop ${loopId} not found`) + const driver = effectiveDriver(meta) + const personalCfg = await loadPersonalConfig(driver, meta.config?.vault) + + // Inner shell: fish, baked into the sandbox image — no per-user override + // (the base image ships a good interactive shell so users don't configure it). + const innerShell = "fish" + // `script -qfc "<shell> -i" /dev/null` gives the inner shell a fresh + // controlling tty so prompt + job control work cleanly. PATH (incl. + // the mise shims dir) is baked into the per-loop image's ENV, so the + // toolchain works in here without host-side activation. + const innerCmd = `script -qfc "${innerShell} -i" /dev/null` + + // Fish (and other interactive shells) want XDG_DATA_HOME / XDG_RUNTIME_DIR + // to be writable. /tmp is bound shared with host inside the container at + // the same path, so we can safely mkdir paths here that the container + // will see at the same location. + const fishHome = `/tmp/loopat-fish-${loopId}` + const fishData = join(fishHome, "data") + const fishRuntime = join(fishHome, "runtime") + await mkdir(fishData, { recursive: true }) + await mkdir(fishRuntime, { recursive: true }) + await chmod(fishRuntime, 0o700).catch(() => {}) + + // Only flips `preparing` on once a build/pull actually emits progress — + // a warm loop (image cached) never fires onProgress, so the UI shows no + // gate. After ensureContainer returns we clear it back to `ready`. + let building = false + await ensureContainer({ + loopId, + createdBy: driver, + vaultName: meta.config?.vault, + knowledgeRw: meta.config?.knowledge_rw, + mountAllLoops: meta.config?.mount_all_loops, + repo: meta.repo, + extraEnv: personalCfg.vaultEnvs, + ephemeralPorts: loopEphemeralPorts(meta), + }, { + onProgress: (msg) => { + if (!building) { building = true; setLoopPhase(loopId, "preparing") } + updateLoopStatus(loopId, msg) + }, + }) + if (building) setLoopPhase(loopId, "ready") + markActive(loopId, "pty") + updateLoopStatus(loopId, "Ready") + + const podmanArgs = buildPodmanExecArgs({ + loopId, + command: "/bin/bash", + args: ["-c", innerCmd], + env: { + ...personalCfg.vaultEnvs, + TERM: "xterm-256color", + XDG_DATA_HOME: fishData, + XDG_RUNTIME_DIR: fishRuntime, + }, + tty: true, + interactive: true, + workdir: V_LOOP_WORKDIR(loopId), + }) + + const binary = process.env.LOOPAT_PODMAN_BIN || "podman" + // Debug-only: arg count is meaningless to end users and printed at error + // level it looks like a failure. Gate behind LOOPAT_DEBUG. + if (process.env.LOOPAT_DEBUG || process.env.LOOPAT_DEBUG_SPAWN) { + console.error(`[term:${tag}] spawn ${binary} argc=${podmanArgs.length}`) + } + const proc = spawn(binary, podmanArgs, { + name: "xterm-256color", + cols: initCols, + rows: initRows, + env: { ...process.env, TERM: "xterm-256color" } as Record<string, string>, + }) + const t: Term = { proc, subscribers: new Set(), scrollback: [], scrollbackBytes: 0 } + terms.set(loopId, t) + + proc.onData((chunk) => { + t.scrollback.push(chunk) + t.scrollbackBytes += chunk.length + while (t.scrollbackBytes > SCROLLBACK_MAX_BYTES && t.scrollback.length > 1) { + const dropped = t.scrollback.shift()! + t.scrollbackBytes -= dropped.length + } + for (const ws of t.subscribers) { + try { ws.send(JSON.stringify({ type: "data", data: chunk })) } catch {} + } + }) + proc.onExit(({ exitCode }) => { + if (exitCode !== 0) { + const trailing = t.scrollback.join("").slice(-400) + console.error(`[term:${tag}] podman exit=${exitCode}; last 400 bytes of pty output:\n${trailing}`) + } + 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 + } catch (e: any) { + console.error(`[term:${tag}] spawn failed: ${e?.message ?? e}`) + throw e + } finally { + pending.delete(loopId) + } +} + +export async function attachTerm(loopId: string, ws: WSContext, initCols = 80, initRows = 24) { + const t = await getOrSpawn(loopId, initCols, initRows) + t.subscribers.add(ws) + // If ensureLoopImage fell back to the base image because the loop's + // mise.toml failed to build, surface the reason to the user in-band. + // The loop is still usable — the user just doesn't get their toolchain + // until they fix mise.toml and restart the loop. + const warning = getLoopWarning(loopId) + if (warning) { + try { + ws.send(JSON.stringify({ + type: "data", + data: `\r\n\x1b[33m⚠ ${warning}\x1b[0m\r\n`, + })) + } catch {} + } + // Send ^L so the inner shell redraws once the new viewer is attached. + t.proc.write("\x0c") +} + +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) + markInactive(loopId, "pty") + } +} + +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 {} +} + +/** Force-kill a loop's terminal PTY process and disconnect all subscribers. + * Handles the in-flight spawn case (pending promise). */ +export function killTerm(loopId: string) { + const inflight = pending.get(loopId) + if (inflight) { + inflight.then((t) => { + terms.delete(loopId) + for (const ws of t.subscribers) { + try { ws.send(JSON.stringify({ type: "exit", code: -1 })); ws.close() } catch {} + } + try { t.proc.kill() } catch {} + }).catch(() => {}) + pending.delete(loopId) + return + } + const t = terms.get(loopId) + if (!t) return + terms.delete(loopId) + for (const ws of t.subscribers) { + try { + ws.send(JSON.stringify({ type: "exit", code: -1 })) + ws.close() + } catch {} + } + t.subscribers.clear() + try { t.proc.kill() } catch {} + markInactive(loopId, "pty") +} diff --git a/server/src/tiers.ts b/server/src/tiers.ts new file mode 100644 index 00000000..77955924 --- /dev/null +++ b/server/src/tiers.ts @@ -0,0 +1,762 @@ +/** + * Tier metadata + settings read/write for the five-tier composition model. + * Team / profile / personal tiers are loopat-managed; project / local are + * SDK-managed (read-only from Settings page perspective). + */ +import { existsSync } from "node:fs" +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { homedir } from "node:os" +import { + personalClaudeDir, + personalClaudeMdPath, + personalSettingsPath, + personalSkillsDir, + personalAgentsDir, + workspaceProfilesDir, + workspaceProfileClaudeDir, + workspaceProfileDir, + workspaceProfileSettingsPath, + workspaceProfileClaudeMdPath, + workspaceProfileSkillsDir, + workspaceProfileAgentsDir, + workspaceTeamClaudeDir, + workspaceTeamSettingsPath, + workspaceTeamClaudeMdPath, + workspaceTeamSkillsDir, + workspaceTeamAgentsDir, +} from "./paths" +import { countToolchainTools } from "./loop-stats" + +// ── types ── + +export type TierId = "team" | `profile:${string}` | "personal" | "project" | "local" + +export type TierInfo = { + id: TierId + label: string + path: string + exists: boolean + editable: boolean + managedBy: "admin" | "user" | "sdk" + /** Parsed settings.json — null if tier doesn't exist or has no settings.json. */ + settings: Record<string, any> | null + claudeMd: string | null + pluginCount: number + mcpServerCount: number + marketplaceCount: number + hookCount: number + skillCount: number + agentCount: number + /** Toolchain tools declared in this tier's mise.toml. */ + toolchainCount: number + /** Keys in this tier that shadow same-name keys from a lower tier. */ + overrides: Record<string, { overrides: string; value: any }> +} + +export type TiersResponse = { + tiers: TierInfo[] + /** Merged settings (team + profiles + personal), for preview. */ + mergedSettings: Record<string, any> + /** User role for permission gating. */ + isAdmin: boolean +} + +export type PluginEntry = { + name: string + marketplace: string + displayName: string + description?: string +} + +// ── helpers ── + +async function readJsonOrNull(path: string): Promise<Record<string, any> | null> { + if (!existsSync(path)) return null + try { + return JSON.parse(await readFile(path, "utf8")) + } catch { return null } +} + +async function countDir(path: string): Promise<number> { + if (!existsSync(path)) return 0 + try { + const entries = await readdir(path) + return entries.filter((e) => !e.startsWith(".")).length + } catch { return 0 } +} + +/** + * Pull a one-line description from a profile's CLAUDE.md. Priority: + * 1. YAML frontmatter `description:` field (mirrors SKILL.md / agent.md + * idiom — CC SDK already parses this for tool routing) + * 2. First non-empty heading (`# ...` line), with `#` stripped — legacy + * convention, kept as fallback so older profiles "just work" + * + * Returns null when neither is present. Pure text op; no I/O. + */ +export function extractProfileDescription(md: string | null): string | null { + if (!md) return null + // 1. Frontmatter (YAML) — between leading `---\n` and `---\n` + const fm = md.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/) + if (fm) { + const desc = fm[1].match(/^description:\s*(.+?)\s*$/m) + if (desc) { + // Strip optional surrounding quotes (YAML allows "..." / '...') + const raw = desc[1].trim() + const stripped = raw.replace(/^["'](.*)["']$/, "$1").trim() + if (stripped) return stripped + } + } + // 2. First heading — legacy fallback + const body = fm ? md.slice(fm[0].length) : md + for (const line of body.split("\n")) { + const t = line.trim() + if (!t) continue + if (t.startsWith("#")) return t.replace(/^#+\s*/, "").trim() || null + // First non-empty non-heading line ends the search — description is "missing" + return null + } + return null +} + +function computeOverrides( + settings: Record<string, any> | null, + lowerSettings: Record<string, any>, +): Record<string, { overrides: string; value: any }> { + if (!settings) return {} + const out: Record<string, { overrides: string; value: any }> = {} + for (const [k, v] of Object.entries(settings)) { + if (k === "_comment") continue + if (k === "enabledPlugins" && v && typeof v === "object") { + for (const [pn, pv] of Object.entries(v as Record<string, any>)) { + const lv = (lowerSettings?.enabledPlugins as Record<string, any>)?.[pn] + if (lv !== undefined) out[`enabledPlugins.${pn}`] = { overrides: "team", value: pv } + } + } else if (k === "mcpServers" && v && typeof v === "object") { + for (const [sn] of Object.entries(v as Record<string, any>)) { + if ((lowerSettings?.mcpServers as Record<string, any>)?.[sn] !== undefined) { + out[`mcpServers.${sn}`] = { overrides: "team", value: true } + } + } + } else if (k === "extraKnownMarketplaces" && v && typeof v === "object") { + for (const [mn] of Object.entries(v as Record<string, any>)) { + if ((lowerSettings?.extraKnownMarketplaces as Record<string, any>)?.[mn] !== undefined) { + out[`extraKnownMarketplaces.${mn}`] = { overrides: "team", value: true } + } + } + } else if (k === "hooks" && v && typeof v === "object") { + for (const [hn] of Object.entries(v as Record<string, any>)) { + if ((lowerSettings?.hooks as Record<string, any>)?.[hn] !== undefined) { + out[`hooks.${hn}`] = { overrides: "team", value: true } + } + } + } else { + if (lowerSettings?.[k] !== undefined) { + out[k] = { overrides: "team", value: v } + } + } + } + return out +} + +/** Shallow union merge (later wins) — simpler than compose.ts deep merge + * but sufficient for override detection in the settings UI. */ +function shallowUnion(a: Record<string, any>, b: Record<string, any>): Record<string, any> { + const out = { ...a } + for (const [k, v] of Object.entries(b)) { + if (k === "_comment") continue + if ( + typeof v === "object" && v !== null && !Array.isArray(v) && + typeof out[k] === "object" && out[k] !== null && !Array.isArray(out[k]) + ) { + out[k] = { ...out[k], ...v } + } else { + out[k] = v + } + } + return out +} + +function settingsSummary(s: Record<string, any> | null) { + return { + pluginCount: s?.enabledPlugins ? Object.keys(s.enabledPlugins).filter((k: string) => s.enabledPlugins[k]).length : 0, + mcpServerCount: s?.mcpServers ? Object.keys(s.mcpServers).length : 0, + marketplaceCount: s?.extraKnownMarketplaces ? Object.keys(s.extraKnownMarketplaces).length : 0, + hookCount: s?.hooks ? Object.keys(s.hooks).length : 0, + } +} + +// ── main tier listing ── + +export async function getTiers(user: string, isAdmin: boolean): Promise<TiersResponse> { + const tiers: TierInfo[] = [] + let merged: Record<string, any> = {} + + // 1. Team tier + const teamDir = workspaceTeamClaudeDir() + const teamSettings = await readJsonOrNull(workspaceTeamSettingsPath()) + tiers.push({ + id: "team", + label: "Team", + path: teamDir, + exists: existsSync(teamDir), + editable: isAdmin, + managedBy: "admin", + settings: teamSettings, + claudeMd: await readMdOrNull(workspaceTeamClaudeMdPath()), + ...settingsSummary(teamSettings), + skillCount: await countDir(workspaceTeamSkillsDir()), + agentCount: await countDir(workspaceTeamAgentsDir()), + toolchainCount: countToolchainTools(teamDir).length, + overrides: {}, + }) + if (teamSettings) merged = shallowUnion(merged, teamSettings) + + // 2. Profile tiers (all existing profiles) + const profilesDir = workspaceProfilesDir() + if (existsSync(profilesDir)) { + const entries = await readdir(profilesDir, { withFileTypes: true }) + for (const e of entries) { + if (!e.isDirectory() || e.name.startsWith(".")) continue + const claudeDir = workspaceProfileClaudeDir(e.name) + if (!existsSync(claudeDir)) continue + const ps = await readJsonOrNull(workspaceProfileSettingsPath(e.name)) + const overrides = computeOverrides(ps, merged) + tiers.push({ + id: `profile:${e.name}`, + label: `Profile: ${e.name}`, + path: claudeDir, + exists: true, + editable: isAdmin, + managedBy: "admin", + settings: ps, + claudeMd: await readMdOrNull(workspaceProfileClaudeMdPath(e.name)), + ...settingsSummary(ps), + skillCount: await countDir(workspaceProfileSkillsDir(e.name)), + agentCount: await countDir(workspaceProfileAgentsDir(e.name)), + toolchainCount: countToolchainTools(claudeDir).length, + overrides, + }) + if (ps) merged = shallowUnion(merged, ps) + } + } + + // 3. Personal tier + const personalCdir = personalClaudeDir(user) + const personalSettings = await readJsonOrNull(personalSettingsPath(user)) + const personalOverrides = computeOverrides(personalSettings, merged) + tiers.push({ + id: "personal", + label: "Personal", + path: personalCdir, + exists: existsSync(personalCdir), + editable: true, + managedBy: "user", + settings: personalSettings, + claudeMd: await readMdOrNull(personalClaudeMdPath(user)), + ...settingsSummary(personalSettings), + skillCount: await countDir(personalSkillsDir(user)), + agentCount: await countDir(personalAgentsDir(user)), + toolchainCount: countToolchainTools(personalCdir).length, + overrides: personalOverrides, + }) + const finalMerged = personalSettings ? shallowUnion(merged, personalSettings) : merged + + // 4. Project tier (SDK-managed, informational) + tiers.push({ + id: "project", + label: "Project", + path: "<workdir>/.claude/", + exists: false, + editable: false, + managedBy: "sdk", + settings: null, + claudeMd: null, + pluginCount: 0, + mcpServerCount: 0, + marketplaceCount: 0, + hookCount: 0, + skillCount: 0, + agentCount: 0, + toolchainCount: 0, + overrides: {}, + }) + + // 5. Local tier (SDK-managed, informational) + tiers.push({ + id: "local", + label: "Local", + path: "<workdir>/.claude/*.local.*", + exists: false, + editable: false, + managedBy: "sdk", + settings: null, + claudeMd: null, + pluginCount: 0, + mcpServerCount: 0, + marketplaceCount: 0, + hookCount: 0, + skillCount: 0, + agentCount: 0, + toolchainCount: 0, + overrides: {}, + }) + + return { tiers, mergedSettings: finalMerged, isAdmin } +} + +async function readMdOrNull(path: string): Promise<string | null> { + if (!existsSync(path)) return null + try { return await readFile(path, "utf8") } catch { return null } +} + +// ── per-tier settings read/write ── + +function resolveTierClaudeDir(tierId: string, user: string): string | null { + if (tierId === "team") return workspaceTeamClaudeDir() + if (tierId === "personal") return personalClaudeDir(user) + if (tierId.startsWith("profile:")) { + const name = tierId.slice("profile:".length) + return workspaceProfileClaudeDir(name) + } + return null +} + +function resolveTierPath(tierId: string, user: string): { settingsPath: string; exists: boolean } | null { + if (tierId === "team") { + const p = workspaceTeamSettingsPath() + return { settingsPath: p, exists: existsSync(p) } + } + if (tierId === "personal") { + const p = personalSettingsPath(user) + return { settingsPath: p, exists: existsSync(p) } + } + if (tierId.startsWith("profile:")) { + const name = tierId.slice("profile:".length) + const p = workspaceProfileSettingsPath(name) + return { settingsPath: p, exists: existsSync(p) } + } + return null +} + +export async function getTierSettings( + tierId: string, + user: string, +): Promise<Record<string, any>> { + const res = resolveTierPath(tierId, user) + if (!res) return {} + if (!res.exists) return {} + return (await readJsonOrNull(res.settingsPath)) ?? {} +} + +export async function saveTierSettings( + tierId: string, + settings: Record<string, any>, + user: string, +): Promise<{ ok: boolean; error?: string }> { + const res = resolveTierPath(tierId, user) + if (!res) return { ok: false, error: `unknown tier: ${tierId}` } + try { + await mkdir(join(res.settingsPath, ".."), { recursive: true }) + await writeFile(res.settingsPath, JSON.stringify(settings, null, 2) + "\n") + return { ok: true } + } catch (e: any) { + return { ok: false, error: e?.message ?? "write failed" } + } +} + +// ── mise.toml config per tier ── + +export async function getTierMiseConfig( + tierId: string, + user: string, +): Promise<{ content: string; exists: boolean; error?: string }> { + const claudeDir = resolveTierClaudeDir(tierId, user) + if (!claudeDir) return { content: "", exists: false, error: `unknown tier: ${tierId}` } + const misePath = join(claudeDir, "mise.toml") + if (!existsSync(misePath)) return { content: "", exists: false } + try { + const content = await readFile(misePath, "utf8") + return { content, exists: true } + } catch (e: any) { + return { content: "", exists: false, error: e?.message ?? "read failed" } + } +} + +export async function saveTierMiseConfig( + tierId: string, + content: string, + user: string, +): Promise<{ ok: boolean; error?: string }> { + const claudeDir = resolveTierClaudeDir(tierId, user) + if (!claudeDir) return { ok: false, error: `unknown tier: ${tierId}` } + try { + await mkdir(claudeDir, { recursive: true }) + const misePath = join(claudeDir, "mise.toml") + await writeFile(misePath, content) + return { ok: true } + } catch (e: any) { + return { ok: false, error: e?.message ?? "write failed" } + } +} + +/** Read a single plugin.json from an installPath to get displayName + description. */ +async function readPluginMeta(installPath: string): Promise<{ displayName?: string; description?: string }> { + const pj = join(installPath, ".claude-plugin", "plugin.json") + if (!existsSync(pj)) return {} + try { + const j = JSON.parse(await readFile(pj, "utf8")) + return { + displayName: typeof j.displayName === "string" ? j.displayName : undefined, + description: typeof j.description === "string" ? j.description : undefined, + } + } catch { return {} } +} + +// ── plugin inventory ── + +export type MarketplaceSource = { + name: string + source: any + installLocation?: string +} + +export type PluginWithStatus = PluginEntry & { + installed: boolean + marketplaceName: string +} + +export async function listAvailablePlugins(): Promise<PluginEntry[]> { + // Read installed plugins from host CC cache. + const cacheDir = join(homedir(), ".claude", "plugins") + const installedPath = join(cacheDir, "installed_plugins.json") + + const out: PluginEntry[] = [] + if (existsSync(installedPath)) { + try { + const installed = JSON.parse(await readFile(installedPath, "utf8")) + const plugins = installed?.plugins ?? installed + for (const [key, entries] of Object.entries(plugins as Record<string, any>)) { + const atIdx = key.lastIndexOf("@") + const name = atIdx >= 0 ? key.slice(0, atIdx) : key + const marketplace = atIdx >= 0 ? key.slice(atIdx + 1) : "" + // Get installPath from first entry, read plugin.json for metadata + const installPath = Array.isArray(entries) && entries.length > 0 + ? (entries[0] as any)?.installPath + : undefined + const meta = installPath ? await readPluginMeta(installPath) : {} + out.push({ + name, + marketplace, + displayName: meta.displayName ?? name, + description: meta.description ?? undefined, + }) + } + } catch {} + } + return out +} + +/** List known marketplaces from CC's cache. */ +export async function listMarketplaces(): Promise<MarketplaceSource[]> { + const cacheDir = join(homedir(), ".claude", "plugins") + const knownMpPath = join(cacheDir, "known_marketplaces.json") + if (!existsSync(knownMpPath)) return [] + try { + const kf = JSON.parse(await readFile(knownMpPath, "utf8")) as Record<string, any> + return Object.entries(kf).map(([name, info]) => ({ + name, + source: info?.source ?? null, + installLocation: info?.installLocation ?? undefined, + })) + } catch { + return [] + } +} + +/** Browse plugins from marketplace catalogs (not just installed ones). + * Scans known_marketplaces.json for each marketplace's install location, + * then reads .claude-plugin/marketplace.json for the plugin catalog. */ +export async function browseMarketplacePlugins(): Promise<PluginWithStatus[]> { + const cacheDir = join(homedir(), ".claude", "plugins") + const knownMpPath = join(cacheDir, "known_marketplaces.json") + const installedPath = join(cacheDir, "installed_plugins.json") + + // Build set of installed plugin keys + const installedSet = new Set<string>() + if (existsSync(installedPath)) { + try { + const installed = JSON.parse(await readFile(installedPath, "utf8")) as Record<string, any> + for (const key of Object.keys(installed)) installedSet.add(key) + } catch {} + } + + const out: PluginWithStatus[] = [] + + if (!existsSync(knownMpPath)) return out + + try { + const kf = JSON.parse(await readFile(knownMpPath, "utf8")) as Record<string, any> + for (const [mpName, mpInfo] of Object.entries(kf)) { + const loc = mpInfo?.installLocation + if (!loc || typeof loc !== "string") continue + const catalogPath = join(loc, ".claude-plugin", "marketplace.json") + if (!existsSync(catalogPath)) continue + try { + const catalog = JSON.parse(await readFile(catalogPath, "utf8")) as { plugins?: Array<{ name: string; source: any }> } + for (const p of catalog.plugins ?? []) { + const key = `${p.name}@${mpName}` + // For local marketplaces, try reading plugin.json from the plugin subdir + let desc: string | undefined + let dname: string | undefined + const pluginDir = join(loc, p.name) + if (existsSync(pluginDir)) { + const meta = await readPluginMeta(pluginDir) + dname = meta.displayName + desc = meta.description + } + out.push({ + name: p.name, + marketplace: mpName, + marketplaceName: mpName, + displayName: dname ?? p.name, + description: desc ?? undefined, + installed: installedSet.has(key), + }) + } + } catch {} + } + } catch {} + + return out +} + +/** Refresh marketplace registrations: scan all tiers' extraKnownMarketplaces, + * register any new ones with the host CC, and update existing ones with + * source/branch drift. This ensures browseMarketplacePlugins can see them. */ +export async function refreshMarketplaces(user: string): Promise<{ ok: boolean; added: string[]; error?: string }> { + try { + const { execFile } = await import("node:child_process") + const { promisify } = await import("node:util") + const execFileP = promisify(execFile) + const runClaude = async (args: string[]) => { + try { + await execFileP("claude", args) + return { ok: true } + } catch (e: any) { + return { ok: false, err: e?.stderr?.toString?.() ?? e?.message ?? String(e) } + } + } + + // 1. Read existing known_marketplaces.json + const kmPath = join(homedir(), ".claude", "plugins", "known_marketplaces.json") + let knownMarketplaces: Record<string, any> = {} + if (existsSync(kmPath)) { + try { knownMarketplaces = JSON.parse(await readFile(kmPath, "utf8")) } catch {} + } + + // 2. Collect all extraKnownMarketplaces from all tiers + const extras: Record<string, any> = {} + + // Team tier + const teamSettings = await readJsonOrNull(workspaceTeamSettingsPath()) + if (teamSettings?.extraKnownMarketplaces) { + Object.assign(extras, teamSettings.extraKnownMarketplaces) + } + + // All profiles + const profilesDir = workspaceProfilesDir() + if (existsSync(profilesDir)) { + const entries = await readdir(profilesDir, { withFileTypes: true }) + for (const e of entries) { + if (!e.isDirectory() || e.name.startsWith(".")) continue + const ps = await readJsonOrNull(workspaceProfileSettingsPath(e.name)) + if (ps?.extraKnownMarketplaces) { + Object.assign(extras, ps.extraKnownMarketplaces) + } + } + } + + // Personal tier + const personalSettings = await readJsonOrNull(personalSettingsPath(user)) + if (personalSettings?.extraKnownMarketplaces) { + Object.assign(extras, personalSettings.extraKnownMarketplaces) + } + + // 3. For each marketplace, register it with CC if missing or drifted + const added: string[] = [] + for (const [name, entry] of Object.entries(extras)) { + const src = (entry as any)?.source + if (!src) continue + + // Determine the add path. CC auto-detects source type from the path + // (URL → git, owner/repo → github, absolute path → directory). + let addPath: string | undefined + if (src.source === "directory" && typeof src.path === "string") { + addPath = src.path + } else if (src.source === "github" && typeof src.repo === "string") { + addPath = src.repo + } else if ((src.source === "git" || src.source === "url") && typeof src.url === "string") { + addPath = src.url + } + + if (!addPath) continue + + // Check if marketplace already registered — search by source match + const existing = knownMarketplaces[name] + ?? Object.entries(knownMarketplaces).find(([, v]: [string, any]) => { + const es = v?.source + if (!es) return false + if (es.source === "directory" && es.path === src.path) return true + if (es.source === "github" && es.repo === src.repo) return true + if ((es.source === "git" || es.source === "url") && es.url === src.url) return true + return false + })?.[0] + + if (existing) { + const existEntry = knownMarketplaces[existing] + const existSrc = existEntry?.source + const needUpdate = !existSrc || + existSrc.source !== src.source || + (src.source === "git" && existSrc.url !== src.url) || + (src.source === "github" && existSrc.repo !== src.repo) || + (src.source === "directory" && existSrc.path !== src.path) || + (typeof src.branch === "string" && existSrc.branch !== src.branch) + + if (!needUpdate) continue + + // Source or branch changed — remove old and re-add + console.warn(`[tiers] marketplace "${existing}" source/branch drift, re-registering`) + await runClaude(["plugin", "marketplace", "remove", existing]) + } + + // Build add command: claude plugin marketplace add <path> [--branch <b>] + // CC auto-detects source type (and derives name) from the path. + const args = ["plugin", "marketplace", "add", addPath] + if (typeof src.branch === "string" && src.branch) { + args.push("--branch", src.branch) + } + + const r = await runClaude(args) + if (r.ok) { + added.push(name) + } else { + console.warn(`[tiers] failed to register marketplace "${name}": ${r.err}`) + } + } + + return { ok: true, added } + } catch (e: any) { + return { ok: false, added: [], error: e?.message ?? "refresh failed" } + } +} + +// ── profile CRUD (admin) ── + +export type ProfileDetail = { + name: string + path: string + description: string | null + settings: Record<string, any> | null + claudeMd: string | null + pluginCount: number + mcpServerCount: number + marketplaceCount: number + hookCount: number + skillCount: number + agentCount: number + /** Toolchain tools declared in this profile's mise.toml. */ + toolchainCount: number +} + +export async function listProfilesRich(): Promise<ProfileDetail[]> { + const root = workspaceProfilesDir() + if (!existsSync(root)) return [] + const entries = await readdir(root, { withFileTypes: true }) + const out: ProfileDetail[] = [] + for (const e of entries) { + if (!e.isDirectory() || e.name.startsWith(".")) continue + const cd = workspaceProfileClaudeDir(e.name) + if (!existsSync(cd)) continue + const settings = await readJsonOrNull(workspaceProfileSettingsPath(e.name)) + const md = await readMdOrNull(workspaceProfileClaudeMdPath(e.name)) + const desc = extractProfileDescription(md) + out.push({ + name: e.name, + path: cd, + description: desc || null, + settings, + claudeMd: md, + ...settingsSummary(settings), + skillCount: await countDir(workspaceProfileSkillsDir(e.name)), + agentCount: await countDir(workspaceProfileAgentsDir(e.name)), + toolchainCount: countToolchainTools(cd).length, + }) + } + return out.sort((a, b) => a.name.localeCompare(b.name)) +} + +export async function createProfile(name: string): Promise<{ ok: boolean; error?: string }> { + if (!/^[a-zA-Z0-9_-]+$/.test(name)) return { ok: false, error: "name must be alphanumeric, dash, or underscore" } + const dir = workspaceProfileDir(name) + if (existsSync(dir)) return { ok: false, error: `profile "${name}" already exists` } + try { + await mkdir(workspaceProfileClaudeDir(name), { recursive: true }) + // Seed with empty settings.json and stub CLAUDE.md + await writeFile(workspaceProfileSettingsPath(name), "{}\n") + await writeFile(workspaceProfileClaudeMdPath(name), `# ${name} profile\n\nAdd instructions for this profile here.\n`) + return { ok: true } + } catch (e: any) { + return { ok: false, error: e?.message ?? "create failed" } + } +} + +export async function getProfile(name: string): Promise<ProfileDetail | null> { + const cd = workspaceProfileClaudeDir(name) + if (!existsSync(cd)) return null + const settings = await readJsonOrNull(workspaceProfileSettingsPath(name)) + const md = await readMdOrNull(workspaceProfileClaudeMdPath(name)) + const desc = extractProfileDescription(md) + return { + name, + path: cd, + description: desc || null, + settings, + claudeMd: md, + ...settingsSummary(settings), + skillCount: await countDir(workspaceProfileSkillsDir(name)), + agentCount: await countDir(workspaceProfileAgentsDir(name)), + toolchainCount: countToolchainTools(cd).length, + } +} + +export async function updateProfile( + name: string, + data: { settings?: Record<string, any>; claudeMd?: string }, +): Promise<{ ok: boolean; error?: string }> { + const cd = workspaceProfileClaudeDir(name) + if (!existsSync(cd)) return { ok: false, error: `profile "${name}" not found` } + try { + if (data.settings !== undefined) { + await writeFile(workspaceProfileSettingsPath(name), JSON.stringify(data.settings, null, 2) + "\n") + } + if (data.claudeMd !== undefined) { + await writeFile(workspaceProfileClaudeMdPath(name), data.claudeMd) + } + return { ok: true } + } catch (e: any) { + return { ok: false, error: e?.message ?? "update failed" } + } +} + +export async function deleteProfile(name: string): Promise<{ ok: boolean; error?: string }> { + const dir = workspaceProfileDir(name) + if (!existsSync(dir)) return { ok: false, error: `profile "${name}" not found` } + try { + await rm(dir, { recursive: true, force: true }) + return { ok: true } + } catch (e: any) { + return { ok: false, error: e?.message ?? "delete failed" } + } +} diff --git a/server/src/uninstall.ts b/server/src/uninstall.ts new file mode 100644 index 00000000..c4e66dbd --- /dev/null +++ b/server/src/uninstall.ts @@ -0,0 +1,133 @@ +/** + * `loopat uninstall` — clean removal of everything THIS workspace created. + * + * Every loopat resource is workspace-scoped: containers, images, and the + * network all carry a `loopat.workspace=<ws>` label, and the data dir IS this + * workspace's LOOPAT_HOME. So uninstall removes only its own — even when other + * LOOPAT_HOMEs exist on the same host, there's no cross-workspace collateral. + * + * Shared host infrastructure loopat merely uses — the podman machine (a Linux + * VM on macOS) and the npx/bun cache — is only PRINTED as a hint, never + * touched. Deleting a shared VM out from under the user would be the opposite + * of a clean uninstall. + * + * Run via the launcher: `npx loopat uninstall [--yes]`. + */ +import { existsSync } from "node:fs" +import { rm } from "node:fs/promises" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { homedir } from "node:os" +import { join } from "node:path" +import { LOOPAT_HOME, WORKSPACE } from "./paths" + +const execFileP = promisify(execFile) + +// The label every loopat container/image/network carries (set at create/build +// time in podman.ts). Deleting by label is exact — no name-prefix ambiguity +// (e.g. workspace "foo" vs "foobar"). +const LABEL_WORKSPACE = "loopat.workspace" +const labelFilter = `label=${LABEL_WORKSPACE}=${WORKSPACE}` + +type Run = { code: number; out: string; err: string } +async function podman(args: string[]): Promise<Run> { + try { + const { stdout, stderr } = await execFileP("podman", args, { maxBuffer: 16 * 1024 * 1024 }) + return { code: 0, out: stdout, err: stderr } + } catch (e: any) { + return { code: typeof e?.code === "number" ? e.code : 1, out: e?.stdout ?? "", err: e?.stderr ?? String(e?.message ?? e) } + } +} + +const lines = (s: string) => s.split("\n").map((x) => x.trim()).filter(Boolean) + +async function podmanAvailable(): Promise<boolean> { + return (await podman(["--version"])).code === 0 +} + +async function workspaceContainers(): Promise<string[]> { + const r = await podman(["ps", "-aq", "--filter", labelFilter]) + return r.code === 0 ? lines(r.out) : [] +} +async function workspaceImageIds(): Promise<string[]> { + const r = await podman(["images", "--filter", labelFilter, "--format", "{{.ID}}"]) + return r.code === 0 ? [...new Set(lines(r.out))] : [] +} +async function workspaceNetworks(): Promise<string[]> { + const r = await podman(["network", "ls", "--filter", labelFilter, "--format", "{{.Name}}"]) + return r.code === 0 ? lines(r.out) : [] +} + +/** TTY confirm. Non-interactive (piped) without --yes → treated as "no". */ +function confirm(question: string): boolean { + const ans = prompt(question) + return ans !== null && /^y(es)?$/i.test(ans.trim()) +} + +export async function runUninstall(argv: string[]): Promise<void> { + const yes = argv.includes("--yes") || argv.includes("-y") + const hasPodman = await podmanAvailable() + const containers = hasPodman ? await workspaceContainers() : [] + const images = hasPodman ? await workspaceImageIds() : [] + const networks = hasPodman ? await workspaceNetworks() : [] + const dataExists = existsSync(LOOPAT_HOME) + + // ── Plan (the user sees the exact boundary before anything happens) ── + console.log(`loopat uninstall — workspace "${WORKSPACE}"`) + console.log("") + console.log("Will remove (this workspace only):") + console.log(` • ${containers.length} sandbox container(s)`) + console.log(` • ${images.length} sandbox image(s)`) + console.log(` • ${networks.length} network(s)${networks.length ? ` (${networks.join(", ")})` : ""}`) + console.log(` • data dir: ${LOOPAT_HOME}${dataExists ? "" : " (absent)"}`) + if (!hasPodman) console.log(" • note: podman not found — skipping container/image/network cleanup") + console.log("") + + if (!yes && !confirm("Proceed? This permanently deletes this workspace's data. [y/N] ")) { + console.log("Aborted — nothing removed.") + return + } + + // Every resource below is label-scoped to THIS workspace — no shared-resource + // guessing, so other workspaces on the host are never touched. + if (hasPodman && containers.length) { + process.stdout.write(`Removing ${containers.length} container(s)… `) + await podman(["rm", "-f", ...containers]) + console.log("done") + } + if (hasPodman && images.length) { + // rmi by image ID removes this workspace's tags; shared overlay layers + // stay alive (refcounted) for any other workspace still using them. + process.stdout.write(`Removing ${images.length} image(s)… `) + await podman(["rmi", "-f", ...images]) + console.log("done") + } + for (const net of networks) { + process.stdout.write(`Removing network "${net}"… `) + await podman(["network", "rm", net]) + console.log("done") + } + if (dataExists) { + process.stdout.write(`Removing data dir ${LOOPAT_HOME}… `) + await rm(LOOPAT_HOME, { recursive: true, force: true }) + console.log("done") + } + + // Second-layer hints — shared infra we deliberately do NOT touch. + console.log("") + console.log("Done. This workspace's resources are gone.") + console.log("") + console.log("Left untouched (shared host infra — remove yourself only if loopat was their only user):") + if (process.platform === "darwin") { + console.log(" • podman machine (Linux VM): podman machine stop && podman machine rm") + } + console.log(` • npx/bun cache: rm -rf ${join(homedir(), ".npm", "_npx")}`) + console.log("") +} + +if (import.meta.main) { + runUninstall(process.argv.slice(2)).catch((e) => { + console.error(`[loopat] uninstall failed: ${e?.message ?? e}`) + process.exit(1) + }) +} diff --git a/server/src/vaults.ts b/server/src/vaults.ts new file mode 100644 index 00000000..46366240 --- /dev/null +++ b/server/src/vaults.ts @@ -0,0 +1,189 @@ +/** + * Vault catalog & resolution. + * + * A vault is a named bundle of credentials owned by one user. Each loop + * selects one vault at spawn time. The vault is NOT mounted into the sandbox + * as a directory; instead, two filesystem conventions drive automatic delivery: + * + * vaults/<v>/envs/<NAME> → injected as env var $NAME + * vaults/<v>/mounts/home/<rel>/... → bound at $HOME/<rel>/... + * + * AI sees a configured machine, not a "vault" directory. + * + * Filesystem: + * personal/<user>/.loopat/vaults/<name>/... + * + * Symlinks within a vault are allowed and follow Linux semantics, BUT + * `walkVaultFiles` rejects any file whose realpath escapes + * `personal/<user>/` — symlinks pointing at host paths outside the user's + * own tree are a privilege-escalation vector and never bind into the sandbox. + */ +import { existsSync, readdirSync, statSync } from "node:fs" +import { readFile, realpath, readdir, stat } from "node:fs/promises" +import { join, relative, sep } from "node:path" +import { + personalDir, + personalVaultDir, + personalVaultsDir, + personalVaultEnvsDir, + personalVaultMountsHomeDir, +} from "./paths" + +export const DEFAULT_VAULT = "default" + +const VAULT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/ +export function isValidVaultName(name: string): boolean { + return VAULT_NAME_RE.test(name) +} + +/** List vault names: subdirectories under `personal/<user>/.loopat/vaults/`. */ +export function listVaults(user: string): string[] { + const vaultsDir = personalVaultsDir(user) + if (!existsSync(vaultsDir)) return [] + try { + return readdirSync(vaultsDir) + .filter((name) => isValidVaultName(name)) + .filter((name) => { + try { + return statSync(join(vaultsDir, name)).isDirectory() + } catch { + return false + } + }) + .sort() + } catch { + return [] + } +} + +/** + * Return the host-side root directory for the named vault, or null if it + * doesn't exist on disk. + */ +export function resolveVaultRoot(user: string, vault: string): string | null { + if (!isValidVaultName(vault)) return null + const path = personalVaultDir(user, vault) + return existsSync(path) ? path : null +} + +/** + * Walk a vault root and yield (relPath, realpath) pairs for every regular + * file (following symlinks). Rejects symlinks whose realpath escapes + * `personal/<user>/` — these are dropped (caller can log) instead of + * silently exposing a host path. + */ +export async function* walkVaultFiles( + user: string, + vaultRoot: string, +): AsyncGenerator<{ rel: string; realpath: string }> { + const userRoot = personalDir(user) + const userRootReal = await realpath(userRoot).catch(() => userRoot) + + async function* visit(dir: string, prefix: string): AsyncGenerator<{ rel: string; realpath: string }> { + let entries: string[] + try { + entries = await readdir(dir) + } catch { + return + } + for (const name of entries) { + const abs = join(dir, name) + const rel = prefix ? `${prefix}/${name}` : name + let st + try { + st = await stat(abs) // follows symlinks + } catch { + continue + } + if (st.isDirectory()) { + yield* visit(abs, rel) + continue + } + if (!st.isFile()) continue + let resolved: string + try { + resolved = await realpath(abs) + } catch { + continue + } + const insideUser = relative(userRootReal, resolved) + if (insideUser.startsWith("..") || insideUser === "" || insideUser.startsWith(`/${sep}`)) { + // realpath escaped personal/<user>/ — refuse to bind + console.warn(`[loopat] vault symlink rejected (escapes user root): ${abs} → ${resolved}`) + continue + } + yield { rel, realpath: resolved } + } + } + + yield* visit(vaultRoot, "") +} + +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Load every file in `vaults/<v>/envs/` as an env-var map. Filename is the + * env var name; content is the value with one trailing newline stripped. + * + * Subdirectories under `envs/` are ignored. Files with non-env-var names + * (e.g. containing dashes or dots) are skipped — they're almost always + * accidental dotfiles or backup swap files, not real env entries. + * + * Missing vault or missing envs/ → empty map. + */ +export async function loadVaultEnvs(user: string, vault: string): Promise<Record<string, string>> { + const dir = personalVaultEnvsDir(user, vault) + if (!existsSync(dir)) return {} + let names: string[] + try { + names = await readdir(dir) + } catch { + return {} + } + const out: Record<string, string> = {} + for (const name of names) { + if (!ENV_NAME_RE.test(name)) continue + let st + try { + st = await stat(join(dir, name)) + } catch { + continue + } + if (!st.isFile()) continue + try { + const raw = await readFile(join(dir, name), "utf8") + out[name] = raw.replace(/[\r\n]+$/, "") + } catch {} + } + return out +} + +/** A single sandbox bind derived from `vaults/<v>/mounts/home/<top>`. */ +export type VaultHomeMount = { + /** Absolute host path (under the vault dir). */ + src: string + /** Path relative to sandbox $HOME (e.g. ".ssh", ".config/gh"). */ + rel: string +} + +/** + * Enumerate top-level entries under `vaults/<v>/mounts/home/`. Each one + * produces a single bind: `<vault>/mounts/home/<name>` → `$HOME/<name>`. + * + * Top-level only by design: binding the whole `.ssh/` directory means the + * sandbox sees vault-owned `.ssh/`, no other writes allowed. Binding individual + * deeper files would require enumerating and re-running on every spawn — and + * users almost always want the whole directory owned by the source. + */ +export function listVaultHomeMounts(user: string, vault: string): VaultHomeMount[] { + const dir = personalVaultMountsHomeDir(user, vault) + if (!existsSync(dir)) return [] + try { + return readdirSync(dir).filter((n) => n && !n.startsWith(".#")).map((name) => ({ + src: join(dir, name), + rel: name, + })) + } catch { + return [] + } +} diff --git a/server/src/workspace.ts b/server/src/workspace.ts new file mode 100644 index 00000000..921797fd --- /dev/null +++ b/server/src/workspace.ts @@ -0,0 +1,347 @@ +/** + * 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, lstat, mkdir, rm, unlink, symlink } 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 { homedir } from "node:os" +import { join, normalize, relative, resolve as resolvePath, sep, dirname } from "node:path" +import { + personalKnowledgeDir, + personalReposDir, + personalDir, + uiNotesDir, +} 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, user: string): string { + switch (vault) { + case "knowledge": + return personalKnowledgeDir(user) + case "notes": + // notes is edited via a per-user UI-loop worktree (opened from origin/main); + // the endpoint ensures the worktree exists before this resolves. + return uiNotesDir(user) + case "personal": + return personalDir(user) + case "repos": + return personalReposDir(user) + } +} + +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, user: string): Promise<VaultEntry[]> { + const root = vaultRoot(vault, user) + 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 }) + } + const isLoopatRoot = (e: VaultEntry) => vault === "personal" && e.type === "dir" && e.name === ".loopat" && relPath === "" + out.sort((a, b) => { + // .loopat/ pinned to the very bottom in personal vault root (platform-managed namespace) + if (isLoopatRoot(a) !== isLoopatRoot(b)) return isLoopatRoot(a) ? 1 : -1 + if (a.type !== b.type) return a.type === "dir" ? -1 : 1 + return a.name.localeCompare(b.name) + }) + return out +} + +/** + * Recursive flat list of files in a vault. Used for sidebar search. + */ +export async function vaultFlatList(vault: VaultId, user: string): Promise<VaultEntry[]> { + const root = vaultRoot(vault, user) + const out: VaultEntry[] = [] + const walk = async (abs: string, rel: string): Promise<void> => { + let names: string[] = [] + try { + names = await readdir(abs) + } catch { + return + } + for (const name of names) { + if (SKIP_DIRS.has(name) || name === ".git" || name === ".DS_Store") continue + const childAbs = join(abs, name) + const childRel = rel ? `${rel}/${name}` : name + let s + try { + s = await stat(childAbs) + } catch { + continue + } + if (s.isDirectory()) { + await walk(childAbs, childRel) + } else { + out.push({ name, path: childRel, type: "file", size: s.size }) + } + } + } + await walk(root, "") + return out +} + +const MAX_BYTES = 1024 * 1024 + +/** + * Anything under `personal/<user>/.loopat/vaults/<vault>/...` is a secret + * value. The worktree holds plaintext (so the sandbox can use it) but the API + * surface MUST NEVER hand it back to the browser — editing means overwriting + * with a new value the user types, never decrypt-and-view. + */ +function isSecretPath(vault: VaultId, relPath: string): boolean { + if (vault !== "personal") return false + if (!relPath.startsWith(".loopat/vaults/")) return false + // Need at least one path segment under the vault name to be a real file + // (`.loopat/vaults/prod` is the vault dir itself, not a secret). + const rest = relPath.slice(".loopat/vaults/".length) + return rest.includes("/") +} + +export async function vaultRead( + vault: VaultId, + relPath: string, + user: string, +): Promise<{ content: string; size: number; truncated: boolean; secret?: boolean } | null> { + const root = vaultRoot(vault, user) + const abs = safeJoin(root, relPath) + if (!abs) return null + try { + const s = await stat(abs) + if (!s.isFile()) return null + // Secrets: never return the plaintext, even to the authenticated user. + // Edit means "overwrite", never "decrypt and view". + if (isSecretPath(vault, relPath)) { + return { content: "", size: s.size, truncated: false, secret: true } + } + 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, + user: string, +): Promise<{ ok: boolean; commit?: string; error?: string }> { + const root = vaultRoot(vault, user) + 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, user: string): Promise<{ ok: boolean; error?: string }> { + const root = vaultRoot(vault, user) + 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 async function vaultCreateFolder(vault: VaultId, relPath: string, user: string): Promise<{ ok: boolean; error?: string }> { + const root = vaultRoot(vault, user) + 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(abs, { recursive: true }) + } catch (e: any) { + return { ok: false, error: e?.message } + } + return { ok: true } +} + +export async function vaultDelete(vault: VaultId, relPath: string, user: string): Promise<{ ok: boolean; error?: string }> { + const root = vaultRoot(vault, user) + const abs = safeJoin(root, relPath) + if (!abs) return { ok: false, error: "path escapes root" } + try { + const s = await stat(abs) + if (s.isDirectory()) { + await rm(abs, { recursive: true, force: true }) + } else { + await unlink(abs) + } + } catch (e: any) { + return { ok: false, error: e?.message ?? "delete failed" } + } + return { ok: true } +} + +export type Backlink = { + path: string // file path that links to the target + preview: string // first line of context around the link +} + +/** + * Scan all .md files in the vault for `[[<basename of path>]]` references + * and return matching files with a short preview. + */ +export async function vaultBacklinks(vault: VaultId, targetPath: string, user: string): Promise<Backlink[]> { + const root = vaultRoot(vault, user) + // basename without .md extension is the wikilink target + const baseName = targetPath.split("/").pop()?.replace(/\.md$/, "") ?? targetPath + const aliases = new Set<string>([baseName, targetPath, targetPath.replace(/\.md$/, "")]) + const out: Backlink[] = [] + const walk = async (dir: string): Promise<void> => { + let names: string[] = [] + try { + names = await readdir(dir) + } catch { + return + } + for (const name of names) { + if (SKIP_DIRS.has(name) || name === ".git") continue + const p = join(dir, name) + let s + try { + s = await stat(p) + } catch { + continue + } + if (s.isDirectory()) { + await walk(p) + continue + } + if (!name.endsWith(".md")) continue + const rel = relative(root, p) + if (rel === targetPath) continue + let body = "" + try { + body = await readFile(p, "utf8") + } catch { + continue + } + // find any [[X]] where X matches one of aliases + const re = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g + let m: RegExpExecArray | null + while ((m = re.exec(body)) !== null) { + const target = m[1].trim() + if (aliases.has(target)) { + // grab the line + const lineStart = body.lastIndexOf("\n", m.index) + 1 + const lineEnd = body.indexOf("\n", m.index) + const line = body.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim() + out.push({ path: rel, preview: line.slice(0, 200) }) + break + } + } + } + } + await walk(root) + return out +} + +const TOPIC_RE = /(?<![\w])#([A-Za-z0-9][\w-]*)/g +function extractTopics(text: string): string[] { + const out = new Set<string>() + let m: RegExpExecArray | null + while ((m = TOPIC_RE.exec(text)) !== null) { + out.add(m[1].toLowerCase()) + } + return [...out] +} + +/** Aggregate all topics across loop titles. */ +export type TopicAggregate = { + name: string + loops: { id: string; title: string }[] +} + +export async function listTopics(loopTitles: { id: string; title: string }[]): Promise<TopicAggregate[]> { + const map = new Map<string, TopicAggregate>() + for (const { id, title } of loopTitles) { + const topics = extractTopics(title) + for (const t of topics) { + let entry = map.get(t) + if (!entry) { + entry = { name: t, loops: [] } + map.set(t, entry) + } + entry.loops.push({ id, title }) + } + } + return [...map.values()].sort((a, b) => { + const wb = b.loops.length + const wa = a.loops.length + if (wa !== wb) return wb - wa + return a.name.localeCompare(b.name) + }) +} + diff --git a/server/templates/.claude-plugin/marketplace.json b/server/templates/.claude-plugin/marketplace.json new file mode 100644 index 00000000..f730a517 --- /dev/null +++ b/server/templates/.claude-plugin/marketplace.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "loopat-builtin", + "description": "Loopat platform-shipped plugins", + "owner": { "name": "loopat" }, + "plugins": [ + { + "name": "loopat", + "source": "./plugins/loopat", + "description": "Built-in loopat platform skills (help)" + } + ] +} diff --git a/server/templates/CLAUDE.md b/server/templates/CLAUDE.md new file mode 100644 index 00000000..c9c1b798 --- /dev/null +++ b/server/templates/CLAUDE.md @@ -0,0 +1,79 @@ +# loopat — sandbox doctrine + +You are running inside a loopat sandbox. The unit of work is a **loop** = context + AI + workdir bound together. You are the Claude process driving one specific loop. + +Mental model: **`/loopat/loop/<id>/` is the ephemeral task instance, `/loopat/context/` is the persistent workspace state around it.** Everything else outside `/loopat/` is host-internal and not your concern. + +## What you can / cannot access + +You see a virtualized filesystem, all rooted under `/loopat/`: + +- `/loopat/loop/<id>/workdir/` — the workdir (rw). cwd lives here. For code-repo loops, contents = git worktree of one repo in `context/repos/`. +- `/loopat/loop/<id>/.claude/` — internal SDK session state (rw). Don't poke unless debugging. +- `/loopat/loops/` — **admin / cross-loop distill only.** Read-only view of every other loop's `loops/<id>/{meta.json,messages.jsonl,workdir/,...}`. Absent on normal loops. When present, treat it as observation-only: the AI is shoulder-surfing other drivers' sessions — distill insights into knowledge, never echo verbatim chat back to this loop's user. +- `/loopat/context/knowledge/` — workspace's distilled docs. **Your private git worktree** on branch `loop/<id>`. Read-only by default; rw if the loop opted in. Other loops see your edits only after you publish (see below). +- `/loopat/context/notes/` — workspace prose layer (rw). **Your private git worktree** on branch `loop/<id>`. `inbox.md`, `focus.md`, plus `memory/` (team memory). Other loops see your edits only after you publish. +- `/loopat/context/personal/` — your driver's private space (rw). Includes `memory/` (personal memory) and `.loopat/config.json` (per-user config). +- `/loopat/context/repos/<name>/` — workspace repos (rw), **clone-on-demand**. Only already-cloned repos exist as subdirs; the full roster (with git urls) is in `repos/REPOS.md`. Need one that isn't there yet? `git clone <git> /loopat/context/repos/<name>`. The current loop's workdir is typically a worktree of one of them. +- `$HOME` (`/home/$USER`) — per-loop overlayfs (docker container-layer semantics). Persistent across sandbox restarts; pip/npm installs, shell history, dotfiles survive. The sandbox arrives pre-configured: `~/.ssh/`, `~/.config/gh/`, your `~/.gitconfig`, and any other CLI configs the user set up — already in place. Just use them. + +Network is open (host network is shared). Use it for API calls, git fetch, package installs, etc. + +Everything outside `/loopat/` (host's other home dirs, `/etc/private`, etc.) is invisible. + +## context conventions + +- `/loopat/context/knowledge/` is the **sedimented** doc tree. + - **Don't edit knowledge directly with Edit/Write.** Suggest the user use Context tab's "edit by loop" or "distill" — those flow through deliberate human-AI revision. This applies to `.loopat/` too (see below). + - Reading is fine and encouraged. +- `/loopat/context/notes/inbox.md` — workspace scratch prose. Format: one bullet per line, `- xxx`. Append freely. +- `/loopat/context/notes/focus.md` — `## pinned` and `## listed` sections name the workspace's current foci. Edit when user asks. +- `/loopat/context/repos/<name>/` — rw, but **don't commit directly** into a main repo. Commits go through the workdir worktree (which sits on a `loop/<slug>-<id6>` branch). Reading other repos is encouraged for cross-repo work. +- Cross-doc references use wikilink `[[basename]]` (no `.md`), Obsidian-style. The Context tab UI renders these clickable + builds backlinks. + +## publishing context edits (promote) + +`notes/` and `knowledge/` are per-loop git worktrees — your edits stay on branch `loop/<id>` until you **promote** them into shared `main`. Use the **`/promote`** skill: it merges in the latest, pushes to `main` (or opens a PR for gated context like `knowledge`/repos), and walks you through any conflict — which you, the loop's AI, resolve in place (you're the merge agent; no other agent, no script). This is the ② edge of `docs/context-flow.md`. + +**When to promote**: when an edit is genuinely meant for the workspace, not on every save. Working notes / scratch can live unpromoted as long as the loop lives. **Runtime never auto-promotes** — if you don't promote, your edits stay in the worktree and persist as long as the loop does. + +## .claude config tiers + +Loopat composes five `.claude/` tiers into the loop's runtime CLAUDE_CONFIG_DIR. +By precedence weakest → strongest: + +1. **Platform doctrine** — this file. Bundled, always loaded (concatenated as part of the system prompt). +2. **Workspace (team)** — `/loopat/context/knowledge/.loopat/.claude/`. Always on for everyone. +3. **Profiles (0..N)** — `/loopat/context/knowledge/.loopat/profiles/<name>/.claude/`. Opt-in per loop. +4. **Personal (user)** — `/loopat/context/personal/.loopat/.claude/`. Per-user overrides. +5. **Project (workdir)** — `/loopat/loop/<id>/workdir/.claude/`. Per-repo, lives in the workdir. + +Each `.claude/` dir may contain: `CLAUDE.md` · `settings.json` · `skills/<name>/SKILL.md` · `agents/<name>.md` · `mise.toml` · `mise.lock`. +The first four tiers are merged by loopat into `loops/<id>/.claude/` and become CC's user tier; the fifth is read by the SDK directly as project tier. + +When the user says "the CLAUDE.md" without qualifying, ask which tier — they often conflate them. Team / profile files live under **knowledge**, not under notes. + +All four loopat-managed tiers (workspace, profiles, personal, plus this file) → **read-only** from your view inside the loop. To edit team or profile config, propose the Context tab "edit by loop" flow — same as any knowledge file. + +## memory (two-tier) + +- `/loopat/context/personal/memory/` — **your** observations about this user. Managed by SDK auto-memory (loaded automatically each session; you write via the standard memory protocol). +- `/loopat/context/notes/memory/` — **team-shared** memory: workspace-wide patterns, conventions, gotchas. Rare, deliberate. Auto-committed and visible to everyone. + +For team memory: when an insight is genuinely team-relevant (a convention everyone should follow, a non-obvious operational fact about the codebase or infra), **promote without asking** — write `/loopat/context/notes/memory/<short-name>.md` and append one line to `/loopat/context/notes/memory/MEMORY.md`. Mention briefly in chat: "记到团队 memory 了"。Read `/loopat/context/notes/memory/MEMORY.md` at the start of non-trivial turns; auto-memory will not load it for you. + +## behavior + +- **Edit/Write directly** for `/loopat/loop/<id>/workdir/*`, `/loopat/context/notes/*`, `/loopat/context/personal/*`, and `/loopat/context/repos/*` (when explicitly working in another repo). Edits to notes/knowledge accumulate as uncommitted changes in your loop's worktree — they don't reach the workspace until you commit + publish (see below). Edits to personal still auto-commit on the host side as before. +- **Don't edit `/loopat/context/knowledge/`** directly — wrong tier, propose user-driven flow instead. +- **Confirm files exist before referencing** them across docs (Glob or Read first). +- **Grep `/loopat/context/knowledge/`** when the user asks about a concept you don't recognize. +- **Don't echo sensitive values** (API keys, tokens, SSH key material, anything that looks like a credential) to chat. Reference by filename or env var name instead. +- **Default to short, direct answers**. Don't announce a plan unless the task is genuinely large. +- **Read before Edit on long files**; avoid guessing surrounding context. +- **`origin` is the source of truth — finishing means pushing to origin.** The workdir is an ordinary git worktree with a normal `origin/<default>` tracking ref, so `git rebase origin/<default>`, `git status` ahead/behind, and `git log origin/<default>` all work as usual. A local commit is NOT "done": work is only preserved and shared once it reaches origin — open a PR, or push directly when that's the team's flow. Don't consider a task complete while it lives only in local commits. + +## collaboration + +- Multiple drivers may attach to the same loop and watch chat in real time. Everything you say persists to `messages.jsonl` and broadcasts to all viewers. +- Don't assume the user identity by name; the runtime context block (below) tells you the active driver. diff --git a/server/templates/loop-kinds/distill/CLAUDE.md b/server/templates/loop-kinds/distill/CLAUDE.md new file mode 100644 index 00000000..f53380a5 --- /dev/null +++ b/server/templates/loop-kinds/distill/CLAUDE.md @@ -0,0 +1,46 @@ +# distill loop + +You're running in a **distill loop** — a child loop spawned to extract reusable insights from a source loop's conversation and sediment them into the workspace. + +## What's in your workdir + +`/loopat/loop/<id>/workdir/source/` contains a point-in-time snapshot of the source loop's conversation: + +- `messages.jsonl` — raw SDK message log. Every assistant message, tool call, tool result, thinking block. Complete but noisy. +- `chat_history.jsonl` — loopat's rendered chat log. What the human saw. More readable but less complete (e.g. no tool internals). + +Both are read-only from your standpoint — they're the substrate you're distilling, not files you edit. If either is missing, the source had no record of that kind. + +`knowledge/` is **rw** in this loop (worktree on branch `loop/<id>`, same publish workflow as any other rw context: commit → merge trunk → push). Treat publishing knowledge as the goal of this loop. + +## How to work + +1. **Wait for the user to open the conversation.** Don't auto-summarize the source on first contact — the user usually has a specific lens (a topic, a confusion, a decision) they want distilled. Read the source files only after they say what they're after. + +2. **When you read, read with a filter.** A distill loop's failure mode is "produce a summary" — that's useless. Look specifically for: + - **conventions** the team should follow (something the source loop established by trial) + - **gotchas / non-obvious facts** about the codebase, infra, tools + - **decisions** made and *why* (the why is what rots fastest in memory) + - **references** worth knowing (a Linear board, a Grafana dashboard, an internal URL) + - **reusable mechanisms** that surfaced (a script, a query, a regex) + + Skip tactical noise: the back-and-forth of getting a build green, transient errors, things specific to that loop's task. + +3. **Propose sedimentation forms, plural.** Not everything wants to be a knowledge md. Be specific about *which file* and *what shape*: + + - `knowledge/<topic>.md` — a doc page when the insight is reference material a human will read + - `knowledge/.loopat/.claude/skills/<name>/SKILL.md` — when the insight is a repeatable workflow Claude should invoke (e.g. "deploy-to-staging", "investigate-latency-spike"). See existing skills in that dir for shape. + - `knowledge/.loopat/.claude/settings.json` (`mcpServers` key) — when the insight is "we should be talking to service X via MCP" + - `knowledge/.loopat/.claude/CLAUDE.md` (the team supplement) — when the insight is a convention every loop should know + - `knowledge/.loopat/profiles/<role>/.claude/...` — same shapes as above but scoped to a profile (role / mode) rather than the whole team + - sometimes the answer is "nothing yet — this doesn't generalize" — say so, don't manufacture an entry + +4. **Draft, then check in.** Propose specific files + specific content. Show the user the proposed text. Let them tweak. *Then* commit + push via the worktree publish workflow. + +5. **Don't edit the source files.** `workdir/source/*.jsonl` are your input, not your draft area. Put drafts in `workdir/` or directly under `knowledge/` (then publish). + +## What NOT to do + +- Don't produce a generic "summary of the conversation" unless the user explicitly asks for one. Distillation ≠ summarization. +- Don't sediment things the original loop merely *did*; sediment things *worth doing again the same way*. +- Don't promote an insight without a concrete file path + content sketch. "We could document the deploy process" is not a proposal; "Add `knowledge/deploy.md` with these three sections: …" is. diff --git a/server/templates/plugins/loopat/.claude-plugin/plugin.json b/server/templates/plugins/loopat/.claude-plugin/plugin.json new file mode 100644 index 00000000..1bbe2e38 --- /dev/null +++ b/server/templates/plugins/loopat/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "loopat", + "description": "Built-in loopat platform skills (promote, help)", + "version": "0.2.0" +} diff --git a/server/templates/plugins/loopat/skills/promote/SKILL.md b/server/templates/plugins/loopat/skills/promote/SKILL.md new file mode 100644 index 00000000..c14b9278 --- /dev/null +++ b/server/templates/plugins/loopat/skills/promote/SKILL.md @@ -0,0 +1,53 @@ +--- +description: Promote this loop's context into shared consensus — the ② edge of docs/context-flow.md. Use when work in a context worktree (notes / knowledge / personal / a repo workdir) is worth sharing, or when the user says promote / share this / publish / sync up / 发布 / 同步 / 合并上去. You merge the latest and push to main (or open a PR for gated context), resolving any conflict three-way yourself — that is the point: the loop's own AI resolves conflicts, nothing else does. +--- + +# promote — share a loop's context + +Promote moves what's worth keeping from this loop into shared `main`. It is +**deliberate** — do it when the work is genuinely worth sharing, not on every +turn. You run plain git; if the merge conflicts, you resolve it yourself (you +are the merge agent — no other agent, no script). + +## Steps + +`cd` into the worktree you want to promote — `/loopat/context/notes`, +`/loopat/context/knowledge`, `/loopat/context/personal`, or a repo workdir — +then capture your work and merge the latest consensus: + +```sh +git add -A && git commit -m "<what you're sharing>" +git fetch origin +git merge origin/main +``` + +**If the merge conflicts**, resolve it now, here: +- Edit each conflicted file; reconcile the `<<<<<<< ======= >>>>>>>` markers by + **keeping both sides' meaning** — this is notes/knowledge, so merge the + information, don't drop a side. +- `git add` the resolved files, then `git commit` to finish the merge. +- (`git merge --abort` backs out cleanly.) + +Then push: + +```sh +# ungated — notes · personal — straight into main: +git push origin HEAD:main + +# gated — knowledge · repos — open a PR instead: +git push origin HEAD +gh pr create --base main --head "$(git symbolic-ref --short HEAD)" --fill +``` + +If `git push` is **rejected** (`non-fast-forward` — `main` moved while you +worked), re-run `git merge origin/main`, resolve again, push again. It converges. + +## Rules + +- Always **merge, never rebase** — both parents survive, so a bad merge is + revertible. +- Resolve conflicts **here, yourself** — never hand off to another agent. +- `notes` / `personal` push straight to `main`; `knowledge` / repos are gated — + open a PR. (Gating is the team's choice; default for knowledge/repos = PR.) +- Trunk is `main` (your runtime context block names it if it ever differs). +- Solo works the same: `origin` is just a loopat-hosted local repo — same commands. diff --git a/server/templates/sandbox/Containerfile b/server/templates/sandbox/Containerfile new file mode 100644 index 00000000..96e7fdcd --- /dev/null +++ b/server/templates/sandbox/Containerfile @@ -0,0 +1,132 @@ +# loopat sandbox base image. +# +# The base is a "full-stack" interactive workbench: the user can apt +# install, git, make, curl, etc. inside the loop without leaving. Per-loop +# child images bake mise-managed toolchains on top of this base (see +# `ensureLoopImage` in server/src/podman.ts). +# +# Built once at first launch (or via scripts/build-sandbox-image.sh) and +# tagged `loopat-sandbox:latest`. Each loop's container is `podman +# create`'d from this image — or, when the loop has a composed mise.toml, +# from a per-loop child `loopat-sandbox-<hash>:latest`. +# +# Base: Ubuntu 24.04 so glibc matches the dev hosts (we use Ubuntu 24.04). +# The host's claude binary binds in via --volume and runs against this glibc. +FROM ac2-registry.cn-hangzhou.cr.aliyuncs.com/ac2/base:ubuntu24.04 + +ARG DEBIAN_FRONTEND=noninteractive + +USER root + +# Strip the image's default `ubuntu` user (uid 1000) and create our own +# fixed `loopat` user at uid 2000. The container then ALWAYS appears as +# `loopat` to whoever's inside, regardless of which host user is running +# rootless podman — see `--userns=keep-id:uid=2000,gid=2000` in podman.ts. +# Why uid 2000: avoid colliding with 1000 (still common host uid; the +# original ubuntu user previously claimed it inside this image). +# +# Why not root: the claude binary refuses --dangerously-skip-permissions +# when uid == 0 ("for security reasons"). The SDK driver always uses +# bypassPermissions in loopat, so container-root is untenable. +# +# Full-stack feel: sudo NOPASSWD for loopat → `sudo apt install <pkg>` +# from any shell, no prompts. +RUN userdel -r ubuntu 2>/dev/null || true \ + && groupadd -g 2000 loopat \ + && useradd -m -u 2000 -g 2000 -s /bin/bash loopat + +# The AC2 base ships apt sources pointing at mirrors.cloud.aliyuncs.com +# (100.100.x.x — Aliyun *intranet* only, unreachable outside ECS). Repoint to +# the public mirrors.aliyun.com; the per-arch path (ubuntu / ubuntu-ports) is +# preserved, so this works on both amd64 and arm64. +RUN sed -i 's|mirrors.cloud.aliyuncs.com|mirrors.aliyun.com|g' \ + /etc/apt/sources.list.d/*.sources /etc/apt/sources.list 2>/dev/null || true \ + && printf 'Acquire::Retries "5";\n' > /etc/apt/apt.conf.d/80-loopat-retries + +# Full-stack system layer. Everything a dev reflexively reaches for. +# podman/uidmap/fuse-overlayfs/slirp4netns: nested rootless podman support +# — every sandbox can run podman without per-loop opt-in. The sandbox's +# create-args carry `--privileged --device /dev/fuse`, and inner loopat +# (or any AI workflow) probes for podman at runtime via the existing +# self-detection path. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash coreutils findutils grep sed less \ + util-linux procps bsdmainutils ca-certificates \ + sudo curl git build-essential openssh-client \ + jq vim fish python3-minimal \ + podman uidmap fuse-overlayfs slirp4netns \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Passwordless sudo for the loopat user. +RUN echo "loopat ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/loopat \ + && chmod 0440 /etc/sudoers.d/loopat + +# Nested rootless podman setup for `loopat` (uid 2000). +# +# Why the unusual subuid range (10000:50000, not the typical 100000:65536): +# the OUTER container's userns size is bounded by the host user's subuid +# allocation (typically 65536). With `--userns=keep-id:uid=2000,gid=2000`, +# uids 0..65535 are mapped inside; uid 100000 simply doesn't exist in +# this namespace, so newuidmap rejects it. Inner subuid must be a SUBSET +# of the outer-mapped range. 10000:50000 stays within 0..65535 and avoids +# clashing with uid 2000 (loopat itself). +# +# Storage driver `vfs`: rootless-in-rootless overlayfs is fragile across +# kernel/podman/storage combinations. vfs always works (cost: disk space +# proportional to layers × containers). Switch to fuse-overlayfs later if +# disk pressure shows up. +# +# Cgroup manager `cgroupfs`: there's no systemd inside the sandbox, so +# the systemd cgroup driver can't be used. +# NOTE on `>`: Ubuntu's useradd auto-allocates `loopat:100000:65536` via +# /etc/login.defs defaults. Appending our entry leaves both, and rootless +# podman tries to apply BOTH ranges — the 100000 one falls outside outer's +# 0..65535 view and newuidmap fails. Overwrite, don't append. +RUN echo "loopat:10000:50000" > /etc/subuid \ + && echo "loopat:10000:50000" > /etc/subgid \ + && mkdir -p /home/loopat/.config/containers \ + && printf '[storage]\ndriver = "vfs"\n' > /home/loopat/.config/containers/storage.conf \ + && printf '[containers]\ncgroup_manager = "cgroupfs"\n' > /home/loopat/.config/containers/containers.conf \ + && printf 'unqualified-search-registries = ["docker.io"]\n' > /home/loopat/.config/containers/registries.conf \ + && chown -R loopat:loopat /home/loopat/.config + +# mise — tool version manager. Per-loop child images bake their tools via +# `mise install` during their build, so the toolchain ends up baked into +# image layers and reused across loops with the same composed mise.toml. +# Note: mise.run's installer reads MISE_INSTALL_PATH (full path to the +# binary), NOT a "dir" env var. +RUN curl -fsSL https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh + +# mise state lives OUTSIDE $HOME so the per-loop $HOME overlay (used for +# persistent shell history) can't shadow the tools the image bakes in. +# Shims dir on PATH makes every tool globally callable without needing +# `mise activate` in each shell. +ENV MISE_DATA_DIR=/opt/loopat-mise \ + MISE_CONFIG_DIR=/opt/loopat-mise/config \ + MISE_CACHE_DIR=/opt/loopat-mise/cache \ + PATH=/opt/loopat-mise/shims:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +RUN mkdir -p /opt/loopat-mise/shims /opt/loopat-mise/config /opt/loopat-mise/cache \ + && chown -R loopat:loopat /opt/loopat-mise \ + && chmod -R 755 /opt/loopat-mise + +# Native host-cli forwarder. The sandbox's built-in way to run a host-only cli +# (macOS-only, or a machine-bound company cli) on behalf of the loop: +# shim(<cli>) → loopat-host → mounted unix socket → host execFile +# This forwarder is generic (same for every loop), so it's baked into the image. +# Per-cli shims are generated per-loop from the loop's mise.toml `[host].clis` +# and COPY'd into the mise shims dir (already first on PATH) by the per-loop +# child build (ensureLoopImage). The socket dir is mounted in and the +# LOOPAT_HOST_SOCK / LOOPAT_LOOP_ID env vars are injected at container create. +COPY loopat-host /usr/local/bin/loopat-host +RUN chmod 0755 /usr/local/bin/loopat-host + +# Switch to loopat. mise install (per-loop child build) and runtime +# exec'd processes all run as this user. +USER loopat + +# The container's main process is just a long-lived sleeper holding the +# namespaces open. SDK driver + PTY shell `podman exec` in as siblings. +CMD ["/bin/sleep", "infinity"] diff --git a/server/templates/sandbox/loopat-host b/server/templates/sandbox/loopat-host new file mode 100644 index 00000000..03dca9d2 --- /dev/null +++ b/server/templates/sandbox/loopat-host @@ -0,0 +1,28 @@ +#!/bin/sh +# loopat-host <cli> [args...] — run <cli> on the HOST, on behalf of this loop, +# via the mounted unix socket. The sandbox can't run host-only clis (macOS +# tools, machine-bound company clis), so a shim hands off to this forwarder. +# +# Injected into the sandbox: +# LOOPAT_HOST_SOCK — path to the mounted host-exec socket +# LOOPAT_LOOP_ID — this loop's id (server uses it to pick the host workdir) +# +# POC notes: args are JSON-encoded naively (assumes no embedded quotes / +# backslashes); stdout/stderr come back base64 so they're safe to pull out of +# the JSON with grep. A hardened version would stream and length-prefix. +cli="$1"; shift +args="" +for a in "$@"; do args="$args\"$a\","; done +body="{\"loopId\":\"$LOOPAT_LOOP_ID\",\"cli\":\"$cli\",\"args\":[${args%,}]}" + +resp=$(curl -fsS --unix-socket "$LOOPAT_HOST_SOCK" -X POST http://localhost/host-exec \ + -H "content-type: application/json" -d "$body") \ + || { echo "loopat-host: cannot reach host socket" >&2; exit 127; } + +printf '%s' "$resp" | grep -o '"stdout_b64":"[^"]*"' | sed 's/.*"stdout_b64":"//; s/"$//' | base64 -d 2>/dev/null +printf '%s' "$resp" | grep -o '"stderr_b64":"[^"]*"' | sed 's/.*"stderr_b64":"//; s/"$//' | base64 -d 2>/dev/null >&2 + +err=$(printf '%s' "$resp" | grep -o '"error":"[^"]*"' | sed 's/.*"error":"//; s/"$//') +if [ -n "$err" ]; then echo "loopat-host: $err" >&2; exit 126; fi + +exit "$(printf '%s' "$resp" | grep -o '"exitCode":[0-9]*' | grep -o '[0-9]*' || echo 0)" diff --git a/server/test/api-e2e/api-edges.test.ts b/server/test/api-e2e/api-edges.test.ts new file mode 100644 index 00000000..f8cc12f6 --- /dev/null +++ b/server/test/api-e2e/api-edges.test.ts @@ -0,0 +1,172 @@ +/** + * L4 api-e2e: v1 API edge behaviors that touch SSE/state but aren't + * covered by the unit suite (api-v1.test.ts) because they need a real + * SDK pipeline. + * + * T1 POST /interrupt mid-tool → SSE stream ends with `interrupted` + * T2 DELETE archives → next POST /messages returns 400 loop_archived + * T3 GET /events viewer running parallel to POST /messages → both + * streams observe assistant_delta from the same turn + * T4 Idempotency replay: 2nd POST with same Idempotency-Key returns the + * buffered events; mock API is NOT invoked a second time for that turn + */ +import { test, expect, describe, afterAll } from "bun:test" +import { + podmanAvailable, + mock, + blocks, + lastIsToolResult, + createLoop, + sendMessage, + readSSE, + readUntilTurnEnds, + interrupt, + archive, + eventsStream, + dumpEvents, + teardownAll, +} from "./helpers" + +afterAll(teardownAll) + +describe.skipIf(!podmanAvailable)("api-e2e: v1 edges", () => { + test("POST /interrupt mid-tool emits an `interrupted` SSE event", async () => { + const loopId = await createLoop({ title: "edge-interrupt" }) + + mock.register({ + marker: "[[edge-interrupt]]", + *respond(req) { + if (!lastIsToolResult(req)) { + // Long-running Bash; the test will interrupt during this. + yield blocks.bash("sleep 15") + yield blocks.endTool() + } else { + yield blocks.text("recovered (should not be reached)") + yield blocks.endTurn() + } + }, + }) + + const send = await sendMessage(loopId, "please [[edge-interrupt]] (slow)") + expect(send.status).toBe(200) + + // Drive the SSE reader and the interrupt POST concurrently. The reader + // races against a deadline; we POST /interrupt as soon as we see + // `tool_call` (proves we interrupted *during* the bash). + const eventsPromise = readSSE(send, { + until: (ev) => ev.event === "done" || ev.event === "error" || ev.event === "interrupted", + timeoutMs: 30_000, + }) + + // Wait for the tool to actually start, then trigger interrupt. + // Tiny polling loop watching mock.requests as a proxy for "CC connected". + let sentInterrupt = false + for (let attempt = 0; attempt < 50; attempt++) { + if (mock.hits("[[edge-interrupt]]") >= 1) { + // Give CC ~250ms to fire the Bash + start sleeping. + await new Promise((r) => setTimeout(r, 300)) + await interrupt(loopId) + sentInterrupt = true + break + } + await new Promise((r) => setTimeout(r, 100)) + } + expect(sentInterrupt).toBe(true) + + const events = await eventsPromise + const seenInterrupted = events.some((e) => e.event === "interrupted") + if (!seenInterrupted) dumpEvents(events) + expect(seenInterrupted).toBe(true) + }, 60_000) + + test("after DELETE, POST /messages returns 400 loop_archived", async () => { + const loopId = await createLoop({ title: "edge-archive" }) + const del = await archive(loopId) + expect(del.status).toBe(204) + + const send = await sendMessage(loopId, "won't matter") + expect(send.status).toBe(400) + const body = (await send.json()) as { error: { code: string } } + expect(body.error.code).toBe("loop_archived") + }, 30_000) + + test("GET /events viewer sees the same assistant_delta as POST /messages", async () => { + const loopId = await createLoop({ title: "edge-viewer" }) + + mock.register({ + marker: "[[edge-viewer]]", + *respond() { + yield blocks.text("viewer-watch-this-text") + yield blocks.endTurn() + }, + }) + + // Open the read-only viewer FIRST — server will hold it open and + // forward events as the turn fires. + const viewerResp = await eventsStream(loopId) + expect(viewerResp.status).toBe(200) + + const viewerEventsPromise = readSSE(viewerResp, { + until: (ev) => ev.event === "done" || ev.event === "error", + timeoutMs: 60_000, + }) + + // Drive the turn. + const send = await sendMessage(loopId, "please [[edge-viewer]] now") + const sendEvents = await readUntilTurnEnds(send, 60_000) + const viewerEvents = await viewerEventsPromise + + const seenInSend = sendEvents.some( + (e) => e.event === "assistant_delta" && JSON.stringify(e.data).includes("viewer-watch-this-text"), + ) + const seenInViewer = viewerEvents.some( + (e) => e.event === "assistant_delta" && JSON.stringify(e.data).includes("viewer-watch-this-text"), + ) + if (!seenInSend) { + console.error("send events:") + dumpEvents(sendEvents) + } + if (!seenInViewer) { + console.error("viewer events:") + dumpEvents(viewerEvents) + } + expect(seenInSend).toBe(true) + expect(seenInViewer).toBe(true) + }, 90_000) + + test("Idempotency-Key replay: second POST replays events, mock NOT invoked twice", async () => { + const loopId = await createLoop({ title: "edge-idem" }) + const key = `idem-${Date.now()}` + + mock.register({ + marker: "[[edge-idem]]", + *respond() { + yield blocks.text("idem-result-text") + yield blocks.endTurn() + }, + }) + + // First send completes a turn. + const first = await sendMessage(loopId, "please [[edge-idem]] once", { idempotencyKey: key }) + const firstEvents = await readUntilTurnEnds(first, 60_000) + expect(firstEvents.some((e) => e.event === "done")).toBe(true) + const hitsAfterFirst = mock.hits("[[edge-idem]]") + expect(hitsAfterFirst).toBeGreaterThan(0) + + // Second send with SAME key + body — server should replay the buffered + // events without calling the model again. + const second = await sendMessage(loopId, "please [[edge-idem]] once", { idempotencyKey: key }) + expect(second.status).toBe(200) + const secondEvents = await readUntilTurnEnds(second, 60_000) + expect(secondEvents.some((e) => e.event === "done")).toBe(true) + const sawText = secondEvents.some( + (e) => e.event === "assistant_delta" && JSON.stringify(e.data).includes("idem-result-text"), + ) + if (!sawText) dumpEvents(secondEvents) + expect(sawText).toBe(true) + + // Critically: mock was not called a second time. + const hitsAfterSecond = mock.hits("[[edge-idem]]") + expect(hitsAfterSecond).toBe(hitsAfterFirst) + }, 90_000) +}) diff --git a/server/test/api-e2e/choice-flow.test.ts b/server/test/api-e2e/choice-flow.test.ts new file mode 100644 index 00000000..51c5cd02 --- /dev/null +++ b/server/test/api-e2e/choice-flow.test.ts @@ -0,0 +1,231 @@ +/** + * L4 api-e2e: choice-flow — the user-pause-the-loop interactions. + * + * T1 Permission allow: user msg with permission_mode="default" forces + * prompts; mock asks for Bash; SSE `requires_choice kind=permission`; + * answer { allow:true }; loop continues; SSE `choice_resolved` + done + * T2 Permission deny: same setup but answer { allow:false }; SDK + * turns the deny into a `tool_result is_error:true`; mock branches + * on that and emits a fallback text; turn ends with done (NOT + * interrupted) per session.ts:1243 + * T3 AskUserQuestion: mock emits the AskUserQuestion tool_use with a + * multi-option question; SSE `requires_choice kind=question`; + * answer { answers: { q1: "B" } }; mock's next round sees the + * answer in lastToolResultText and emits a confirmation text + * + * Per-turn `permission_mode` override is implicitly covered by T1/T2: + * loop's default mode is bypassPermissions, so the requires_choice only + * fires because the POST body said permission_mode="default" for that turn. + */ +import { test, expect, describe } from "bun:test" +import { + podmanAvailable, + mock, + blocks, + lastIsToolResult, + lastToolResultText, + createLoop, + sendMessage, + sseEvents, + answerChoice, + dumpEvents, + type SSEEvent, +} from "./helpers" + +/** + * Pull from the generator until `predicate(ev)` matches, or the stream + * ends. Uses manual `next()` — NOT `for await ... break` — because in an + * async generator, breaking out triggers `.return()` which cancels the + * underlying SSE reader. We need to be able to come back and read more + * from the same stream after the test does a POST. + */ +async function readUntil( + gen: AsyncGenerator<SSEEvent>, + predicate: (ev: SSEEvent) => boolean, + bag: SSEEvent[], +): Promise<SSEEvent | null> { + while (true) { + const next = await gen.next() + if (next.done) return null + bag.push(next.value) + if (predicate(next.value)) return next.value + } +} + +describe.skipIf(!podmanAvailable)("api-e2e: choice flow", () => { + test("permission allow → choice_resolved → tool runs → done", async () => { + const loopId = await createLoop({ title: "perm-allow" }) + mock.register({ + marker: "[[perm-allow]]", + *respond(req) { + if (!lastIsToolResult(req)) { + // Bash isn't in SAFE_TOOLS, so default permission_mode prompts. + yield blocks.bash("printf perm-allowed > perm-allow.txt") + yield blocks.endTool() + } else { + yield blocks.text("did it") + yield blocks.endTurn() + } + }, + }) + + const send = await sendMessage(loopId, "please [[perm-allow]] write", { + permission_mode: "default", + }) + expect(send.status).toBe(200) + + const events: SSEEvent[] = [] + const gen = sseEvents(send, { timeoutMs: 60_000 }) + + const reqChoice = await readUntil(gen, (ev) => ev.event === "requires_choice", events) + if (!reqChoice) dumpEvents(events) + expect(reqChoice).not.toBeNull() + expect(reqChoice!.data.kind).toBe("permission") + expect(reqChoice!.data.choice_id).toMatch(/^choice_/) + + const ans = await answerChoice(loopId, reqChoice!.data.choice_id, { allow: true }) + expect(ans.status).toBe(202) + + const endEv = await readUntil( + gen, + (ev) => ev.event === "done" || ev.event === "error", + events, + ) + await gen.return(undefined as any) + if (!endEv || endEv.event !== "done") dumpEvents(events) + expect(endEv?.event).toBe("done") + expect(events.some((e) => e.event === "choice_resolved")).toBe(true) + }, 90_000) + + test("permission deny → tool_result is_error → mock falls back → done (no interrupted)", async () => { + const loopId = await createLoop({ title: "perm-deny" }) + mock.register({ + marker: "[[perm-deny]]", + *respond(req) { + if (!lastIsToolResult(req)) { + yield blocks.bash("touch should-not-exist.txt") + yield blocks.endTool() + } else { + // Branch on what came back from the SDK after deny. + const last = lastToolResultText(req) + if (/User denied permission/.test(last)) { + yield blocks.text("user-denied-fallback") + yield blocks.endTurn() + } else { + yield blocks.text("unexpected: " + last.slice(0, 80)) + yield blocks.endTurn() + } + } + }, + }) + + const send = await sendMessage(loopId, "please [[perm-deny]] write", { + permission_mode: "default", + }) + expect(send.status).toBe(200) + + const events: SSEEvent[] = [] + const gen = sseEvents(send, { timeoutMs: 60_000 }) + + const reqChoice = await readUntil(gen, (ev) => ev.event === "requires_choice", events) + expect(reqChoice).not.toBeNull() + expect(reqChoice!.data.kind).toBe("permission") + + const ans = await answerChoice(loopId, reqChoice!.data.choice_id, { allow: false }) + expect(ans.status).toBe(202) + + const endEv = await readUntil( + gen, + (ev) => ev.event === "done" || ev.event === "error" || ev.event === "interrupted", + events, + ) + await gen.return(undefined as any) + if (!endEv || endEv.event !== "done") dumpEvents(events) + // Spec semantics confirmed via session.ts:1243: deny is a tool error, + // turn does NOT become `interrupted`. + expect(endEv?.event).toBe("done") + + // mock must have seen the deny in tool_result, branched to fallback + const seenFallback = events.some( + (e) => e.event === "assistant_delta" && JSON.stringify(e.data).includes("user-denied-fallback"), + ) + if (!seenFallback) dumpEvents(events) + expect(seenFallback).toBe(true) + }, 90_000) + + test("AskUserQuestion → answer → mock confirms choice in next assistant text", async () => { + const loopId = await createLoop({ title: "question-flow" }) + mock.register({ + marker: "[[q-flow]]", + *respond(req) { + if (!lastIsToolResult(req)) { + // AskUserQuestion is caught by session.ts:520 BEFORE the + // SAFE_TOOLS / permission_mode branches, so it always prompts. + yield blocks.toolUse("AskUserQuestion", { + questions: [ + { + question: "Which option?", + header: "Pick", + multiSelect: false, + options: [ + { label: "Alpha", description: "first" }, + { label: "Bravo", description: "second" }, + ], + }, + ], + }) + yield blocks.endTool() + } else { + const last = lastToolResultText(req) + // The tool_result carries the answers map session.ts wrote back + // into updatedInput; we just look for the picked label. + if (last.includes("Bravo")) { + yield blocks.text("user picked Bravo") + } else if (last.includes("Alpha")) { + yield blocks.text("user picked Alpha") + } else { + yield blocks.text("unexpected answer: " + last.slice(0, 80)) + } + yield blocks.endTurn() + } + }, + }) + + const send = await sendMessage(loopId, "please [[q-flow]] ask me") + expect(send.status).toBe(200) + + const events: SSEEvent[] = [] + const gen = sseEvents(send, { timeoutMs: 60_000 }) + + const reqChoice = await readUntil(gen, (ev) => ev.event === "requires_choice", events) + expect(reqChoice).not.toBeNull() + expect(reqChoice!.data.kind).toBe("question") + const questions = reqChoice!.data.payload.questions + expect(Array.isArray(questions)).toBe(true) + expect(questions.length).toBeGreaterThan(0) + + // Answer it. The API spec is `{ answers: { <question_id>: <value> } }`. + // AskUserQuestion doesn't always carry a stable q-id; use whatever the + // payload provided, fall back to index-keyed. + const qId = questions[0].id ?? questions[0].question ?? "q0" + const ans = await answerChoice(loopId, reqChoice!.data.choice_id, { + answers: { [qId]: "Bravo" }, + }) + expect(ans.status).toBe(202) + + const endEv = await readUntil( + gen, + (ev) => ev.event === "done" || ev.event === "error", + events, + ) + await gen.return(undefined as any) + if (!endEv || endEv.event !== "done") dumpEvents(events) + expect(endEv?.event).toBe("done") + + const sawBravo = events.some( + (e) => e.event === "assistant_delta" && JSON.stringify(e.data).includes("user picked Bravo"), + ) + if (!sawBravo) dumpEvents(events) + expect(sawBravo).toBe(true) + }, 90_000) +}) diff --git a/server/test/api-e2e/cross-surface.test.ts b/server/test/api-e2e/cross-surface.test.ts new file mode 100644 index 00000000..de5094c7 --- /dev/null +++ b/server/test/api-e2e/cross-surface.test.ts @@ -0,0 +1,209 @@ +/** + * L4 api-e2e: bidirectional visibility between CC (via Bash tool) and the + * sandbox probe (via podman exec). This is the test set that validates the + * "loop = shared sandbox" invariant — what one channel writes the other + * channel sees, including long-lived background processes. + * + * T1 cc writes a file via Bash → probe reads it (mirror of file-roundtrip + * but explicit about the cross-surface property) + * T2 probe writes a file → cc reads it via Bash; SSE assistant text + * contains the content the probe wrote + * T3 cc starts an HTTP server via Bash run_in_background:true → probe + * `curl localhost:<port>` returns 200; the bg process really runs + * inside CC's tool dispatcher + * T4 while T3's bg server is still up, a second POST /messages on the + * same loop completes normally — proves loop session is not blocked + * by an outstanding background tool task + */ +import { test, expect, describe, afterAll } from "bun:test" +import { + podmanAvailable, + mock, + blocks, + lastIsToolResult, + lastToolResultText, + createLoop, + sendMessage, + readUntilTurnEnds, + inSandbox, + workdirInSandbox, + ensureSandbox, + sleep, + dumpEvents, + teardownAll, +} from "./helpers" + +afterAll(teardownAll) + +describe.skipIf(!podmanAvailable)("api-e2e: cross-surface", () => { + test("cc writes a file via Bash; probe reads identical content", async () => { + const loopId = await createLoop({ title: "xs-write-then-read" }) + mock.register({ + marker: "[[xs-write]]", + *respond(req) { + if (!lastIsToolResult(req)) { + yield blocks.bash("printf cc-wrote-this > xs-w.txt") + yield blocks.endTool() + } else { + yield blocks.text("written") + yield blocks.endTurn() + } + }, + }) + const send = await sendMessage(loopId, "please [[xs-write]] now") + const events = await readUntilTurnEnds(send) + expect(events.some((e) => e.event === "error")).toBe(false) + expect(events.some((e) => e.event === "done")).toBe(true) + + const probe = await inSandbox(loopId, `cat ${workdirInSandbox(loopId)}/xs-w.txt`) + expect(probe.code).toBe(0) + expect(probe.stdout).toBe("cc-wrote-this") + }, 90_000) + + test("probe writes a file; cc reads it via Bash tool, content flows back through SSE", async () => { + const loopId = await createLoop({ title: "xs-probe-then-cc" }) + await ensureSandbox(loopId) + // Pre-seed the workdir from the host side. + const preseed = await inSandbox( + loopId, + `mkdir -p ${workdirInSandbox(loopId)} && printf probe-seeded-content > ${workdirInSandbox(loopId)}/probe.txt`, + ) + expect(preseed.code).toBe(0) + + mock.register({ + marker: "[[xs-read]]", + *respond(req) { + if (!lastIsToolResult(req)) { + yield blocks.bash(`cat ${workdirInSandbox(loopId)}/probe.txt`) + yield blocks.endTool() + } else { + yield blocks.text("File content: " + lastToolResultText(req).trim()) + yield blocks.endTurn() + } + }, + }) + + const send = await sendMessage(loopId, "[[xs-read]] please") + const events = await readUntilTurnEnds(send) + const failed = events.filter((e) => e.event === "error" || e.event === "interrupted") + if (failed.length) dumpEvents(events) + expect(failed).toEqual([]) + + const seen = events.some( + (e) => + e.event === "assistant_delta" && + typeof e.data?.text === "string" && + e.data.text.includes("probe-seeded-content"), + ) + if (!seen) dumpEvents(events) + expect(seen).toBe(true) + }, 90_000) + + test("cc starts a backgrounded shell loop; probe observes it ticking", async () => { + // The sandbox image is minimal (no python/curl/nc), so use a pure-bash + // heartbeat-file pattern instead of a TCP server. The semantics we care + // about are identical: run_in_background launches a long-lived child + // that keeps writing while CC moves on. + const loopId = await createLoop({ title: "xs-bg-tick" }) + const wd = workdirInSandbox(loopId) + mock.register({ + marker: "[[xs-tick]]", + *respond(req) { + if (!lastIsToolResult(req)) { + yield blocks.bash( + `cd ${wd} && (while true; do date +%s%N >> heartbeat.log; sleep 0.1; done)`, + { run_in_background: true }, + ) + yield blocks.endTool() + } else { + yield blocks.text("bg ticker started") + yield blocks.endTurn() + } + }, + }) + + const send = await sendMessage(loopId, "please [[xs-tick]] now") + const events = await readUntilTurnEnds(send, 60_000) + const failed = events.filter((e) => e.event === "error" || e.event === "interrupted") + if (failed.length) dumpEvents(events) + expect(failed).toEqual([]) + expect(events.some((e) => e.event === "done")).toBe(true) + + // Observe ticking: read the line count, wait, read again, assert growth. + let n1 = 0 + for (let attempt = 0; attempt < 10; attempt++) { + await sleep(300) + const p = await inSandbox(loopId, `wc -l < ${wd}/heartbeat.log 2>/dev/null || echo 0`) + n1 = parseInt(p.stdout.trim() || "0", 10) + if (n1 > 0) break + } + expect(n1).toBeGreaterThan(0) + await sleep(500) + const p2 = await inSandbox(loopId, `wc -l < ${wd}/heartbeat.log`) + const n2 = parseInt(p2.stdout.trim() || "0", 10) + expect(n2).toBeGreaterThan(n1) + }, 90_000) + + test("loop accepts another message while a background tool task is outstanding", async () => { + const loopId = await createLoop({ title: "xs-bg-then-ping" }) + const wd = workdirInSandbox(loopId) + + mock.register({ + marker: "[[xs-start-bg]]", + *respond(req) { + if (!lastIsToolResult(req)) { + yield blocks.bash(`cd ${wd} && (while true; do echo tick >> bg.log; sleep 0.2; done)`, { + run_in_background: true, + }) + yield blocks.endTool() + } else { + yield blocks.text("bg up") + yield blocks.endTurn() + } + }, + }) + + // Message 1 — start the background ticker. + let send = await sendMessage(loopId, "[[xs-start-bg]] please") + let events = await readUntilTurnEnds(send) + expect(events.some((e) => e.event === "done")).toBe(true) + expect(events.some((e) => e.event === "error")).toBe(false) + + // Confirm bg is actually ticking before sending the next message. + let alive = false + for (let attempt = 0; attempt < 10; attempt++) { + await sleep(300) + const p = await inSandbox(loopId, `wc -l < ${wd}/bg.log 2>/dev/null || echo 0`) + if (parseInt(p.stdout.trim() || "0", 10) > 0) { + alive = true + break + } + } + expect(alive).toBe(true) + + // Now register a "ping" scenario AFTER xs-start-bg. LIFO: ping wins on + // matching second message even though the conversation history still + // contains [[xs-start-bg]]. + mock.register({ + marker: "[[xs-ping]]", + *respond() { + yield blocks.text("pong") + yield blocks.endTurn() + }, + }) + + send = await sendMessage(loopId, "[[xs-ping]] are you there?") + events = await readUntilTurnEnds(send) + const failed = events.filter((e) => e.event === "error" || e.event === "interrupted") + if (failed.length) dumpEvents(events) + expect(failed).toEqual([]) + expect(events.some((e) => e.event === "done")).toBe(true) + + const sawPong = events.some( + (e) => + e.event === "assistant_delta" && typeof e.data?.text === "string" && e.data.text.includes("pong"), + ) + if (!sawPong) dumpEvents(events) + expect(sawPong).toBe(true) + }, 120_000) +}) diff --git a/server/test/api-e2e/file-roundtrip.test.ts b/server/test/api-e2e/file-roundtrip.test.ts new file mode 100644 index 00000000..ddb26830 --- /dev/null +++ b/server/test/api-e2e/file-roundtrip.test.ts @@ -0,0 +1,122 @@ +/** + * L4 api-e2e: file roundtrip via Bash tool_use. + * + * - cc creates a file via Bash → sandbox probe reads it back + * - across two POST /messages: turn 1 creates file, turn 2 lists files; + * turn 2's tool_result demonstrably contains the file from turn 1 + * + * These are the cases that prove CC's real tool dispatch is wired through: + * the mock just *says* "use Bash with this command"; the actual side-effect + * comes from CC executing that Bash inside the podman container. + */ +import { test, expect, describe, afterAll } from "bun:test" +import { + podmanAvailable, + mock, + blocks, + lastIsToolResult, + lastToolResultText, + createLoop, + sendMessage, + readUntilTurnEnds, + inSandbox, + workdirInSandbox, + dumpEvents, + teardownAll, +} from "./helpers" + +afterAll(teardownAll) + +describe.skipIf(!podmanAvailable)("api-e2e: file roundtrip", () => { + test("cc creates a file via Bash; sandbox probe sees it", async () => { + const loopId = await createLoop({ title: "file-create" }) + + mock.register({ + marker: "[[create-foo]]", + *respond(req) { + if (!lastIsToolResult(req)) { + yield blocks.text("Creating foo.txt.") + // cwd is workdir already (V_LOOP_WORKDIR), relative path is fine + yield blocks.bash("printf 'hello-from-cc' > foo.txt") + yield blocks.endTool() + } else { + yield blocks.text("Created.") + yield blocks.endTurn() + } + }, + }) + + const send = await sendMessage(loopId, "please [[create-foo]] in the workdir") + expect(send.status).toBe(200) + const events = await readUntilTurnEnds(send, 60_000) + const failed = events.filter((e) => e.event === "error" || e.event === "interrupted") + if (failed.length) dumpEvents(events) + expect(failed).toEqual([]) + expect(events.some((e) => e.event === "done")).toBe(true) + + // Sandbox probe — bypass everything, look directly at what's in the + // container's workdir. This is what makes the test "really e2e": + // CC's Bash tool ran inside the container, the file is real. + const probe = await inSandbox(loopId, `cat ${workdirInSandbox(loopId)}/foo.txt`) + expect(probe.code).toBe(0) + expect(probe.stdout).toBe("hello-from-cc") + }, 90_000) + + test("multi-message: create file in msg 1, list files in msg 2; second response includes the file", async () => { + const loopId = await createLoop({ title: "file-roundtrip" }) + + // First scenario — create a uniquely-named file. Marker is unique to + // this test so it doesn't cross-fire with other tests. + mock.register({ + marker: "[[mt-create]]", + *respond(req) { + if (!lastIsToolResult(req)) { + yield blocks.bash("touch mt-marker.txt && echo done > mt-marker.txt") + yield blocks.endTool() + } else { + yield blocks.text("File created.") + yield blocks.endTurn() + } + }, + }) + + let send = await sendMessage(loopId, "please [[mt-create]]") + expect(send.status).toBe(200) + let events = await readUntilTurnEnds(send, 60_000) + expect(events.some((e) => e.event === "done")).toBe(true) + expect(events.some((e) => e.event === "error")).toBe(false) + + // Now register the list scenario. LIFO means this scenario will match + // first when [[mt-list]] is in the conversation. + mock.register({ + marker: "[[mt-list]]", + *respond(req) { + if (!lastIsToolResult(req)) { + yield blocks.bash("ls " + workdirInSandbox(loopId)) + yield blocks.endTool() + } else { + // Echo the tool result text back so the SSE consumer can assert + // on the file name appearing in assistant_delta output. + const listing = lastToolResultText(req) + yield blocks.text("Files: " + listing.trim()) + yield blocks.endTurn() + } + }, + }) + + send = await sendMessage(loopId, "please [[mt-list]] now") + expect(send.status).toBe(200) + events = await readUntilTurnEnds(send, 60_000) + const failed = events.filter((e) => e.event === "error" || e.event === "interrupted") + if (failed.length) dumpEvents(events) + expect(failed).toEqual([]) + expect(events.some((e) => e.event === "done")).toBe(true) + + // The assistant_delta stream must contain the file name from msg 1. + const seen = events.some( + (e) => e.event === "assistant_delta" && typeof e.data?.text === "string" && e.data.text.includes("mt-marker.txt"), + ) + if (!seen) dumpEvents(events) + expect(seen).toBe(true) + }, 120_000) +}) diff --git a/server/test/api-e2e/hello.test.ts b/server/test/api-e2e/hello.test.ts new file mode 100644 index 00000000..09aa662a --- /dev/null +++ b/server/test/api-e2e/hello.test.ts @@ -0,0 +1,56 @@ +/** + * L4 api-e2e: smoke test for the v1 loop API + sandbox + mock anthropic. + * + * The goal of this test (and the one most likely to surface env / wiring + * problems): a fresh loop, one user message, scripted text-only response + * from the mock model, expect to see a `done` event, no `error`, and + * the assistant text somewhere in the SSE stream. + * + * Skipped if podman is unavailable. Network mode = host (set in podman.ts) + * so CC inside the container reaches 127.0.0.1:<mock_port> directly. + */ +import { test, expect, describe, afterAll } from "bun:test" +import { + podmanAvailable, + mock, + blocks, + createLoop, + sendMessage, + readUntilTurnEnds, + dumpEvents, + teardownAll, +} from "./helpers" + +afterAll(teardownAll) + +describe.skipIf(!podmanAvailable)("api-e2e: hello", () => { + test("text-only scenario: send 'hi' → assistant text → done, no error", async () => { + mock.register({ + marker: "[[hello]]", + *respond() { + yield blocks.text("hello back from mock") + yield blocks.endTurn() + }, + }) + + const loopId = await createLoop({ title: "hello-test" }) + const send = await sendMessage(loopId, "please [[hello]] now") + expect(send.status).toBe(200) + expect(send.headers.get("content-type")).toContain("text/event-stream") + + const events = await readUntilTurnEnds(send, 45_000) + const failed = events.filter((e) => e.event === "error" || e.event === "interrupted") + const done = events.filter((e) => e.event === "done") + + if (failed.length || !done.length) dumpEvents(events) + + expect(failed).toEqual([]) + expect(done.length).toBeGreaterThanOrEqual(1) + + const seenText = events.some((e) => JSON.stringify(e.data).includes("hello back from mock")) + if (!seenText) dumpEvents(events) + expect(seenText).toBe(true) + + expect(mock.hits("[[hello]]")).toBeGreaterThanOrEqual(1) + }, 60_000) +}) diff --git a/server/test/api-e2e/helpers.ts b/server/test/api-e2e/helpers.ts new file mode 100644 index 00000000..d71e3162 --- /dev/null +++ b/server/test/api-e2e/helpers.ts @@ -0,0 +1,401 @@ +/** + * api-e2e test helpers — shared fixture state. + * + * Import order matters: this module sets process.env (LOOPAT_HOME, ports, + * provider config) at top-level BEFORE the first import of `../../src/index`, + * because paths.ts captures LOOPAT_HOME at module load. All test files + * import from here, and bun:test runs them in one process (verified), so + * the env setup + mock-anthropic server + test user are shared across files. + * + * Per-test isolation is at the loop level: each test calls createLoop() + * to get a fresh loopId → fresh container → fresh workdir. + */ +import { mkdir, rm, writeFile } from "node:fs/promises" +import { existsSync } from "node:fs" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { join } from "node:path" +import { + startMockServer, + type Scenario, + type MockServer, + type AnthropicRequest, + blocks as mockBlocks, + lastIsToolResult as mockLastIsToolResult, + lastToolResultText as mockLastToolResultText, +} from "./mock-anthropic" + +const execFileP = promisify(execFile) + +// ── env setup (runs once on first import) ──────────────────────────────── +// +// Bun's default `bun test` glob picks up every file under `test/`, so when +// the api-e2e suite runs alongside other test files (chat-integration, +// api-v1, ...), we inherit whatever env vars they set. Specifically: +// - LOOPAT_HOME: already pinned by paths.ts on first load — we cannot +// change it, so we adopt the existing value and write our config.json +// on top. +// - LOOPAT_CLAUDE_BIN: chat-integration.test.ts sets this to a bash mock. +// For api-e2e we need the *real* claude binary (which actually talks to +// our mock anthropic API), so unset it before src/ imports. + +delete process.env.LOOPAT_CLAUDE_BIN +process.env.LOOPAT_HOME ??= `/tmp/loopat-api-e2e-${process.pid}` +process.env.PORT ??= "0" +process.env.LOOPAT_SERVE_PORT ??= "0" +// Idle stop fast-ish so a hung background process from a failing test gets +// reaped, but not so fast it shuts down between two messages of one test. +// The process-exit hook stops all workspace containers as a backstop. +process.env.LOOPAT_CONTAINER_IDLE_MS ??= "60000" + +const TEST_HOME = process.env.LOOPAT_HOME! +export const USER = "alice" +export const PASSWORD = "test123" + +// Start mock anthropic server (random free port). +export const mockServer: MockServer = await startMockServer() + +// Wipe + rebuild LOOPAT_HOME. (Idempotent across test files: re-imports +// short-circuit at the top-level guard.) +await rm(TEST_HOME, { recursive: true, force: true }).catch(() => {}) +await mkdir(TEST_HOME, { recursive: true }) +await writeFile( + join(TEST_HOME, "config.json"), + JSON.stringify({ + knowledge: { git: "" }, + notes: { git: "" }, + repos: [], + default: "mock", + providers: { + mock: { + baseUrl: mockServer.url, + apiKey: "sk-mock-test", + models: [{ id: "claude-mock", enabled: true }], + }, + }, + }), +) + +// Now safe to import the app. +const { app } = await import("../../src/index") +const { createUser, createSession, COOKIE_NAME } = await import("../../src/auth") +const { probePodman, containerName, ensureContainer, stopAllWorkspaceContainers } = await import("../../src/podman") +const { getLoop } = await import("../../src/loops") + +// Earlier test files (chat-integration, multi-vault, etc.) may have left +// stopped-but-not-removed containers behind. Each container holds a +// session keyring entry, and `kernel.keys.maxkeys` is ~200 on stock +// Linux — once we cross that threshold, every new `podman create` fails +// with "unable to join session keyring: disk quota exceeded". Wipe the +// slate at first api-e2e import; we own this workspace from here on. +if ((await probePodman()).ok) { + try { + const { spawnSync } = await import("node:child_process") + const list = spawnSync( + "podman", + ["ps", "--all", "--quiet", "--filter", "label=loopat.workspace"], + { encoding: "utf8" }, + ) + const ids = (list.stdout ?? "").split("\n").map((s) => s.trim()).filter(Boolean) + if (ids.length > 0) { + spawnSync("podman", ["rm", "--force", ...ids], { stdio: "ignore" }) + } + } catch {} +} +const { personalLoopatDir } = await import("../../src/paths") + +// Set up per-user dirs the SDK driver expects on first ensureContainer. +await mkdir(join(personalLoopatDir(USER), "vaults", "default"), { recursive: true }) +if (!existsSync(join(personalLoopatDir(USER), "config.json"))) { + await writeFile(join(personalLoopatDir(USER), "config.json"), "{}") +} + +// Register test user (idempotent across reruns inside same LOOPAT_HOME). +try { + await createUser({ id: USER, password: PASSWORD, role: "admin", status: "active" }) +} catch (e: any) { + if (!/username taken/.test(e?.message ?? "")) throw e +} +export const SESSION = createSession(USER) +const COOKIE_HDR = { Cookie: `${COOKIE_NAME}=${SESSION}` } + +// Probe podman once at load — tests skipIf this is false. +export const podmanAvailable = (await probePodman()).ok + +// Re-export scenario block helpers for terse test authoring. +export const blocks = mockBlocks +export const mock = mockServer +export const lastIsToolResult = mockLastIsToolResult +export const lastToolResultText = mockLastToolResultText +export type { Scenario, AnthropicRequest } from "./mock-anthropic" + +// ── auth'd request helper ──────────────────────────────────────────────── + +export function authedRequest(path: string, init: RequestInit = {}): Promise<Response> { + const headers = { ...(init.headers ?? {}), ...COOKIE_HDR } + return app.request(path, { ...init, headers }) +} + +// ── v1 API fixtures ────────────────────────────────────────────────────── + +export async function createLoop(opts: { title?: string } = {}): Promise<string> { + const r = await authedRequest("/api/v1/loops", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: opts.title ?? "api-e2e" }), + }) + if (r.status !== 201) { + throw new Error(`createLoop expected 201, got ${r.status}: ${await r.text()}`) + } + const { id } = (await r.json()) as { id: string } + return id +} + +export type SSEEvent = { event: string; data: any } + +export async function sendMessage( + loopId: string, + content: string, + opts: { idempotencyKey?: string; permission_mode?: string } = {}, +): Promise<Response> { + const headers: Record<string, string> = { + "content-type": "application/json", + accept: "text/event-stream", + } + if (opts.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey + const body: Record<string, unknown> = { content } + if (opts.permission_mode) body.permission_mode = opts.permission_mode + return authedRequest(`/api/v1/loops/${loopId}/messages`, { + method: "POST", + headers, + body: JSON.stringify(body), + }) +} + +export async function interrupt(loopId: string): Promise<Response> { + return authedRequest(`/api/v1/loops/${loopId}/interrupt`, { method: "POST" }) +} + +export async function archive(loopId: string): Promise<Response> { + return authedRequest(`/api/v1/loops/${loopId}`, { method: "DELETE" }) +} + +export function eventsStream(loopId: string): Promise<Response> { + return authedRequest(`/api/v1/loops/${loopId}/events`, { + headers: { accept: "text/event-stream" }, + }) +} + +/** + * Async generator over SSE events from a Response.body stream. Caller + * controls when to stop (a `break` in `for await` releases the underlying + * reader). Multi-phase tests (read → POST a choice answer → read more) + * use this directly; single-shot tests use `readSSE` / `readUntilTurnEnds`. + */ +export async function* sseEvents( + r: Response, + opts: { timeoutMs?: number } = {}, +): AsyncGenerator<SSEEvent> { + const reader = r.body!.getReader() + const decoder = new TextDecoder() + let buf = "" + const deadline = Date.now() + (opts.timeoutMs ?? 60_000) + try { + while (Date.now() < deadline) { + const remaining = deadline - Date.now() + const next = await Promise.race([ + reader.read(), + new Promise<{ value: undefined; done: true }>((res) => + setTimeout(() => res({ value: undefined, done: true } as any), remaining), + ), + ]) + if (next.done) return + buf += decoder.decode(next.value, { stream: true }) + let idx: number + while ((idx = buf.indexOf("\n\n")) >= 0) { + const frame = buf.slice(0, idx) + buf = buf.slice(idx + 2) + let ev = "" + let data = "" + for (const line of frame.split("\n")) { + if (line.startsWith("event:")) ev = line.slice(6).trim() + else if (line.startsWith("data:")) data += line.slice(5).trim() + } + if (!ev) continue + let parsed: any = data + try { + parsed = JSON.parse(data) + } catch {} + yield { event: ev, data: parsed } + } + } + } finally { + try { + reader.cancel() + } catch {} + } +} + +/** + * Eagerly drain SSE until `until(ev) === true` or timeout, then close. + * For tests that need to react mid-stream, use `sseEvents` directly. + */ +export async function readSSE( + r: Response, + opts: { until: (ev: SSEEvent) => boolean; timeoutMs?: number }, +): Promise<SSEEvent[]> { + const events: SSEEvent[] = [] + const gen = sseEvents(r, { timeoutMs: opts.timeoutMs ?? 30_000 }) + for await (const ev of gen) { + events.push(ev) + if (opts.until(ev)) break + } + return events +} + +/** Convenience: read until done/error/interrupted. */ +export function readUntilTurnEnds(r: Response, timeoutMs = 60_000): Promise<SSEEvent[]> { + return readSSE(r, { + until: (ev) => ev.event === "done" || ev.event === "error" || ev.event === "interrupted", + timeoutMs, + }) +} + +/** POST /api/v1/loops/:id/choices/:choiceId — answer a permission or + * question choice. `body` is either `{ allow: boolean }` (permission) + * or `{ answers: Record<string, string> }` (question). */ +export async function answerChoice( + loopId: string, + choiceId: string, + body: { allow: boolean } | { answers: Record<string, string> }, +): Promise<Response> { + return authedRequest(`/api/v1/loops/${loopId}/choices/${choiceId}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) +} + +/** Print a captured event list to stderr — invoke when an assertion fails. */ +export function dumpEvents(events: SSEEvent[]): void { + console.error("=== SSE events ===") + for (const e of events) { + const s = JSON.stringify(e.data) + console.error(` ${e.event}: ${s.length > 200 ? s.slice(0, 200) + "…" : s}`) + } +} + +/** Strip the `loop_` prefix so callers can pass the API-flavored id and + * get back what the sandbox / on-disk world uses. */ +export function rawLoopId(apiId: string): string { + return apiId.startsWith("loop_") ? apiId.slice("loop_".length) : apiId +} + +/** Force the per-loop podman container to be created + running, without + * sending a chat message. Use when a test needs to pre-seed the workdir + * before CC ever talks to it. */ +export async function ensureSandbox(apiLoopId: string): Promise<void> { + const id = rawLoopId(apiLoopId) + const meta = await getLoop(id) + if (!meta) throw new Error(`loop ${apiLoopId} not found`) + await ensureContainer({ + loopId: id, + createdBy: meta.createdBy, + vaultName: meta.config?.vault, + knowledgeRw: meta.config?.knowledge_rw, + mountAllLoops: meta.config?.mount_all_loops, + }) +} + +/** Absolute path of a loop's workdir inside the sandbox container. Useful + * for scenarios building `Bash` commands and for inSandbox probes. */ +export function workdirInSandbox(apiId: string): string { + return `/loopat/loop/${rawLoopId(apiId)}/workdir` +} + +// ── sandbox probe (bypasses ws/auth — pure podman exec) ────────────────── + +export async function inSandbox( + apiLoopId: string, + command: string, + opts: { timeoutMs?: number; cwd?: string } = {}, +): Promise<{ stdout: string; stderr: string; code: number }> { + const podman = process.env.LOOPAT_PODMAN_BIN || "podman" + const id = rawLoopId(apiLoopId) + const args = ["exec"] + if (opts.cwd) args.push("--workdir", opts.cwd) + args.push(containerName(id), "bash", "-lc", command) + try { + const { stdout, stderr } = await execFileP(podman, args, { timeout: opts.timeoutMs ?? 10_000 }) + return { stdout: String(stdout), stderr: String(stderr), code: 0 } + } catch (e: any) { + return { + stdout: String(e.stdout ?? ""), + stderr: String(e.stderr ?? e.message ?? ""), + code: typeof e.code === "number" ? e.code : 1, + } + } +} + +// ── misc utilities ─────────────────────────────────────────────────────── + +/** Ask the OS for a free TCP port. Window between probe and bind is small + * enough to ignore for tests. Container uses `--network host`, so the port + * the test picks is the same port the container sees. */ +export async function freePort(): Promise<number> { + const { createServer } = await import("node:net") + return await new Promise<number>((resolve, reject) => { + const s = createServer() + s.unref() + s.on("error", reject) + s.listen(0, "127.0.0.1", () => { + const a = s.address() as { port: number } + const port = a.port + s.close(() => resolve(port)) + }) + }) +} + +/** Sleep, used sparingly when waiting for a side effect that has no signal. */ +export function sleep(ms: number): Promise<void> { + return new Promise((res) => setTimeout(res, ms)) +} + +// ── cleanup, registered once at process exit ───────────────────────────── +// +// We deliberately do NOT export an `afterAll`-style teardown for tests to +// call — bun:test runs all files in one process and shares helpers' state, +// so a file-level afterAll would tear down the world for subsequent files. +// Instead, register cleanup at the Node-level `exit`/`beforeExit` signal, +// which fires exactly once after every file is done. + +let teardownStarted = false +async function teardownOnce(): Promise<void> { + if (teardownStarted) return + teardownStarted = true + await mockServer.close().catch(() => {}) + if (podmanAvailable) { + await stopAllWorkspaceContainers().catch(() => {}) + try { + const { spawnSync } = await import("node:child_process") + spawnSync("podman", ["unshare", "rm", "-rf", TEST_HOME], { stdio: "ignore" }) + } catch {} + } + await rm(TEST_HOME, { recursive: true, force: true }).catch(() => {}) +} + +process.on("beforeExit", () => { + // beforeExit is async-friendly: pending promises keep the loop alive. + void teardownOnce() +}) +// SIGINT during a hanging test should still clean up. +process.on("SIGINT", async () => { + await teardownOnce() + process.exit(130) +}) + +/** No-op kept for source compatibility with earlier per-file calls. + * Real teardown runs on process exit via beforeExit. */ +export async function teardownAll(): Promise<void> { + // intentionally empty +} diff --git a/server/test/api-e2e/mock-anthropic.ts b/server/test/api-e2e/mock-anthropic.ts new file mode 100644 index 00000000..3c6387db --- /dev/null +++ b/server/test/api-e2e/mock-anthropic.ts @@ -0,0 +1,344 @@ +/** + * Mock Anthropic Messages API for api-e2e tests. + * + * The mock plays the *model's* role only: scripted SSE responses dispatched + * by a per-test marker in the first user message. The real claude binary + * runs in the loop's podman sandbox and connects here via + * ANTHROPIC_BASE_URL = http://127.0.0.1:<port>. CC executes its tools for + * real; we only generate the assistant content blocks that tell it what to do. + * + * Why stateless dispatch (no per-conversation cursor): each request from CC + * carries the full `messages` array, so we recompute the turn index every + * call. That means a request retried by the SDK gets the same response — + * the mock never gets "out of sync". + */ +import { randomUUID } from "node:crypto" + +// ── public scenario shape ──────────────────────────────────────────────── + +export type MockBlock = + | { type: "text"; text: string } + | { type: "tool_use"; name: string; input: unknown; id?: string } + | { type: "end"; stop_reason: "end_turn" | "tool_use" } + +export type Scenario = { + /** + * Substring matched against the first user message's text. The first + * registered scenario whose marker is a substring of that text wins; + * the empty string `""` matches anything (fallback). + */ + marker: string + /** + * `turn` = number of model rounds already produced for this conversation. + * turn 0 = the first user message just arrived; turn 1 = CC has sent a + * tool_result back; etc. See `turnIndex()` below. + */ + respond: (req: AnthropicRequest, turn: number) => Iterable<MockBlock> +} + +export type AnthropicRequest = { + model?: string + messages: AnthropicMessage[] + tools?: { name: string; input_schema?: unknown }[] + system?: unknown + // ...everything else ignored +} + +export type AnthropicMessage = { + role: "user" | "assistant" + content: string | AnthropicContentBlock[] +} + +export type AnthropicContentBlock = + | { type: "text"; text: string } + | { type: "tool_use"; id: string; name: string; input: unknown } + | { type: "tool_result"; tool_use_id: string; content: string | unknown[]; is_error?: boolean } + | { type: string; [k: string]: unknown } + +// ── server ─────────────────────────────────────────────────────────────── + +export type MockServer = { + port: number + url: string + register(s: Scenario): void + clear(): void + /** Inspect what scenarios were hit; useful for asserting "mock was called". */ + hits(marker: string): number + /** Stop the HTTP listener. */ + close(): Promise<void> + /** Log captured per request — useful when a test misbehaves. */ + requests: { ts: number; marker: string | null; turn: number; tools: string[] }[] +} + +const FALLBACK: Scenario = { + marker: "", + *respond() { + yield { type: "text", text: "ack" } + yield { type: "end", stop_reason: "end_turn" } + }, +} + +export async function startMockServer(opts: { port?: number } = {}): Promise<MockServer> { + const scenarios: Scenario[] = [] + const hitCounts = new Map<string, number>() + const requests: MockServer["requests"] = [] + + const server = Bun.serve({ + port: opts.port ?? 0, + fetch(req) { + const url = new URL(req.url) + if (url.pathname === "/v1/messages" && req.method === "POST") { + return handleMessages(req, scenarios, hitCounts, requests) + } + // `/v1/messages/count_tokens` — return 404; SDK fallback is fine. + return new Response(JSON.stringify({ type: "error", error: { type: "not_found_error", message: "not mocked" } }), { + status: 404, + headers: { "content-type": "application/json" }, + }) + }, + }) + + return { + port: server.port, + url: `http://127.0.0.1:${server.port}`, + register(s) { + scenarios.unshift(s) // LIFO — most-recent registration wins on overlap + }, + clear() { + scenarios.length = 0 + hitCounts.clear() + requests.length = 0 + }, + hits(marker) { + return hitCounts.get(marker) ?? 0 + }, + requests, + async close() { + server.stop(true) + }, + } +} + +// ── request handling ───────────────────────────────────────────────────── + +async function handleMessages( + req: Request, + scenarios: Scenario[], + hitCounts: Map<string, number>, + requests: MockServer["requests"], +): Promise<Response> { + let body: AnthropicRequest + try { + body = (await req.json()) as AnthropicRequest + } catch (e) { + return new Response(JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: "bad json" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + } + + const firstUser = allUserText(body.messages) + const turn = turnIndex(body.messages) + const scenario = scenarios.find((s) => firstUser.includes(s.marker)) ?? FALLBACK + const markerKey = scenario.marker || "<fallback>" + if (process.env.LOOPAT_MOCK_DEBUG) { + console.error(`[mock] turn=${turn} first_user="${firstUser.slice(0, 120)}" → marker=${markerKey} (scenarios=${scenarios.length})`) + console.error(`[mock] messages shape: ${body.messages.map((m) => `${m.role}:${typeof m.content === "string" ? m.content.slice(0, 60) : `[${(m.content as any[]).map((b) => b.type).join(",")}]`}`).join(" | ")}`) + if (process.env.LOOPAT_MOCK_DEBUG === "full") { + console.error(`[mock] full body: ${JSON.stringify(body).slice(0, 4000)}`) + } + } + hitCounts.set(markerKey, (hitCounts.get(markerKey) ?? 0) + 1) + requests.push({ + ts: Date.now(), + marker: markerKey, + turn, + tools: (body.tools ?? []).map((t) => t.name), + }) + + const blocks = Array.from(scenario.respond(body, turn)) + + const stream = buildSSEStream(blocks) + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + "anthropic-request-id": "req_" + randomUUID().slice(0, 12), + }, + }) +} + +/** + * Concatenate every text-typed content block from every user-role message. + * Used for marker matching. We pick "all user text" rather than "first user + * message first text" because CC wraps the real user input alongside + * `<system-reminder>` blocks — the marker can land in any of them. + * `tool_result` content is also included via JSON.stringify so scenarios can + * key on tool output if they want. + */ +function allUserText(messages: AnthropicMessage[]): string { + const parts: string[] = [] + for (const m of messages) { + if (m.role !== "user") continue + if (typeof m.content === "string") { + parts.push(m.content) + continue + } + for (const b of m.content) { + if (b.type === "text" && typeof (b as any).text === "string") parts.push((b as any).text) + else if (b.type === "tool_result") parts.push(JSON.stringify((b as any).content)) + } + } + return parts.join("\n") +} + +/** `turn = floor(messages.length / 2)`. After CC sends back a tool_result, + * the messages array has grown by 2 (assistant w/ tool_use + user w/ tool_result), + * so the next request to us increments the turn by 1. */ +function turnIndex(messages: AnthropicMessage[]): number { + return Math.floor(messages.length / 2) +} + +/** + * True if the *last* message is a user message carrying a `tool_result` + * block — meaning CC just finished running a tool we asked for. Scenarios + * use this to branch "first turn: emit tool_use" vs "tool finished: emit + * final text". + * + * Robust against multi-message conversations where messages.length keeps + * growing across separate POST /messages calls (each call adds 2+ entries). + */ +export function lastIsToolResult(req: AnthropicRequest): boolean { + const last = req.messages.at(-1) + if (!last || last.role !== "user" || typeof last.content === "string") return false + return (last.content as AnthropicContentBlock[]).some((b) => b.type === "tool_result") +} + +/** Concatenate all tool_result contents in the last user message (for + * scenarios that want to look at what the tool output was). */ +export function lastToolResultText(req: AnthropicRequest): string { + const last = req.messages.at(-1) + if (!last || last.role !== "user" || typeof last.content === "string") return "" + const parts: string[] = [] + for (const b of last.content as AnthropicContentBlock[]) { + if (b.type !== "tool_result") continue + const c = (b as any).content + if (typeof c === "string") parts.push(c) + else if (Array.isArray(c)) { + for (const item of c) { + if (item?.type === "text" && typeof item.text === "string") parts.push(item.text) + } + } + } + return parts.join("\n") +} + +// ── Anthropic SSE framing ──────────────────────────────────────────────── + +function buildSSEStream(blocks: MockBlock[]): ReadableStream<Uint8Array> { + const encoder = new TextEncoder() + const messageId = "msg_" + randomUUID().slice(0, 12) + // Anthropic stream events: message_start → (content_block_start, deltas, content_block_stop)* + // → message_delta (with stop_reason) → message_stop + return new ReadableStream({ + async pull(controller) { + const write = (event: string, data: unknown) => { + controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)) + } + + // message_start + write("message_start", { + type: "message_start", + message: { + id: messageId, + type: "message", + role: "assistant", + model: "claude-mock", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + }) + + let idx = 0 + let stopReason: "end_turn" | "tool_use" = "end_turn" + + for (const block of blocks) { + if (block.type === "text") { + write("content_block_start", { + type: "content_block_start", + index: idx, + content_block: { type: "text", text: "" }, + }) + // Stream the text in one delta — chunk sizing doesn't matter to CC. + write("content_block_delta", { + type: "content_block_delta", + index: idx, + delta: { type: "text_delta", text: block.text }, + }) + write("content_block_stop", { type: "content_block_stop", index: idx }) + idx++ + } else if (block.type === "tool_use") { + const id = block.id ?? "toolu_" + randomUUID().slice(0, 12) + write("content_block_start", { + type: "content_block_start", + index: idx, + content_block: { type: "tool_use", id, name: block.name, input: {} }, + }) + write("content_block_delta", { + type: "content_block_delta", + index: idx, + delta: { type: "input_json_delta", partial_json: JSON.stringify(block.input) }, + }) + write("content_block_stop", { type: "content_block_stop", index: idx }) + idx++ + } else if (block.type === "end") { + stopReason = block.stop_reason + break // any blocks after `end` are dropped + } + } + + // message_delta + message_stop close the turn. + write("message_delta", { + type: "message_delta", + delta: { stop_reason: stopReason, stop_sequence: null }, + usage: { output_tokens: 1 }, + }) + write("message_stop", { type: "message_stop" }) + + controller.close() + }, + }) +} + +// ── block helpers (re-exported through helpers.ts for convenience) ─────── + +export const blocks = { + text(t: string): MockBlock { + return { type: "text", text: t } + }, + toolUse(name: string, input: Record<string, unknown>): MockBlock { + return { type: "tool_use", name, input } + }, + bash(command: string, opts: { run_in_background?: boolean; description?: string; timeout?: number } = {}): MockBlock { + const input: Record<string, unknown> = { command } + if (opts.run_in_background) input.run_in_background = true + if (opts.description) input.description = opts.description + if (opts.timeout) input.timeout = opts.timeout + return { type: "tool_use", name: "Bash", input } + }, + write(file_path: string, content: string): MockBlock { + return { type: "tool_use", name: "Write", input: { file_path, content } } + }, + read(file_path: string): MockBlock { + return { type: "tool_use", name: "Read", input: { file_path } } + }, + endTurn(): MockBlock { + return { type: "end", stop_reason: "end_turn" } + }, + endTool(): MockBlock { + return { type: "end", stop_reason: "tool_use" } + }, +} diff --git a/server/test/api-e2e/multi-tool.test.ts b/server/test/api-e2e/multi-tool.test.ts new file mode 100644 index 00000000..a11f496b --- /dev/null +++ b/server/test/api-e2e/multi-tool.test.ts @@ -0,0 +1,89 @@ +/** + * L4 api-e2e: a single user message that triggers N sequential tool_use / + * tool_result rounds inside one turn. Simulates the typical cc workflow: + * "read this file, grep for X, edit one place, run tests" — all in one + * `POST /messages` invocation. + * + * Validates that: + * - mock's per-`turnIndex` dispatch holds across many rounds in one turn + * - SSE emits one `tool_call` + `tool_result` pair per round + * - the final state on disk reflects all rounds (file written + line + * count matches what each step did) + */ +import { test, expect, describe } from "bun:test" +import { + podmanAvailable, + mock, + blocks, + createLoop, + sendMessage, + readUntilTurnEnds, + inSandbox, + workdirInSandbox, + dumpEvents, +} from "./helpers" + +describe.skipIf(!podmanAvailable)("api-e2e: multi-tool single turn", () => { + test("4-round single turn: write → append → tail → cat; final file content reflects all rounds", async () => { + const loopId = await createLoop({ title: "multi-tool" }) + const wd = workdirInSandbox(loopId) + const file = `${wd}/mt.log` + + mock.register({ + marker: "[[mt-multi]]", + *respond(req, turn) { + // Each round: model emits one tool_use; CC runs it; we move to + // the next `turn`. The fourth call is the wrap-up text. + switch (turn) { + case 0: + yield blocks.text("step 1 — create file") + yield blocks.bash(`printf 'line1\\n' > ${file}`) + yield blocks.endTool() + return + case 1: + yield blocks.text("step 2 — append") + yield blocks.bash(`printf 'line2\\n' >> ${file}`) + yield blocks.endTool() + return + case 2: + yield blocks.text("step 3 — append again") + yield blocks.bash(`printf 'line3\\n' >> ${file}`) + yield blocks.endTool() + return + case 3: + yield blocks.text("all 3 lines written") + yield blocks.endTurn() + return + default: + yield blocks.text("unexpected extra turn") + yield blocks.endTurn() + } + }, + }) + + const send = await sendMessage(loopId, "[[mt-multi]] do all the steps") + expect(send.status).toBe(200) + const events = await readUntilTurnEnds(send, 90_000) + const failed = events.filter((e) => e.event === "error" || e.event === "interrupted") + if (failed.length) dumpEvents(events) + expect(failed).toEqual([]) + expect(events.some((e) => e.event === "done")).toBe(true) + + // SSE accounting — 3 tool_call + 3 tool_result, all in one turn (one + // `started`, one `done`). + const toolCalls = events.filter((e) => e.event === "tool_call") + const toolResults = events.filter((e) => e.event === "tool_result") + if (toolCalls.length !== 3 || toolResults.length !== 3) dumpEvents(events) + expect(toolCalls.length).toBe(3) + expect(toolResults.length).toBe(3) + const started = events.filter((e) => e.event === "started") + const done = events.filter((e) => e.event === "done") + expect(started.length).toBe(1) + expect(done.length).toBe(1) + + // Sandbox-side ground truth: 3 lines, in order. + const probe = await inSandbox(loopId, `cat ${file}`) + expect(probe.code).toBe(0) + expect(probe.stdout).toBe("line1\nline2\nline3\n") + }, 120_000) +}) diff --git a/server/test/api-e2e/multi-turn.test.ts b/server/test/api-e2e/multi-turn.test.ts new file mode 100644 index 00000000..fd7144ac --- /dev/null +++ b/server/test/api-e2e/multi-turn.test.ts @@ -0,0 +1,86 @@ +/** + * L4 api-e2e: three messages, three model "rewrites" of the same file — + * v1 = bare function, v2 = +type hints, v3 = +docstring. The final + * on-disk file must show evidence of all three iterations. Validates + * that: + * - loop session state persists across POST /messages + * - CC's Write tool faithfully reflects each scripted content + * - LIFO scenario registration handles the "growing conversation" case + * (later markers shadow earlier ones) + */ +import { test, expect, describe, afterAll } from "bun:test" +import { + podmanAvailable, + mock, + blocks, + lastIsToolResult, + createLoop, + sendMessage, + readUntilTurnEnds, + inSandbox, + workdirInSandbox, + dumpEvents, + teardownAll, +} from "./helpers" + +afterAll(teardownAll) + +describe.skipIf(!podmanAvailable)("api-e2e: multi-turn", () => { + test("three messages iteratively rewrite add.py: bare → +hints → +docstring", async () => { + const loopId = await createLoop({ title: "mt-iter" }) + const wd = workdirInSandbox(loopId) + const target = `${wd}/add.py` + + const v1 = "def add(a, b):\n return a + b\n" + const v2 = "def add(a: int, b: int) -> int:\n return a + b\n" + const v3 = + 'def add(a: int, b: int) -> int:\n """Return the sum of two ints."""\n return a + b\n' + + // Each scenario writes its version then yields a final text. The + // tool_use is the SDK-driven Write tool, which CC dispatches to the + // real `Write` handler — file appears for real in the workdir. + const writer = (content: string, summary: string) => + function* (req: any) { + if (!lastIsToolResult(req)) { + yield blocks.write(target, content) + yield blocks.endTool() + } else { + yield blocks.text(summary) + yield blocks.endTurn() + } + } + + mock.register({ marker: "[[mt-v1]]", respond: writer(v1, "v1 written") }) + let send = await sendMessage(loopId, "please [[mt-v1]] write the function") + let events = await readUntilTurnEnds(send, 60_000) + if (events.some((e) => e.event === "error")) dumpEvents(events) + expect(events.some((e) => e.event === "error")).toBe(false) + expect(events.some((e) => e.event === "done")).toBe(true) + + let probe = await inSandbox(loopId, `cat ${target}`) + expect(probe.stdout).toBe(v1) + + mock.register({ marker: "[[mt-v2]]", respond: writer(v2, "v2 written") }) + send = await sendMessage(loopId, "please [[mt-v2]] add type hints") + events = await readUntilTurnEnds(send, 60_000) + if (events.some((e) => e.event === "error")) dumpEvents(events) + expect(events.some((e) => e.event === "error")).toBe(false) + expect(events.some((e) => e.event === "done")).toBe(true) + + probe = await inSandbox(loopId, `cat ${target}`) + expect(probe.stdout).toBe(v2) + expect(probe.stdout).toContain("a: int") + + mock.register({ marker: "[[mt-v3]]", respond: writer(v3, "v3 written") }) + send = await sendMessage(loopId, "please [[mt-v3]] add a docstring") + events = await readUntilTurnEnds(send, 60_000) + if (events.some((e) => e.event === "error")) dumpEvents(events) + expect(events.some((e) => e.event === "error")).toBe(false) + expect(events.some((e) => e.event === "done")).toBe(true) + + probe = await inSandbox(loopId, `cat ${target}`) + expect(probe.stdout).toBe(v3) + expect(probe.stdout).toContain('"""') + expect(probe.stdout).toContain("a: int") + }, 240_000) +}) diff --git a/server/test/api-e2e/queue.test.ts b/server/test/api-e2e/queue.test.ts new file mode 100644 index 00000000..1509f086 --- /dev/null +++ b/server/test/api-e2e/queue.test.ts @@ -0,0 +1,126 @@ +/** + * L4 api-e2e: queue when busy. + * + * T1 Send msg 1 with a slow Bash (CC really sleeps inside the sandbox); + * immediately send msg 2; msg 2's SSE stream must start with + * `event: queued` (position ≥ 1). After msg 1 completes, msg 2 fires + * its own `started` and runs to `done` normally. + * + * What this verifies on top of api-v1.test.ts unit tests: the queue logic + * works against a real running turn (not a faked-busy state), and the + * second SSE stream actually flushes `queued` before the first turn ends. + */ +import { test, expect, describe } from "bun:test" +import { + podmanAvailable, + mock, + blocks, + lastIsToolResult, + createLoop, + sendMessage, + sseEvents, + dumpEvents, + type SSEEvent, +} from "./helpers" + +describe.skipIf(!podmanAvailable)("api-e2e: queue", () => { + test("second POST while busy → SSE starts with `queued`, then runs to done after the first finishes", async () => { + const loopId = await createLoop({ title: "queue-busy" }) + + mock.register({ + marker: "[[q-slow]]", + *respond(req) { + if (!lastIsToolResult(req)) { + // Real sleep inside the container — keeps the turn open ~3s. + yield blocks.bash("sleep 3") + yield blocks.endTool() + } else { + yield blocks.text("slow done") + yield blocks.endTurn() + } + }, + }) + mock.register({ + marker: "[[q-fast]]", + *respond() { + yield blocks.text("fast done") + yield blocks.endTurn() + }, + }) + + // Send msg 1. The await here only blocks until response headers — the + // SSE handler hasn't yet called session.sendUserText, so the session + // is NOT yet busy at this point. We need a *real* mid-flight signal + // before sending msg 2. + const send1 = await sendMessage(loopId, "[[q-slow]] please be slow") + expect(send1.status).toBe(200) + + const gen1 = sseEvents(send1, { timeoutMs: 90_000 }) + const events1: SSEEvent[] = [] + + // Wait until msg 1 is *actually* mid-turn — `tool_call` means CC has + // started executing the Bash tool inside the container, which only + // happens after session.sendUserText has been called and the SDK + // produced at least one stream event. By this point session.isBusy() + // is guaranteed true. + let inFlight = false + for (let i = 0; i < 200; i++) { + const n = await gen1.next() + if (n.done) break + events1.push(n.value) + if (n.value.event === "tool_call") { + inFlight = true + break + } + } + if (!inFlight) dumpEvents(events1) + expect(inFlight).toBe(true) + + // Now send msg 2. Session is busy, so its SSE should open with `queued`. + const send2 = await sendMessage(loopId, "[[q-fast]] queue me up") + expect(send2.status).toBe(200) + + const events2: SSEEvent[] = [] + const gen2 = sseEvents(send2, { timeoutMs: 90_000 }) + const first = await gen2.next() + if (first.done) { + dumpEvents(events2) + throw new Error("msg 2 stream ended before any event") + } + events2.push(first.value) + if (first.value.event !== "queued") { + dumpEvents(events2) + } + expect(first.value.event).toBe("queued") + expect(typeof first.value.data.position).toBe("number") + expect(first.value.data.position).toBeGreaterThanOrEqual(1) + + // Continue draining until done. + while (true) { + const n = await gen2.next() + if (n.done) break + events2.push(n.value) + if (n.value.event === "done" || n.value.event === "error" || n.value.event === "interrupted") break + } + await gen2.return(undefined as any) + + expect(events2.some((e) => e.event === "started")).toBe(true) + expect(events2.some((e) => e.event === "done")).toBe(true) + expect(events2.some((e) => e.event === "error" || e.event === "interrupted")).toBe(false) + const sawFast = events2.some( + (e) => e.event === "assistant_delta" && JSON.stringify(e.data).includes("fast done"), + ) + if (!sawFast) dumpEvents(events2) + expect(sawFast).toBe(true) + + // Drain the rest of msg 1's stream — it must complete too. + while (true) { + const n = await gen1.next() + if (n.done) break + events1.push(n.value) + if (n.value.event === "done" || n.value.event === "error" || n.value.event === "interrupted") break + } + await gen1.return(undefined as any) + expect(events1.some((e) => e.event === "done")).toBe(true) + }, 120_000) +}) diff --git a/server/test/api-e2e/reconnect.test.ts b/server/test/api-e2e/reconnect.test.ts new file mode 100644 index 00000000..8264a9d9 --- /dev/null +++ b/server/test/api-e2e/reconnect.test.ts @@ -0,0 +1,103 @@ +/** + * L4 api-e2e: GET /events opened mid-turn → server emits `snapshot` first. + * + * Per api-v1.ts:577 the snapshot fires when `session.isBusy() && + * runtime.currentTurnId` at the time of attach. This is the "user + * refreshed the page while CC was already working" path. + * + * Asserts: + * - snapshot is emitted (and is the first non-ping event) + * - snapshot payload carries the running turn_id + * - subsequent live events (e.g. done from the in-flight turn) still + * reach the viewer, so it's a true reconnect, not just a sniff + */ +import { test, expect, describe } from "bun:test" +import { + podmanAvailable, + mock, + blocks, + lastIsToolResult, + createLoop, + sendMessage, + eventsStream, + sseEvents, + readUntilTurnEnds, + dumpEvents, + sleep, + type SSEEvent, +} from "./helpers" + +describe.skipIf(!podmanAvailable)("api-e2e: reconnect", () => { + test("GET /events attached mid-turn → first non-ping event is `snapshot`", async () => { + const loopId = await createLoop({ title: "reconnect-snapshot" }) + + mock.register({ + marker: "[[rc-slow]]", + *respond(req) { + if (!lastIsToolResult(req)) { + // Slow enough to give us room to open the viewer mid-turn. + yield blocks.text("starting now...") + yield blocks.bash("sleep 3") + yield blocks.endTool() + } else { + yield blocks.text("ok done") + yield blocks.endTurn() + } + }, + }) + + // Kick off a long turn but don't wait for it. + const send = await sendMessage(loopId, "[[rc-slow]] go work") + expect(send.status).toBe(200) + const drainSend = readUntilTurnEnds(send, 60_000) + + // Wait until the turn is *really* running: mock has been hit AND the + // SDK had a moment to set runtime.currentTurnId. Polling on mock.hits + // is a reasonable proxy; +200ms gives the SDK time to push `started`. + for (let i = 0; i < 50; i++) { + await sleep(50) + if (mock.hits("[[rc-slow]]") >= 1) break + } + await sleep(250) + + // Now attach the viewer. + const viewer = await eventsStream(loopId) + expect(viewer.status).toBe(200) + const viewerEvents: SSEEvent[] = [] + const gen = sseEvents(viewer, { timeoutMs: 60_000 }) + + // First non-ping event we see should be `snapshot`. Pings can arrive + // every 15s (heartbeat), but we expect the snapshot to come out + // immediately on attach — well before any heartbeat. + let firstReal: SSEEvent | null = null + for (let i = 0; i < 50; i++) { + const n = await gen.next() + if (n.done) break + viewerEvents.push(n.value) + if (n.value.event !== "ping") { + firstReal = n.value + break + } + } + + if (!firstReal || firstReal.event !== "snapshot") dumpEvents(viewerEvents) + expect(firstReal?.event).toBe("snapshot") + expect(typeof firstReal!.data.turn_id).toBe("string") + expect(firstReal!.data.turn_id).toMatch(/^turn_/) + + // Keep reading the viewer until we see the turn finish — proves it's + // a real reconnect feeding live events, not just a snapshot snip. + for (let i = 0; i < 200; i++) { + const n = await gen.next() + if (n.done) break + viewerEvents.push(n.value) + if (n.value.event === "done" || n.value.event === "error" || n.value.event === "interrupted") break + } + await gen.return(undefined as any) + expect(viewerEvents.some((e) => e.event === "done")).toBe(true) + + // POST stream should also be done. + const sendEvents = await drainSend + expect(sendEvents.some((e) => e.event === "done")).toBe(true) + }, 90_000) +}) diff --git a/server/test/api-e2e/tool-error.test.ts b/server/test/api-e2e/tool-error.test.ts new file mode 100644 index 00000000..4a9cdea2 --- /dev/null +++ b/server/test/api-e2e/tool-error.test.ts @@ -0,0 +1,80 @@ +/** + * L4 api-e2e: cc adapts when a tool fails with a non-zero exit. + * + * T1 Mock emits Bash `false` (always exit 1). CC runs it, gets a + * non-zero tool_result (in CC's stream-json this surfaces as + * content with "Exit code 1"). Mock's second round branches on + * that, emits a fallback assistant text. Turn ends with done, + * NOT error. + * + * What this exercises beyond multi-turn / multi-tool: the *contents* of + * the tool_result feedback loop — that we can observe what CC sends back + * after a failed tool and steer the next model response accordingly. + * Mirrors the real-world pattern: "test failed → look at the error → try + * a different fix". + */ +import { test, expect, describe } from "bun:test" +import { + podmanAvailable, + mock, + blocks, + lastIsToolResult, + lastToolResultText, + createLoop, + sendMessage, + readUntilTurnEnds, + dumpEvents, +} from "./helpers" + +describe.skipIf(!podmanAvailable)("api-e2e: tool error → adapt", () => { + test("Bash exit 1 → mock sees error in tool_result → emits fallback text → done", async () => { + const loopId = await createLoop({ title: "tool-err" }) + + mock.register({ + marker: "[[te-fail]]", + *respond(req) { + if (!lastIsToolResult(req)) { + // `false` is the canonical exit-1 command. + yield blocks.text("trying primary approach") + yield blocks.bash("false") + yield blocks.endTool() + } else { + const last = lastToolResultText(req) + // CC's stream-json reports failures with something like + // "Exit code 1" in the tool_result content. Branch on it. + if (/Exit code|error/i.test(last)) { + yield blocks.text("primary failed; falling back to plan B") + yield blocks.endTurn() + } else { + yield blocks.text("unexpected success: " + last.slice(0, 80)) + yield blocks.endTurn() + } + } + }, + }) + + const send = await sendMessage(loopId, "please [[te-fail]] now") + expect(send.status).toBe(200) + const events = await readUntilTurnEnds(send, 60_000) + + const failed = events.filter((e) => e.event === "error" || e.event === "interrupted") + if (failed.length) dumpEvents(events) + expect(failed).toEqual([]) + expect(events.some((e) => e.event === "done")).toBe(true) + + // tool_result event must indicate failure + const trFails = events.filter((e) => e.event === "tool_result" && e.data?.ok === false) + if (trFails.length === 0) dumpEvents(events) + expect(trFails.length).toBeGreaterThanOrEqual(1) + + // Fallback text reached the SSE consumer + const sawFallback = events.some( + (e) => + e.event === "assistant_delta" && + typeof e.data?.text === "string" && + e.data.text.includes("falling back to plan B"), + ) + if (!sawFallback) dumpEvents(events) + expect(sawFallback).toBe(true) + }, 90_000) +}) diff --git a/server/test/api-mcp.test.ts b/server/test/api-mcp.test.ts new file mode 100644 index 00000000..4d5bf46d --- /dev/null +++ b/server/test/api-mcp.test.ts @@ -0,0 +1,279 @@ +/** + * L2: HTTP-endpoint tests for the MCP-facing API. Uses Hono's app.request() + * — no real network listener. + * + * Setup hazards: + * 1. paths.ts captures LOOPAT_HOME at module load → must be set first. + * 2. index.ts bootstraps at module load (loadConfig, clones repos[], starts + * ./serve listener). Pre-seed a minimal config.json (no repos) BEFORE + * import so bootstrap is cheap. Random ports avoid collision. + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-api-mcp-${process.pid}` +process.env.PORT = "0" +process.env.LOOPAT_SERVE_PORT = "0" + +// Pre-seed before import so the bootstrap doesn't try to clone repos +const HOME = process.env.LOOPAT_HOME! +await rm(HOME, { recursive: true, force: true }) +await mkdir(HOME, { recursive: true }) +await writeFile(join(HOME, "config.json"), JSON.stringify({ + knowledge: { git: "" }, + notes: { git: "" }, + repos: [], + providers: {}, +})) + +const { app } = await import("../src/index") +const { composeFromPlan } = await import("../src/compose") +const { + personalDir, + personalLoopatDir, + personalLoopatConfigPath, + personalVaultDir, + personalVaultEnvPath, + personalClaudeDir, + personalSettingsPath, + workspaceTeamSettingsPath, + workspaceTeamClaudeDir, + loopWorkdir, + loopClaudeDir, + loopContextKnowledge, + loopContextNotes, +} = await import("../src/paths") +const { createUser, createSession, COOKIE_NAME } = await import("../src/auth") + +const USER = "tester" +let SESSION_COOKIE = "" + +async function authedHeaders(): Promise<Record<string, string>> { + return { Cookie: `${COOKIE_NAME}=${SESSION_COOKIE}` } +} + +async function setupLoop(loopId: string): Promise<void> { + await mkdir(loopWorkdir(loopId), { recursive: true }) + await mkdir(loopClaudeDir(loopId), { recursive: true }) + await mkdir(loopContextKnowledge(loopId), { recursive: true }) + await mkdir(loopContextNotes(loopId), { recursive: true }) + await composeFromPlan(loopId, { + user: USER, + claudeSources: [ + { source: "team", dir: workspaceTeamClaudeDir() }, + { source: `personal:${USER}`, dir: personalDir(USER) }, + ], + } as any) +} + +async function setupUserAndTeam() { + try { await createUser({ id: USER, password: "pw" }) } catch {} + SESSION_COOKIE = createSession(USER) + await mkdir(personalLoopatDir(USER), { recursive: true }) + await mkdir(personalVaultDir(USER, "default"), { recursive: true }) + await mkdir(join(personalVaultDir(USER, "default"), "envs"), { recursive: true }) + await mkdir(personalClaudeDir(USER), { recursive: true }) + await writeFile(personalSettingsPath(USER), JSON.stringify({ mcpServers: {} })) + await writeFile(personalLoopatConfigPath(USER), JSON.stringify({ + providers: { + default: "anthropic", + anthropic: { + baseUrl: "https://api.anthropic.com", + model: "claude-opus-4-7", + apiKey: "${ANTHROPIC_API_KEY}", + }, + }, + })) + await mkdir(workspaceTeamClaudeDir(), { recursive: true }) + await writeFile(workspaceTeamSettingsPath(), JSON.stringify({ + mcpServers: { + github: { + type: "http", + url: "https://api.githubcopilot.com/mcp", + headers: { Authorization: "Bearer ${GITHUB_TOKEN}" }, + }, + "stdio-server": { type: "stdio", command: "echo", args: ["hi"] }, + "no-bearer": { + type: "http", + url: "https://api.example/mcp", + headers: { "X-Api-Key": "${EXAMPLE_KEY}" }, + }, + }, + })) +} + +beforeAll(setupUserAndTeam) +afterAll(async () => { await rm(HOME, { recursive: true, force: true }) }) + +// Note: we don't assert on oauthSupport — that field depends on network +// reachability of the server's .well-known endpoints. Shape and authed-flag +// derivation are what we care about. + +describe("GET /api/mcp-servers — flat list from merged settings.json", () => { + test("without loopId, returns empty list", async () => { + const r = await app.request("/api/mcp-servers", { headers: await authedHeaders() }) + expect(r.status).toBe(200) + const j = await r.json() as any + expect(j.servers).toEqual([]) + }) + + test("with loopId, returns servers from the loop's composed settings.json", async () => { + const loopId = "api-mcp-0000-0000-0000-000000000001" + await setupLoop(loopId) + const r = await app.request(`/api/mcp-servers?loopId=${loopId}`, { headers: await authedHeaders() }) + const j = await r.json() as any + const names = j.servers.map((s: any) => s.name).sort() + expect(names).toEqual(["github", "no-bearer", "stdio-server"]) + }, { timeout: 15000 }) + + test("Bearer-templated server exposes authTokenEnv", async () => { + const loopId = "api-mcp-0000-0000-0000-000000000002" + await setupLoop(loopId) + const r = await app.request(`/api/mcp-servers?loopId=${loopId}`, { headers: await authedHeaders() }) + const j = await r.json() as any + const gh = j.servers.find((s: any) => s.name === "github") + expect(gh.authTokenEnv).toBe("GITHUB_TOKEN") + }, { timeout: 15000 }) + + test("non-Bearer auth → authTokenEnv is null", async () => { + const loopId = "api-mcp-0000-0000-0000-000000000003" + await setupLoop(loopId) + const r = await app.request(`/api/mcp-servers?loopId=${loopId}`, { headers: await authedHeaders() }) + const j = await r.json() as any + const nb = j.servers.find((s: any) => s.name === "no-bearer") + expect(nb.authTokenEnv).toBeNull() + }, { timeout: 15000 }) + + test("stdio server → authTokenEnv is null", async () => { + const loopId = "api-mcp-0000-0000-0000-000000000004" + await setupLoop(loopId) + const r = await app.request(`/api/mcp-servers?loopId=${loopId}`, { headers: await authedHeaders() }) + const j = await r.json() as any + const stdio = j.servers.find((s: any) => s.name === "stdio-server") + expect(stdio.authTokenEnv).toBeNull() + }) + + test("authed flag reflects env file existence", async () => { + const loopId = "api-mcp-0000-0000-0000-000000000005" + await setupLoop(loopId) + // Without env file: authed=false + let r = await app.request(`/api/mcp-servers?loopId=${loopId}`, { headers: await authedHeaders() }) + let j = await r.json() as any + expect(j.servers.find((s: any) => s.name === "github").authed).toBe(false) + // Write the env file → authed=true + await writeFile(personalVaultEnvPath(USER, "default", "GITHUB_TOKEN"), "ghu_xxx") + r = await app.request(`/api/mcp-servers?loopId=${loopId}`, { headers: await authedHeaders() }) + j = await r.json() as any + expect(j.servers.find((s: any) => s.name === "github").authed).toBe(true) + // Empty env file → authed=false (treated as unset) + await writeFile(personalVaultEnvPath(USER, "default", "GITHUB_TOKEN"), "") + r = await app.request(`/api/mcp-servers?loopId=${loopId}`, { headers: await authedHeaders() }) + j = await r.json() as any + expect(j.servers.find((s: any) => s.name === "github").authed).toBe(false) + }) +}) + +describe("POST /api/mcp-auth/start — input validation", () => { + test("rejects missing serverName", async () => { + const r = await app.request("/api/mcp-auth/start", { + method: "POST", + headers: { "content-type": "application/json", ...(await authedHeaders()) }, + body: JSON.stringify({ loopId: "any" }), + }) + expect(r.status).toBe(400) + }) + + test("rejects missing loopId", async () => { + const r = await app.request("/api/mcp-auth/start", { + method: "POST", + headers: { "content-type": "application/json", ...(await authedHeaders()) }, + body: JSON.stringify({ serverName: "github" }), + }) + expect(r.status).toBe(400) + }) + + test("rejects server with shell metas in name", async () => { + const r = await app.request("/api/mcp-auth/start", { + method: "POST", + headers: { "content-type": "application/json", ...(await authedHeaders()) }, + body: JSON.stringify({ serverName: "foo;rm -rf", loopId: "any" }), + }) + expect(r.status).toBe(400) + }) + + test("rejects server not present in merged settings", async () => { + const loopId = "api-mcp-0000-0000-0000-000000000020" + await setupLoop(loopId) + const r = await app.request("/api/mcp-auth/start", { + method: "POST", + headers: { "content-type": "application/json", ...(await authedHeaders()) }, + body: JSON.stringify({ serverName: "ghost", loopId }), + }) + expect(r.status).toBe(400) + const j = await r.json() as any + expect(j.error).toMatch(/not found in loop's merged settings/) + }) + + test("rejects stdio server (OAuth only applies to http/sse)", async () => { + const loopId = "api-mcp-0000-0000-0000-000000000021" + await setupLoop(loopId) + const r = await app.request("/api/mcp-auth/start", { + method: "POST", + headers: { "content-type": "application/json", ...(await authedHeaders()) }, + body: JSON.stringify({ serverName: "stdio-server", loopId }), + }) + expect(r.status).toBe(400) + }) + + test("rejects server without Bearer template", async () => { + const loopId = "api-mcp-0000-0000-0000-000000000022" + await setupLoop(loopId) + const r = await app.request("/api/mcp-auth/start", { + method: "POST", + headers: { "content-type": "application/json", ...(await authedHeaders()) }, + body: JSON.stringify({ serverName: "no-bearer", loopId }), + }) + expect(r.status).toBe(400) + const j = await r.json() as any + expect(j.error).toMatch(/Authorization: Bearer/) + }) +}) + +describe("DELETE /api/envs/:name — removes vault env (= Forget MCP token)", () => { + test("deletes the named env file from personal default vault", async () => { + await writeFile(personalVaultEnvPath(USER, "default", "GITHUB_TOKEN"), "ghu_will_die") + const r = await app.request("/api/envs/GITHUB_TOKEN", { + method: "DELETE", + headers: await authedHeaders(), + }) + expect(r.status).toBe(200) + expect(await Bun.file(personalVaultEnvPath(USER, "default", "GITHUB_TOKEN")).exists()).toBe(false) + }) + + test("succeeds even when env file doesn't exist (idempotent)", async () => { + const r = await app.request("/api/envs/NEVER_EXISTED", { + method: "DELETE", + headers: await authedHeaders(), + }) + expect(r.status).toBe(200) + }) + + test("rejects invalid env name (lowercase / leading digit / shell metas)", async () => { + const r1 = await app.request("/api/envs/lowercase", { + method: "DELETE", + headers: await authedHeaders(), + }) + expect(r1.status).toBe(400) + const r2 = await app.request("/api/envs/1STARTS", { + method: "DELETE", + headers: await authedHeaders(), + }) + expect(r2.status).toBe(400) + const r3 = await app.request("/api/envs/foo%3Brm", { + method: "DELETE", + headers: await authedHeaders(), + }) + expect(r3.status).toBe(400) + }) +}) diff --git a/server/test/api-settings.test.ts b/server/test/api-settings.test.ts new file mode 100644 index 00000000..a3ac3d1d --- /dev/null +++ b/server/test/api-settings.test.ts @@ -0,0 +1,175 @@ +/** + * L2: HTTP-endpoint tests for /api/settings/personal/* — the Settings UI's + * write path. + * + * Same bootstrap-suppression pattern as api-mcp.test.ts. + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { existsSync } from "node:fs" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-api-settings-${process.pid}` +process.env.PORT = "0" +process.env.LOOPAT_SERVE_PORT = "0" + +const HOME = process.env.LOOPAT_HOME! +await rm(HOME, { recursive: true, force: true }) +await mkdir(HOME, { recursive: true }) +await writeFile(join(HOME, "config.json"), JSON.stringify({ + knowledge: { git: "" }, notes: { git: "" }, repos: [], providers: {}, +})) + +const { app } = await import("../src/index") +const { + personalLoopatDir, + personalLoopatConfigPath, + personalVaultEnvPath, + personalVaultEnvsDir, +} = await import("../src/paths") +const { createUser, createSession, COOKIE_NAME } = await import("../src/auth") +const { clearPersonalCache } = await import("../src/config") + +const USER = "settest" +let COOKIE = "" + +async function authed(): Promise<Record<string, string>> { + return { Cookie: `${COOKIE_NAME}=${COOKIE}` } +} + +beforeAll(async () => { + try { await createUser({ id: USER, password: "pw" }) } catch {} + COOKIE = createSession(USER) + await mkdir(personalLoopatDir(USER), { recursive: true }) + await mkdir(personalVaultEnvsDir(USER, "default"), { recursive: true }) +}) + +afterAll(async () => { await rm(HOME, { recursive: true, force: true }) }) + +describe("GET /api/settings/personal/disk", () => { + test("returns disk shape + refExists for each provider apiKey", async () => { + await writeFile(personalLoopatConfigPath(USER), JSON.stringify({ + providers: { + default: "anthropic", + anthropic: { baseUrl: "https://api.anthropic.com", model: "x", apiKey: "${ANTHROPIC_API_KEY}" }, + idealab: { baseUrl: "https://idealab.example.com", model: "y", apiKey: "${IDEALAB_API_KEY}" }, + custom: { baseUrl: "u", model: "z", apiKey: "sk-literal-here" }, + }, + })) + // Only ANTHROPIC_API_KEY exists on disk + await writeFile(personalVaultEnvPath(USER, "default", "ANTHROPIC_API_KEY"), "sk-ant-actual") + clearPersonalCache(USER) + + const r = await app.request("/api/settings/personal/disk", { headers: await authed() }) + expect(r.status).toBe(200) + const j = await r.json() as any + + expect(j.disk.providers).toBeDefined() + expect(j.refExists["providers.anthropic.apiKey"]).toEqual({ + kind: "var", exists: true, varName: "ANTHROPIC_API_KEY", + }) + expect(j.refExists["providers.idealab.apiKey"]).toEqual({ + kind: "var", exists: false, varName: "IDEALAB_API_KEY", + }) + expect(j.refExists["providers.custom.apiKey"]).toEqual({ + kind: "literal", exists: true, + }) + }) + + test("does NOT leak resolved apiKey values to client", async () => { + const r = await app.request("/api/settings/personal/disk", { headers: await authed() }) + const j = await r.json() as any + // disk shape carries the template (${VAR}), not the resolved value + expect(j.disk.providers.anthropic.apiKey).toBe("${ANTHROPIC_API_KEY}") + // No field anywhere contains the real secret + const all = JSON.stringify(j) + expect(all.includes("sk-ant-actual")).toBe(false) + }) +}) + +describe("POST /api/settings/personal/value — write vault env", () => { + test("writes the value to vaults/<v>/envs/<NAME>", async () => { + const r = await app.request("/api/settings/personal/value", { + method: "POST", + headers: { "content-type": "application/json", ...(await authed()) }, + body: JSON.stringify({ name: "MY_NEW_KEY", value: "fresh-value", vault: "default" }), + }) + expect(r.status).toBe(200) + const written = await Bun.file(personalVaultEnvPath(USER, "default", "MY_NEW_KEY")).text() + expect(written).toBe("fresh-value\n") + }) + + test("vault defaults to 'default' when omitted", async () => { + const r = await app.request("/api/settings/personal/value", { + method: "POST", + headers: { "content-type": "application/json", ...(await authed()) }, + body: JSON.stringify({ name: "DEFAULT_VAULT_TEST", value: "v" }), + }) + expect(r.status).toBe(200) + expect(existsSync(personalVaultEnvPath(USER, "default", "DEFAULT_VAULT_TEST"))).toBe(true) + }) + + test("rejects missing name", async () => { + const r = await app.request("/api/settings/personal/value", { + method: "POST", + headers: { "content-type": "application/json", ...(await authed()) }, + body: JSON.stringify({ value: "v" }), + }) + expect(r.status).toBe(400) + }) + + test("rejects invalid vault name", async () => { + const r = await app.request("/api/settings/personal/value", { + method: "POST", + headers: { "content-type": "application/json", ...(await authed()) }, + body: JSON.stringify({ name: "X", value: "v", vault: "../escape" }), + }) + expect(r.status).toBe(400) + }) + + test("rejects invalid env name", async () => { + const r = await app.request("/api/settings/personal/value", { + method: "POST", + headers: { "content-type": "application/json", ...(await authed()) }, + body: JSON.stringify({ name: "bad-name", value: "v" }), + }) + expect(r.status).toBe(400) + }) +}) + +describe("PUT /api/settings/personal/disk — config.json structural patch", () => { + test("saves providers patch", async () => { + const r = await app.request("/api/settings/personal/disk", { + method: "PUT", + headers: { "content-type": "application/json", ...(await authed()) }, + body: JSON.stringify({ + providers: { + default: "new-one", + "new-one": { baseUrl: "u", model: "m", apiKey: "${NEW_ONE_API_KEY}" }, + }, + }), + }) + expect(r.status).toBe(200) + const j = JSON.parse(await Bun.file(personalLoopatConfigPath(USER)).text()) + expect(j.providers["new-one"].baseUrl).toBe("u") + expect(j.providers.default).toBe("new-one") + }) + + test("rejects non-string default provider", async () => { + const r = await app.request("/api/settings/personal/disk", { + method: "PUT", + headers: { "content-type": "application/json", ...(await authed()) }, + body: JSON.stringify({ providers: { default: 123, foo: { baseUrl: "u", model: "m" } } }), + }) + expect(r.status).toBe(400) + }) + + test("rejects provider missing baseUrl", async () => { + const r = await app.request("/api/settings/personal/disk", { + method: "PUT", + headers: { "content-type": "application/json", ...(await authed()) }, + body: JSON.stringify({ providers: { foo: { model: "m" } } }), + }) + expect(r.status).toBe(400) + }) +}) diff --git a/server/test/api-v1.test.ts b/server/test/api-v1.test.ts new file mode 100644 index 00000000..9caacbdb --- /dev/null +++ b/server/test/api-v1.test.ts @@ -0,0 +1,403 @@ +/** + * L2: HTTP-endpoint tests for the v1 Loop API (token store + Loop CRUD + + * auth). SSE streaming endpoints require a live agent and are exercised by + * the Playwright e2e suite. + */ +import { test, expect, describe, beforeAll } from "bun:test" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-api-v1-${process.pid}` +process.env.PORT = "0" +process.env.LOOPAT_SERVE_PORT = "0" + +const HOME = process.env.LOOPAT_HOME! +await rm(HOME, { recursive: true, force: true }) +await mkdir(HOME, { recursive: true }) +await writeFile(join(HOME, "config.json"), JSON.stringify({ + knowledge: { git: "" }, + notes: { git: "" }, + repos: [], + providers: {}, +})) + +const { app } = await import("../src/index") +const { createUser, createSession, COOKIE_NAME } = await import("../src/auth") +const { _resetCache } = await import("../src/api-tokens") + +const USER_A = "alice" +const USER_B = "bob" +let SESSION_A = "" +let SESSION_B = "" +let TOKEN_A = "" + +async function ensureUser(id: string, opts: { password: string, role: string, status: string }) { + try { await createUser({ id, ...opts }) } catch {} +} + +beforeAll(async () => { + await ensureUser(USER_A, { password: "pw", role: "admin", status: "active" }) + await ensureUser(USER_B, { password: "pw", role: "member", status: "active" }) + SESSION_A = createSession(USER_A) + SESSION_B = createSession(USER_B) + _resetCache() +}) + +function cookieHeader(sess: string): Record<string, string> { + return { Cookie: `${COOKIE_NAME}=${sess}` } +} + +describe("token management (/me/tokens)", () => { + test("requires session cookie (not bearer)", async () => { + const r = await app.request("/api/v1/me/tokens", { method: "GET" }) + expect(r.status).toBe(401) + }) + + test("create + list + revoke round-trip", async () => { + const created = await app.request("/api/v1/me/tokens", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({ label: "ci-bot" }), + }) + expect(created.status).toBe(201) + const cj = await created.json() + expect(cj.token).toMatch(/^la_[0-9a-f]+$/) + expect(cj.tokenId).toMatch(/^tok_[0-9a-f]+$/) + expect(cj.label).toBe("ci-bot") + TOKEN_A = cj.token + + const listed = await app.request("/api/v1/me/tokens", { headers: cookieHeader(SESSION_A) }) + expect(listed.status).toBe(200) + const lj = await listed.json() + expect(lj.tokens.length).toBe(1) + expect(lj.tokens[0]).toMatchObject({ label: "ci-bot", tokenId: cj.tokenId }) + expect(lj.tokens[0].token).toBeUndefined() // never returned after creation + + const revoked = await app.request(`/api/v1/me/tokens/${cj.tokenId}`, { + method: "DELETE", + headers: cookieHeader(SESSION_A), + }) + expect(revoked.status).toBe(204) + + const afterRevoke = await app.request("/api/v1/me/tokens", { headers: cookieHeader(SESSION_A) }) + expect((await afterRevoke.json()).tokens.length).toBe(0) + }) + + test("user B does not see user A's tokens", async () => { + await app.request("/api/v1/me/tokens", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({ label: "alice-private" }), + }) + const bobList = await app.request("/api/v1/me/tokens", { headers: cookieHeader(SESSION_B) }) + expect((await bobList.json()).tokens.length).toBe(0) + }) + + test("revoke fails 404 for unknown tokenId", async () => { + const r = await app.request("/api/v1/me/tokens/tok_nonexistent", { + method: "DELETE", + headers: cookieHeader(SESSION_A), + }) + expect(r.status).toBe(404) + }) +}) + +describe("auth: cookie vs bearer on /loops", () => { + let bearerToken = "" + beforeAll(async () => { + const r = await app.request("/api/v1/me/tokens", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({ label: "test-bearer" }), + }) + bearerToken = (await r.json()).token + }) + + test("cookie auth works", async () => { + const r = await app.request("/api/v1/loops", { headers: cookieHeader(SESSION_A) }) + expect(r.status).toBe(200) + }) + + test("bearer auth works", async () => { + const r = await app.request("/api/v1/loops", { + headers: { Authorization: `Bearer ${bearerToken}` }, + }) + expect(r.status).toBe(200) + }) + + test("bad bearer rejected", async () => { + const r = await app.request("/api/v1/loops", { + headers: { Authorization: "Bearer la_definitely_invalid" }, + }) + expect(r.status).toBe(401) + }) + + test("malformed bearer rejected", async () => { + const r = await app.request("/api/v1/loops", { + headers: { Authorization: "Bearer not-la-prefix" }, + }) + expect(r.status).toBe(401) + }) + + test("no auth → 401", async () => { + const r = await app.request("/api/v1/loops") + expect(r.status).toBe(401) + }) +}) + +describe("loop CRUD (/loops)", () => { + let createdLoopId = "" + + test("POST /loops creates with vault/metadata", async () => { + // profiles intentionally omitted — test env has no workspace profile dir + const r = await app.request("/api/v1/loops", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({ + title: "demo loop", + vault: "default", + metadata: { slack_thread: "C1:t2" }, + }), + }) + expect(r.status).toBe(201) + const j = await r.json() + expect(j.id).toMatch(/^loop_[0-9a-f-]+$/) + expect(j.title).toBe("demo loop") + expect(j.created_by).toBe(USER_A) + expect(j.vault).toBe("default") + expect(j.archived).toBe(false) + expect(j.metadata).toEqual({ slack_thread: "C1:t2" }) + createdLoopId = j.id + }) + + test("POST /loops with empty body uses defaults", async () => { + const r = await app.request("/api/v1/loops", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: "{}", + }) + expect(r.status).toBe(201) + const j = await r.json() + expect(j.title).toBe("untitled") + }) + + test("POST /loops rejects oversized metadata", async () => { + const huge = { x: "y".repeat(17 * 1024) } + const r = await app.request("/api/v1/loops", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({ metadata: huge }), + }) + expect(r.status).toBe(400) + expect((await r.json()).error.code).toBe("metadata_too_large") + }) + + test("POST /loops rejects long title", async () => { + const r = await app.request("/api/v1/loops", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({ title: "x".repeat(201) }), + }) + expect(r.status).toBe(400) + expect((await r.json()).error.code).toBe("title_too_long") + }) + + test("GET /loops returns user's loops", async () => { + const r = await app.request("/api/v1/loops", { headers: cookieHeader(SESSION_A) }) + expect(r.status).toBe(200) + const j = await r.json() + expect(j.data.length).toBeGreaterThanOrEqual(2) + expect(j.data.every((l: any) => l.created_by === USER_A)).toBe(true) + expect(j.has_more).toBe(false) + }) + + test("GET /loops respects limit", async () => { + const r = await app.request("/api/v1/loops?limit=1", { headers: cookieHeader(SESSION_A) }) + const j = await r.json() + expect(j.data.length).toBe(1) + expect(j.has_more).toBe(true) + }) + + test("GET /loops does not leak other users' loops", async () => { + await app.request("/api/v1/loops", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_B) }, + body: JSON.stringify({ title: "bob's loop" }), + }) + const bob = await app.request("/api/v1/loops", { headers: cookieHeader(SESSION_B) }) + const bobJson = await bob.json() + expect(bobJson.data.every((l: any) => l.created_by === USER_B)).toBe(true) + + const alice = await app.request("/api/v1/loops", { headers: cookieHeader(SESSION_A) }) + const aliceJson = await alice.json() + expect(aliceJson.data.every((l: any) => l.created_by === USER_A)).toBe(true) + }) + + test("GET /loops/{id} returns full detail", async () => { + const r = await app.request(`/api/v1/loops/${createdLoopId}`, { headers: cookieHeader(SESSION_A) }) + expect(r.status).toBe(200) + const j = await r.json() + expect(j.id).toBe(createdLoopId) + expect(j.title).toBe("demo loop") + expect(typeof j.busy).toBe("boolean") + expect(typeof j.queue_depth).toBe("number") + }) + + test("GET /loops/{id} 403 for non-owner", async () => { + const r = await app.request(`/api/v1/loops/${createdLoopId}`, { headers: cookieHeader(SESSION_B) }) + expect(r.status).toBe(403) + expect((await r.json()).error.code).toBe("not_loop_owner") + }) + + test("GET /loops/{id} 404 for unknown", async () => { + const r = await app.request("/api/v1/loops/loop_doesnotexist", { headers: cookieHeader(SESSION_A) }) + expect(r.status).toBe(404) + }) + + test("DELETE /loops/{id} archives", async () => { + const r = await app.request(`/api/v1/loops/${createdLoopId}`, { + method: "DELETE", + headers: cookieHeader(SESSION_A), + }) + expect(r.status).toBe(204) + const detail = await app.request(`/api/v1/loops/${createdLoopId}`, { headers: cookieHeader(SESSION_A) }) + expect((await detail.json()).archived).toBe(true) + }) + + test("archived loops are excluded from default list", async () => { + const r = await app.request("/api/v1/loops", { headers: cookieHeader(SESSION_A) }) + const j = await r.json() + expect(j.data.find((l: any) => l.id === createdLoopId)).toBeUndefined() + }) + + test("?archived=true includes archived", async () => { + const r = await app.request("/api/v1/loops?archived=true", { headers: cookieHeader(SESSION_A) }) + const j = await r.json() + expect(j.data.find((l: any) => l.id === createdLoopId)).toBeDefined() + }) + + test("DELETE 403 for non-owner", async () => { + // Make a fresh loop for B's archive attempt + const create = await app.request("/api/v1/loops", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({ title: "alice's only" }), + }) + const id = (await create.json()).id + const r = await app.request(`/api/v1/loops/${id}`, { + method: "DELETE", + headers: cookieHeader(SESSION_B), + }) + expect(r.status).toBe(403) + }) +}) + +describe("idempotency conflict on /messages", () => { + let id = "" + beforeAll(async () => { + const r = await app.request("/api/v1/loops", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({ title: "idem" }), + }) + id = (await r.json()).id + }) + + test("same key + different body → 409", async () => { + // We can't actually run the agent in this test env (no provider config), + // so we check the validation paths up front: oversized content + missing + // content return non-SSE 400s, and the idempotency conflict path returns + // 409 before any streaming kicks in. + + // First, prime the idempotency store by hitting the validator with a + // request that gets past auth but fails downstream. To force a stored + // record without invoking the agent, we use the loop_archived path. + // (The store is only written for successful streaming requests; this + // means we can't easily test the 409 in isolation without an agent.) + // → Skip: relies on streaming. Covered by e2e instead. + expect(true).toBe(true) + }) + + test("oversized content rejected before SSE", async () => { + const r = await app.request(`/api/v1/loops/${id}/messages`, { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({ content: "x".repeat(2 * 1024 * 1024) }), + }) + expect(r.status).toBe(400) + expect((await r.json()).error.code).toBe("content_too_large") + }) + + test("missing content rejected", async () => { + const r = await app.request(`/api/v1/loops/${id}/messages`, { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: "{}", + }) + expect(r.status).toBe(400) + expect((await r.json()).error.code).toBe("missing_content") + }) + + test("oversized idempotency key rejected", async () => { + const r = await app.request(`/api/v1/loops/${id}/messages`, { + method: "POST", + headers: { + "content-type": "application/json", + "idempotency-key": "x".repeat(257), + ...cookieHeader(SESSION_A), + }, + body: JSON.stringify({ content: "hi" }), + }) + expect(r.status).toBe(400) + expect((await r.json()).error.code).toBe("idempotency_key_too_long") + }) +}) + +describe("choices + interrupt validation", () => { + let id = "" + beforeAll(async () => { + const r = await app.request("/api/v1/loops", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({ title: "ctrl" }), + }) + id = (await r.json()).id + }) + + test("POST /choices/{id} with invalid body → 400", async () => { + const r = await app.request(`/api/v1/loops/${id}/choices/choice_xyz`, { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({}), + }) + expect(r.status).toBe(400) + expect((await r.json()).error.code).toBe("invalid_choice_payload") + }) + + test("POST /choices/{id} with no pending choice → 404", async () => { + const r = await app.request(`/api/v1/loops/${id}/choices/choice_does_not_exist`, { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION_A) }, + body: JSON.stringify({ allow: true }), + }) + expect(r.status).toBe(404) + expect((await r.json()).error.code).toBe("choice_not_found") + }) + + test("POST /interrupt 202 even when no turn is running", async () => { + const r = await app.request(`/api/v1/loops/${id}/interrupt`, { + method: "POST", + headers: cookieHeader(SESSION_A), + }) + expect(r.status).toBe(202) + }) + + test("non-owner cannot interrupt", async () => { + const r = await app.request(`/api/v1/loops/${id}/interrupt`, { + method: "POST", + headers: cookieHeader(SESSION_B), + }) + expect(r.status).toBe(403) + }) +}) + diff --git a/server/test/chat-integration.test.ts b/server/test/chat-integration.test.ts new file mode 100644 index 00000000..41b3cf5a --- /dev/null +++ b/server/test/chat-integration.test.ts @@ -0,0 +1,220 @@ +/** + * L4 chat-integration: drives the full chat pipeline end-to-end via the + * v1 API surface (POST /api/v1/loops/:id/messages → SSE) with a mock + * claude binary, so we exercise the real session.ts + podman exec stack + * WITHOUT burning real API credits. + * + * This test exists specifically to catch the class of bugs that slipped + * past the unit tests during the bwrap→podman migration: + * + * - hash-drift between term/session ensureContainer calls + * → caused recreate → PTY/SDK SIGKILL (code 137) + * - missing `-i` on `podman exec` for SDK + * → claude received EOF on stdin and exited with code 0, no output + * → "chat sends but never responds" symptom + * - SDK post-result SIGTERM/SIGKILL cleanup propagating as exit 137 + * → spurious error event after a successful turn + * + * Any of those would manifest here as either: no `assistant` event ever + * arrives, or an `error` event arrives, or the SSE stream never reaches + * `done`. All three assertions below fail in those cases. + * + * Skipped automatically if `podman` is not installed. + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile, chmod } from "node:fs/promises" +import { existsSync } from "node:fs" +import { join, dirname } from "node:path" +import { fileURLToPath } from "node:url" + +const HERE = dirname(fileURLToPath(import.meta.url)) +const MOCK_CLAUDE = join(HERE, "fixtures", "mock-claude.sh") + +// ── Set env BEFORE any src/ import (paths.ts captures LOOPAT_HOME at load) ── +// Use ??= so we don't clobber an LOOPAT_HOME another test set first; in the +// full-suite case paths.ts is already locked to whichever value won. +process.env.LOOPAT_HOME ??= `/tmp/loopat-chat-int-${process.pid}` +process.env.PORT ??= "0" +process.env.LOOPAT_SERVE_PORT ??= "0" +// Point the SDK at the mock claude script. The path lives under the loopat +// install dir (which is bind-mounted into the container at the same host-abs +// path) so it resolves inside the sandbox. +process.env.LOOPAT_CLAUDE_BIN = MOCK_CLAUDE + +const TEST_HOME = process.env.LOOPAT_HOME! +const USER = "alice" +const PASSWORD = "test123" + +await rm(TEST_HOME, { recursive: true, force: true }) +await mkdir(TEST_HOME, { recursive: true }) +// Minimal workspace config: a fake provider so session.ts has something to +// resolve. The mock claude doesn't actually call any API, but session.ts +// still requires a provider with a non-empty apiKey. +await writeFile(join(TEST_HOME, "config.json"), JSON.stringify({ + knowledge: { git: "" }, + notes: { git: "" }, + repos: [], + default: "mock", + providers: { + mock: { + baseUrl: "https://mock.invalid", + apiKey: "sk-mock-test", + models: [{ id: "mock-model", enabled: true }], + }, + }, +})) +// Ensure the mock binary still exists + is executable (chmod survives git +// per .gitignore? — re-chmod just in case CI clones strip the +x bit). +if (!existsSync(MOCK_CLAUDE)) { + throw new Error(`mock claude script missing at ${MOCK_CLAUDE}`) +} +await chmod(MOCK_CLAUDE, 0o755) + +// Now safe to import the app + dependencies. +const { app } = await import("../src/index") +const { createUser, createSession, COOKIE_NAME } = await import("../src/auth") +const { probePodman } = await import("../src/podman") + +const podmanAvailable = (await probePodman()).ok + +let SESSION = "" + +beforeAll(async () => { + // Idempotent across full-suite runs where LOOPAT_HOME is shared. + try { + await createUser({ id: USER, password: PASSWORD, role: "admin", status: "active" }) + } catch (e: any) { + if (!/username taken/.test(e?.message ?? "")) throw e + } + SESSION = createSession(USER) + // ensureContainer binds personal/<user>/ — needs to exist on disk before + // podman create. + const { personalLoopatDir } = await import("../src/paths") + await mkdir(join(personalLoopatDir(USER), "vaults", "default"), { recursive: true }) + if (!existsSync(join(personalLoopatDir(USER), "config.json"))) { + await writeFile(join(personalLoopatDir(USER), "config.json"), "{}") + } +}) + +afterAll(async () => { + // Use podman unshare to clean any subuid-owned files inside the LOOPAT_HOME. + if (podmanAvailable) { + const { stopAllWorkspaceContainers, removeContainer } = await import("../src/podman") + await stopAllWorkspaceContainers().catch(() => {}) + // Best-effort: rm via podman unshare so subuid-owned cruft inside any + // home-upper layer doesn't block cleanup. If unshare isn't usable in this + // env (e.g. nested userns), fall through to plain rm. + try { + const { spawnSync } = await import("node:child_process") + spawnSync("podman", ["unshare", "rm", "-rf", TEST_HOME], { stdio: "ignore" }) + } catch {} + } + await rm(TEST_HOME, { recursive: true, force: true }).catch(() => {}) +}) + +function cookieHeader(sess: string): Record<string, string> { + return { Cookie: `${COOKIE_NAME}=${sess}` } +} + +type SSEEvent = { event: string; data: any } + +/** Parse a single SSE response body stream into an array of events. */ +async function readSSE(r: Response, maxMs: number, until: (ev: SSEEvent) => boolean): Promise<SSEEvent[]> { + const events: SSEEvent[] = [] + const reader = r.body!.getReader() + const decoder = new TextDecoder() + let buf = "" + const deadline = Date.now() + maxMs + while (Date.now() < deadline) { + const remaining = deadline - Date.now() + const { value, done } = await Promise.race([ + reader.read(), + new Promise<{ value: undefined, done: true }>((res) => + setTimeout(() => res({ value: undefined, done: true } as any), remaining), + ), + ]) + if (done) break + buf += decoder.decode(value, { stream: true }) + // SSE frames are separated by blank line. Parse each complete frame. + let idx: number + while ((idx = buf.indexOf("\n\n")) >= 0) { + const frame = buf.slice(0, idx) + buf = buf.slice(idx + 2) + let ev = "" + let data = "" + for (const line of frame.split("\n")) { + if (line.startsWith("event:")) ev = line.slice(6).trim() + else if (line.startsWith("data:")) data += line.slice(5).trim() + } + if (!ev) continue + let parsed: any = data + try { parsed = JSON.parse(data) } catch {} + const sse = { event: ev, data: parsed } + events.push(sse) + if (until(sse)) return events + } + } + return events +} + +describe.skipIf(!podmanAvailable)("chat integration — POST /api/v1/loops/:id/messages SSE", () => { + test("full turn: send 'hi' → init → assistant text → done; no error event", async () => { + // Create loop via API. + const createR = await app.request("/api/v1/loops", { + method: "POST", + headers: { "content-type": "application/json", ...cookieHeader(SESSION) }, + body: JSON.stringify({ title: "chat int test" }), + }) + expect(createR.status).toBe(201) + const loop = await createR.json() as { id: string } + expect(loop.id).toMatch(/^loop_/) + + // POST /messages — returns SSE stream of events from the SDK pipeline. + const sendR = await app.request(`/api/v1/loops/${loop.id}/messages`, { + method: "POST", + headers: { + "content-type": "application/json", + "accept": "text/event-stream", + ...cookieHeader(SESSION), + }, + body: JSON.stringify({ content: "hi" }), + }) + expect(sendR.status).toBe(200) + expect(sendR.headers.get("content-type")).toContain("text/event-stream") + + // Read until we see `done` or `error`, or 30s timeout. + const events = await readSSE(sendR, 30_000, (ev) => + ev.event === "done" || ev.event === "error" || ev.event === "interrupted", + ) + // Debug aid when this test fails — print every event we got. Bun's test + // runner buffers these and surfaces them on failure. + if (events.filter((e) => e.event === "done").length === 0) { + console.error("=== events captured (no done) ===") + for (const e of events) { + console.error(` ${e.event}: ${JSON.stringify(e.data).slice(0, 200)}`) + } + } + + // What we expect, in any order: + // - at least one assistant-text event (raw SDK pass-through OR mapped) + // - a `done` event signaling clean completion + // What we DO NOT want: + // - any `error` event (would indicate session failure) + // - missing `assistant` (would indicate "no response" — i.e. the + // missing-`-i` bug) + + const errorEvents = events.filter((e) => e.event === "error") + expect(errorEvents).toEqual([]) + + const doneEvents = events.filter((e) => e.event === "done") + expect(doneEvents.length).toBeGreaterThanOrEqual(1) + + // Look for our mock's assistant text in any of the events. The v1 mapping + // may surface it via different event names (e.g. "assistant_text", + // "message", or raw passthrough). Just search the JSON. + const seenMockText = events.some((e) => + JSON.stringify(e.data).includes("mock-response-OK"), + ) + expect(seenMockText).toBe(true) + }, 45_000) +}) diff --git a/server/test/compose.test.ts b/server/test/compose.test.ts new file mode 100644 index 00000000..7318fb50 --- /dev/null +++ b/server/test/compose.test.ts @@ -0,0 +1,951 @@ +/** + * Tests for the tiered .claude/ merge — loopat's core composition logic. + * + * What we're testing: + * 1. mergeSettings: enabledPlugins / extraKnownMarketplaces union + last-wins + * 2. normalizeMarketplaceEntry: relative path resolution against source dir + * 3. composeSubdir: skills/agents symlink union with later-wins shadowing + * 4. composeFromPlan: full E2E with team + N profiles + personal + repo layers + * 5. Stress: 12+ .claude sources merging cleanly + * 6. Edge cases: empty sources, missing files, plugin disable overriding enable + * + * NOTE: LOOPAT_HOME must be set BEFORE source imports — paths.ts reads it + * at module load time. Fixtures live under that home; afterAll wipes it. + */ +import { test, expect, describe, beforeAll, afterAll, beforeEach } from "bun:test" +import { mkdir, rm, writeFile, readFile, readdir } from "node:fs/promises" +import { existsSync, readlinkSync } from "node:fs" +import { join } from "node:path" + +// paths.ts captures LOOPAT_HOME at module load. If another test file imported +// it first in this run, this assignment is a no-op; align our TEST_HOME to +// whatever was captured so the fixture and the helpers agree. +process.env.LOOPAT_HOME ??= `/tmp/loopat-merge-test-${process.pid}` + +// Imports AFTER LOOPAT_HOME is set +const { composeLoopClaudeConfig } = await import("../src/compose") +const { resolveLoopPlan, listProfiles } = await import("../src/profiles") +const { + LOOPAT_HOME, + loopClaudeDir, + workspaceTeamClaudeDir, + workspaceProfileClaudeDir, + personalClaudeDir, + personalLoopatConfigPath, +} = await import("../src/paths") +const TEST_HOME = LOOPAT_HOME +// Avoid unused-var warning when tests below don't use this in every block +void loopClaudeDir + +// ─── fixture helpers ─────────────────────────────────────────────────── + +async function writeJson(path: string, obj: unknown) { + await mkdir(join(path, ".."), { recursive: true }) + await writeFile(path, JSON.stringify(obj, null, 2)) +} + +async function writeMd(path: string, content: string) { + await mkdir(join(path, ".."), { recursive: true }) + await writeFile(path, content) +} + +/** Create a `.claude/` dir with given settings, CLAUDE.md, skill names, agent names. */ +async function makeClaudeDir(opts: { + dir: string + settings?: Record<string, any> + claudeMd?: string + skills?: string[] + agents?: string[] +}) { + await mkdir(opts.dir, { recursive: true }) + if (opts.settings !== undefined) { + await writeJson(join(opts.dir, "settings.json"), opts.settings) + } + if (opts.claudeMd !== undefined) { + await writeMd(join(opts.dir, "CLAUDE.md"), opts.claudeMd) + } + for (const s of opts.skills ?? []) { + const skillDir = join(opts.dir, "skills", s) + await mkdir(skillDir, { recursive: true }) + await writeMd(join(skillDir, "SKILL.md"), `---\nname: ${s}\ndescription: test skill ${s}\n---\n\n${s} body`) + } + for (const a of opts.agents ?? []) { + await writeMd(join(opts.dir, "agents", `${a}.md`), `---\nname: ${a}\n---\n\n${a} body`) + } +} + +async function makeProfile(name: string, opts: Omit<Parameters<typeof makeClaudeDir>[0], "dir">) { + await makeClaudeDir({ ...opts, dir: workspaceProfileClaudeDir(name) }) +} + +async function makeTeam(opts: Omit<Parameters<typeof makeClaudeDir>[0], "dir">) { + await makeClaudeDir({ ...opts, dir: workspaceTeamClaudeDir() }) +} + +async function makePersonal(user: string, opts: Omit<Parameters<typeof makeClaudeDir>[0], "dir"> & { + defaultProfiles?: string[] +}) { + await makeClaudeDir({ ...opts, dir: personalClaudeDir(user) }) + await writeJson(personalLoopatConfigPath(user), { + default_profiles: opts.defaultProfiles ?? [], + default_vault: "default", + }) +} + +async function reset() { + await rm(TEST_HOME, { recursive: true, force: true }) + await mkdir(TEST_HOME, { recursive: true }) +} + +beforeAll(async () => { await reset() }) +afterAll(async () => { await rm(TEST_HOME, { recursive: true, force: true }) }) +beforeEach(async () => { await reset() }) + +// ─── 1. enabledPlugins union ─────────────────────────────────────────── + +describe("mergeSettings — enabledPlugins union", () => { + test("union across sources, all preserved", async () => { + await makeTeam({ settings: { enabledPlugins: { "team-plugin@mp": true } } }) + await makeProfile("p1", { settings: { enabledPlugins: { "p1-plugin@mp": true } } }) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-test-1", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + expect(merged.enabledPlugins).toEqual({ + "team-plugin@mp": true, + "p1-plugin@mp": true, + }) + }) + + test("later source can DISABLE a plugin enabled earlier", async () => { + await makeTeam({ settings: { enabledPlugins: { "foo@mp": true } } }) + await makeProfile("p1", { settings: { enabledPlugins: { "foo@mp": false } } }) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-test-2", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + expect(merged.enabledPlugins["foo@mp"]).toBe(false) + expect(result.enabledPlugins).not.toContain("foo@mp") + }) + + test("disabled plugin can be re-enabled by later source", async () => { + await makeTeam({ settings: { enabledPlugins: { "foo@mp": false } } }) + await makeProfile("p1", { settings: { enabledPlugins: { "foo@mp": true } } }) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-test-3", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + expect(merged.enabledPlugins["foo@mp"]).toBe(true) + expect(result.enabledPlugins).toContain("foo@mp") + }) +}) + +// ─── 2. extraKnownMarketplaces — union + path normalization ──────────── + +describe("mergeSettings — extraKnownMarketplaces", () => { + test("union across sources", async () => { + await makeTeam({ settings: { extraKnownMarketplaces: { mp1: { source: { source: "github", repo: "x/y" } } } } }) + await makeProfile("p1", { settings: { extraKnownMarketplaces: { mp2: { source: { source: "github", repo: "a/b" } } } } }) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-mp-1", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + expect(Object.keys(merged.extraKnownMarketplaces ?? {}).sort()).toEqual(["mp1", "mp2"]) + }) + + test("relative directory path resolves against source settings.json dir", async () => { + // team settings at <knowledge>/.loopat/.claude/ → "../../marketplace" = <knowledge>/marketplace/ + await makeTeam({ + settings: { + extraKnownMarketplaces: { + "team-mp": { source: { source: "directory", path: "../../marketplace" } }, + }, + }, + }) + await makePersonal("alice", { defaultProfiles: [] }) + + const result = await composeLoopClaudeConfig("loop-mp-2", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + const path = merged.extraKnownMarketplaces["team-mp"].source.path + expect(path).toMatch(/^\//) + expect(path.endsWith("/knowledge/marketplace")).toBe(true) + }) + + test("absolute directory path preserved as-is", async () => { + await makeTeam({ + settings: { + extraKnownMarketplaces: { "abs-mp": { source: { source: "directory", path: "/some/abs/path" } } }, + }, + }) + await makePersonal("alice", { defaultProfiles: [] }) + + const result = await composeLoopClaudeConfig("loop-mp-3", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + expect(merged.extraKnownMarketplaces["abs-mp"].source.path).toBe("/some/abs/path") + }) + + test("non-directory sources (github, url) pass through unchanged", async () => { + await makeTeam({ + settings: { + extraKnownMarketplaces: { + "gh-mp": { source: { source: "github", repo: "anthropics/claude-plugins-official" } }, + "url-mp": { source: { source: "url", url: "https://example.com/m.json" } }, + }, + }, + }) + await makePersonal("alice", { defaultProfiles: [] }) + + const result = await composeLoopClaudeConfig("loop-mp-4", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + expect(merged.extraKnownMarketplaces["gh-mp"]).toEqual({ + source: { source: "github", repo: "anthropics/claude-plugins-official" }, + }) + expect(merged.extraKnownMarketplaces["url-mp"]).toEqual({ + source: { source: "url", url: "https://example.com/m.json" }, + }) + }) + + test("each source resolves relative paths against ITS OWN settings dir", async () => { + await makeTeam({ + settings: { extraKnownMarketplaces: { "team-rel": { source: { source: "directory", path: "../foo" } } } }, + }) + await makeProfile("p1", { + settings: { extraKnownMarketplaces: { "p1-rel": { source: { source: "directory", path: "../foo" } } } }, + }) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-mp-5", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + const teamPath = merged.extraKnownMarketplaces["team-rel"].source.path + const p1Path = merged.extraKnownMarketplaces["p1-rel"].source.path + expect(teamPath).not.toBe(p1Path) + expect(teamPath.endsWith("/.loopat/foo")).toBe(true) + expect(p1Path.endsWith("/profiles/p1/foo")).toBe(true) + }) +}) + +// ─── 3. Other settings field semantics ───────────────────────────────── + +describe("mergeSettings — other fields", () => { + test("primitives: later source wins", async () => { + await makeTeam({ settings: { someInt: 1, someStr: "team" } }) + await makeProfile("p1", { settings: { someInt: 2, someStr: "p1" } }) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-prim-1", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + expect(merged.someInt).toBe(2) + expect(merged.someStr).toBe("p1") + }) + + test("arrays: later source replaces", async () => { + await makeTeam({ settings: { someArr: ["a", "b"] } }) + await makeProfile("p1", { settings: { someArr: ["c"] } }) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-arr-1", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + expect(merged.someArr).toEqual(["c"]) + }) + + test("_comment field stripped", async () => { + await makeTeam({ settings: { _comment: "team", enabledPlugins: {} } }) + await makeProfile("p1", { settings: { _comment: "p1" } }) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-comment", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + expect(merged._comment).toBeUndefined() + }) + + test("loopat injects autoMemory fields", async () => { + await makePersonal("alice", { defaultProfiles: [] }) + const result = await composeLoopClaudeConfig("loop-auto", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + expect(merged.autoMemoryEnabled).toBe(true) + expect(merged.autoMemoryDirectory).toBeDefined() + }) +}) + +// ─── 4. CLAUDE.md concat ──────────────────────────────────────────────── + +describe("CLAUDE.md concat", () => { + test("order: team → profile → personal", async () => { + await makeTeam({ claudeMd: "# Team" }) + await makeProfile("p1", { claudeMd: "# P1" }) + await makePersonal("alice", { claudeMd: "# Alice", defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-md-1", "alice") + const body = await readFile(result.claudeMdPath, "utf8") + expect(body.indexOf("# Team")).toBeLessThan(body.indexOf("# P1")) + expect(body.indexOf("# P1")).toBeLessThan(body.indexOf("# Alice")) + }) + + test("section markers identify each source", async () => { + await makeTeam({ claudeMd: "Team body" }) + await makeProfile("p1", { claudeMd: "P1 body" }) + await makePersonal("alice", { claudeMd: "Alice body", defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-md-2", "alice") + const body = await readFile(result.claudeMdPath, "utf8") + expect(body).toContain("<!-- ========== team ========== -->") + expect(body).toContain("<!-- ========== profile:p1 ========== -->") + expect(body).toContain("<!-- ========== personal:alice ========== -->") + }) + + test("missing source files silently skipped", async () => { + await makeTeam({ claudeMd: "Team body" }) + await makeProfile("p2", {}) // no CLAUDE.md + await makePersonal("alice", { defaultProfiles: ["p2"] }) + + const result = await composeLoopClaudeConfig("loop-md-3", "alice") + const body = await readFile(result.claudeMdPath, "utf8") + expect(body).toContain("Team body") + expect(body).not.toContain("profile:p2") + }) + + test("no CLAUDE.md anywhere → file does not exist", async () => { + await makePersonal("alice", { defaultProfiles: [] }) + const result = await composeLoopClaudeConfig("loop-md-4", "alice") + expect(existsSync(result.claudeMdPath)).toBe(false) + }) +}) + +// ─── 5. skills/agents symlink union ───────────────────────────────────── + +describe("skills/agents symlink union", () => { + test("skills from all sources union'd", async () => { + await makeTeam({ skills: ["s-team-1", "s-team-2"] }) + await makeProfile("p1", { skills: ["s-p1"] }) + await makePersonal("alice", { skills: ["s-alice"], defaultProfiles: ["p1"] }) + + await composeLoopClaudeConfig("loop-skills-1", "alice") + const entries = await readdir(join(loopClaudeDir("loop-skills-1"), "skills")) + expect(entries.sort()).toEqual(["s-alice", "s-p1", "s-team-1", "s-team-2"]) + }) + + test("same-name skill from later source shadows earlier", async () => { + await makeTeam({ skills: ["dup"] }) + await makeProfile("p1", { skills: ["dup"] }) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + await composeLoopClaudeConfig("loop-skills-2", "alice") + const dupLink = readlinkSync(join(loopClaudeDir("loop-skills-2"), "skills", "dup")) + expect(dupLink).toContain("/profiles/p1/.claude/skills/dup") + }) + + test("agents are .md files", async () => { + await makeTeam({ agents: ["a1"] }) + await makeProfile("p1", { agents: ["a2"] }) + await makePersonal("alice", { agents: ["a3"], defaultProfiles: ["p1"] }) + + await composeLoopClaudeConfig("loop-agents-1", "alice") + const entries = await readdir(join(loopClaudeDir("loop-agents-1"), "agents")) + expect(entries.sort()).toEqual(["a1.md", "a2.md", "a3.md"]) + }) +}) + +// ─── 6. Profile selection ────────────────────────────────────────────── + +describe("resolveLoopPlan — profile selection", () => { + test("default_profiles loaded from personal config", async () => { + await makeProfile("role-a", {}) + await makeProfile("role-b", {}) + await makePersonal("alice", { defaultProfiles: ["role-a", "role-b"] }) + + const plan = await resolveLoopPlan({ user: "alice" }) + expect(plan.profiles).toEqual(["role-a", "role-b"]) + }) + + test("cliAdded appends", async () => { + await makeProfile("role-a", {}) + await makeProfile("mode-c", {}) + await makePersonal("alice", { defaultProfiles: ["role-a"] }) + + const plan = await resolveLoopPlan({ user: "alice", cliAdded: ["mode-c"] }) + expect(plan.profiles).toEqual(["role-a", "mode-c"]) + }) + + test("cliRemoved drops", async () => { + await makeProfile("role-a", {}) + await makeProfile("role-b", {}) + await makePersonal("alice", { defaultProfiles: ["role-a", "role-b"] }) + + const plan = await resolveLoopPlan({ user: "alice", cliRemoved: ["role-a"] }) + expect(plan.profiles).toEqual(["role-b"]) + }) + + test("overrideProfiles replaces defaults", async () => { + await makeProfile("role-a", {}) + await makeProfile("mode-x", {}) + await makePersonal("alice", { defaultProfiles: ["role-a"] }) + + const plan = await resolveLoopPlan({ user: "alice", overrideProfiles: ["mode-x"] }) + expect(plan.profiles).toEqual(["mode-x"]) + }) + + test("missing profile errors", async () => { + // Create at least one profile so profiles/ dir exists; then ask for a missing one + await makeProfile("real-profile", {}) + await makePersonal("alice", { defaultProfiles: ["does-not-exist"] }) + await expect(resolveLoopPlan({ user: "alice" })).rejects.toThrow(/does-not-exist/) + }) + + test("workdir/.claude/ becomes 5th source layer", async () => { + await makeProfile("role-a", {}) + await makePersonal("alice", { defaultProfiles: ["role-a"] }) + const workdir = join(TEST_HOME, "fake-workdir") + await makeClaudeDir({ dir: join(workdir, ".claude"), claudeMd: "# Repo" }) + + const plan = await resolveLoopPlan({ user: "alice", workdir }) + expect(plan.claudeSources.map((s) => s.source)).toContain(`repo:${workdir}`) + }) +}) + +// ─── 7. STRESS: 12+ .claude sources merge cleanly ────────────────────── + +describe("stress — 12 .claude sources", () => { + test("12 profiles + team + personal merge without breaking", async () => { + await makeTeam({ + settings: { enabledPlugins: { "team-base@mp": true } }, + claudeMd: "# Team", + skills: ["team-skill"], + }) + + const profileNames: string[] = [] + for (let i = 0; i < 12; i++) { + const name = `mode-${i.toString().padStart(2, "0")}` + profileNames.push(name) + await makeProfile(name, { + settings: { enabledPlugins: { [`p${i}@mp`]: true } }, + claudeMd: `# Profile ${i}`, + skills: [`skill-${i}`], + }) + } + await makePersonal("alice", { + defaultProfiles: profileNames, + skills: ["personal-skill"], + claudeMd: "# Personal", + }) + + const result = await composeLoopClaudeConfig("loop-stress", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + + // 13 plugins enabled (team-base + 12 profile plugins) + const enabledKeys = Object.entries(merged.enabledPlugins ?? {}) + .filter(([_, v]) => v).map(([k]) => k) + expect(enabledKeys.length).toBe(13) + expect(enabledKeys).toContain("team-base@mp") + for (let i = 0; i < 12; i++) { + expect(enabledKeys).toContain(`p${i}@mp`) + } + + // 14 distinct skills + const skills = await readdir(join(loopClaudeDir("loop-stress"), "skills")) + expect(skills.length).toBe(14) + + // 14 CLAUDE.md sections (team + 12 + personal) + const md = await readFile(result.claudeMdPath, "utf8") + const markers = md.match(/<!-- ========== /g) ?? [] + expect(markers.length).toBe(14) + + // result lists also reflect this + expect(result.enabledPlugins.length).toBe(13) + expect(result.sources.length).toBe(14) + }) +}) + +// ─── 8. ComposeResult shape ───────────────────────────────────────────── + +describe("composeFromPlan E2E", () => { + test("returns enabledPlugins (true only) and extraMarketplaces list", async () => { + await makeTeam({ + settings: { + enabledPlugins: { "a@mp": true, "b@mp": false }, + extraKnownMarketplaces: { + "mp": { source: { source: "directory", path: "../mp-here" } }, + "remote": { source: { source: "github", repo: "x/y" } }, + }, + }, + }) + await makePersonal("alice", { defaultProfiles: [] }) + + const r = await composeLoopClaudeConfig("loop-shape-1", "alice") + expect(r.enabledPlugins).toEqual(["a@mp"]) + expect(r.extraMarketplaces.sort()).toEqual(["mp", "remote"]) + expect(r.sources).toContain("team") + expect(r.sources).toContain("personal:alice") + }) +}) + +// ─── 9. mise.toml + mise.lock merge ──────────────────────────────────── + +describe("mise.toml merge (toolchain layer)", () => { + async function writeToml(path: string, content: string) { + await mkdir(join(path, ".."), { recursive: true }) + await writeFile(path, content) + } + + test("union of [tools] across sources, last wins per-key", async () => { + await makeTeam({ settings: {} }) + await writeToml(join(workspaceTeamClaudeDir(), "mise.toml"), ` +[tools] +node = "20" +python = "3.12" +`) + await makeProfile("ml", { settings: {} }) + await writeToml(join(workspaceProfileClaudeDir("ml"), "mise.toml"), ` +[tools] +python = "3.13" +cuda = "12.4" +`) + await makePersonal("alice", { defaultProfiles: ["ml"] }) + await writeToml(join(personalClaudeDir("alice"), "mise.toml"), ` +[tools] +node = "22" +`) + + const result = await composeLoopClaudeConfig("loop-mise-1", "alice") + expect(result.miseTomlPath).toBeTruthy() + const merged = await readFile(result.miseTomlPath!, "utf8") + expect(merged).toContain('node = "22"') // personal wins over team + expect(merged).toContain('python = "3.13"') // profile wins over team + expect(merged).toContain('cuda = "12.4"') // profile-only + }) + + test("union of [env] across sources, last wins per-key", async () => { + await makeTeam({ settings: {} }) + await writeToml(join(workspaceTeamClaudeDir(), "mise.toml"), ` +[env] +NODE_ENV = "development" +TEAM_FLAG = "true" +`) + await makeProfile("p1", { settings: {} }) + await writeToml(join(workspaceProfileClaudeDir("p1"), "mise.toml"), ` +[env] +NODE_ENV = "production" +PROFILE_FLAG = "yes" +`) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-mise-2", "alice") + const merged = await readFile(result.miseTomlPath!, "utf8") + expect(merged).toContain('NODE_ENV = "production"') // profile wins + expect(merged).toContain('TEAM_FLAG = "true"') + expect(merged).toContain('PROFILE_FLAG = "yes"') + }) + + test("nested table merge (e.g. [tools.node] = {version, checksum})", async () => { + await makeTeam({ settings: {} }) + await writeToml(join(workspaceTeamClaudeDir(), "mise.lock"), ` +[tools.node] +version = "20.18.0" +checksum = "abc" + +[tools.python] +version = "3.12.7" +checksum = "def" +`) + await makeProfile("p1", { settings: {} }) + await writeToml(join(workspaceProfileClaudeDir("p1"), "mise.lock"), ` +[tools.python] +version = "3.13.0" +checksum = "jkl" + +[tools.cuda] +version = "12.4.1" +checksum = "ghi" +`) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-mise-3", "alice") + expect(result.miseLockPath).toBeTruthy() + const merged = await readFile(result.miseLockPath!, "utf8") + // python overridden by profile + expect(merged).toMatch(/python[\s\S]*version = "3.13.0"/) + // node preserved from team + expect(merged).toMatch(/node[\s\S]*version = "20.18.0"/) + // cuda added by profile + expect(merged).toMatch(/cuda[\s\S]*version = "12.4.1"/) + }) + + test("no source has mise.toml → miseTomlPath null", async () => { + await makePersonal("alice", { defaultProfiles: [] }) + const result = await composeLoopClaudeConfig("loop-mise-4", "alice") + expect(result.miseTomlPath).toBeNull() + expect(result.miseLockPath).toBeNull() + }) + + test("only team has mise.toml → merged is team's", async () => { + await makeTeam({ settings: {} }) + await writeToml(join(workspaceTeamClaudeDir(), "mise.toml"), ` +[tools] +node = "20" +`) + await makePersonal("alice", { defaultProfiles: [] }) + + const result = await composeLoopClaudeConfig("loop-mise-5", "alice") + expect(result.miseTomlPath).toBeTruthy() + const merged = await readFile(result.miseTomlPath!, "utf8") + expect(merged).toContain('node = "20"') + }) + + test("malformed TOML in one source doesn't crash; warns and skips", async () => { + await makeTeam({ settings: {} }) + await writeToml(join(workspaceTeamClaudeDir(), "mise.toml"), 'not valid toml [[[') + await makeProfile("p1", { settings: {} }) + await writeToml(join(workspaceProfileClaudeDir("p1"), "mise.toml"), ` +[tools] +python = "3.12" +`) + await makePersonal("alice", { defaultProfiles: ["p1"] }) + + const result = await composeLoopClaudeConfig("loop-mise-6", "alice") + expect(result.miseTomlPath).toBeTruthy() + const merged = await readFile(result.miseTomlPath!, "utf8") + expect(merged).toContain('python = "3.12"') // good source survives + }) + + test("mise.toml independence — different layer adds DIFFERENT tools", async () => { + await makeTeam({ settings: {} }) + await writeToml(join(workspaceTeamClaudeDir(), "mise.toml"), `[tools]\nnode = "20"`) + await makeProfile("backend", { settings: {} }) + await writeToml(join(workspaceProfileClaudeDir("backend"), "mise.toml"), `[tools]\ngo = "1.22"`) + await makeProfile("ml", { settings: {} }) + await writeToml(join(workspaceProfileClaudeDir("ml"), "mise.toml"), `[tools]\npython = "3.13"`) + await makePersonal("alice", { defaultProfiles: ["backend", "ml"] }) + + const result = await composeLoopClaudeConfig("loop-mise-7", "alice") + const merged = await readFile(result.miseTomlPath!, "utf8") + expect(merged).toContain('node = "20"') + expect(merged).toContain('go = "1.22"') + expect(merged).toContain('python = "3.13"') + }) + + test("orphan mise.lock (no mise.toml in any source) still writes lock", async () => { + // Edge case: lock present but no toml. Rare but valid. + await makeTeam({ settings: {} }) + await writeToml(join(workspaceTeamClaudeDir(), "mise.lock"), ` +[tools.node] +version = "20.18.0" +checksum = "abc" +`) + await makePersonal("alice", { defaultProfiles: [] }) + + const result = await composeLoopClaudeConfig("loop-mise-8", "alice") + expect(result.miseTomlPath).toBeNull() + expect(result.miseLockPath).toBeTruthy() + }) +}) + +// ─── 10. Marketplace source-drift detection ──────────────────────────── + +describe("sourcesMatch — marketplace URL drift detection", () => { + let sourcesMatch: any + + beforeAll(async () => { + sourcesMatch = (await import("../src/plugin-installer")).sourcesMatch + }) + + test("git: identical URL → match", () => { + expect(sourcesMatch( + { source: "git", url: "git@x.com/foo.git" }, + { source: "git", url: "git@x.com/foo.git" }, + )).toBe(true) + }) + + test("git: different URL (drift) → mismatch", () => { + expect(sourcesMatch( + { source: "git", url: "git@x.com/new.git" }, + { source: "git", url: "git@x.com/old.git" }, + )).toBe(false) + }) + + test("github: repo same → match", () => { + expect(sourcesMatch( + { source: "github", repo: "owner/x" }, + { source: "github", repo: "owner/x" }, + )).toBe(true) + }) + + test("github: legacy `repository` field == new `repo` field", () => { + expect(sourcesMatch( + { source: "github", repo: "owner/x" }, + { source: "github", repository: "owner/x" }, + )).toBe(true) + }) + + test("directory: same path → match", () => { + expect(sourcesMatch( + { source: "directory", path: "/a/b" }, + { source: "directory", path: "/a/b" }, + )).toBe(true) + }) + + test("directory: different path → mismatch", () => { + expect(sourcesMatch( + { source: "directory", path: "/new/path" }, + { source: "directory", path: "/old/path" }, + )).toBe(false) + }) + + test("different source types → mismatch", () => { + expect(sourcesMatch( + { source: "github", repo: "x/y" }, + { source: "git", url: "git@x.com/y.git" }, + )).toBe(false) + }) + + test("nullish guards", () => { + expect(sourcesMatch(null, null)).toBe(true) + expect(sourcesMatch(null, { source: "git" })).toBe(false) + expect(sourcesMatch({ source: "git" }, undefined)).toBe(false) + }) +}) + +// ─── 11. listProfiles ─────────────────────────────────────────────────── + +describe("listProfiles", () => { + test("returns dirs that have .claude/ subdir", async () => { + await makeProfile("role-a", {}) + await makeProfile("role-b", {}) + await mkdir(join(TEST_HOME, "context/knowledge/.loopat/profiles/not-a-profile"), { recursive: true }) + + const names = await listProfiles() + expect(names.sort()).toEqual(["role-a", "role-b"]) + }) + + test("empty when no profiles", async () => { + const names = await listProfiles() + expect(names).toEqual([]) + }) +}) + +// ─── 8. Path invariants (post-composition-model rewrite) ───────────────── + +describe("path invariants", () => { + test("personal .claude/ lives at personal/<user>/.loopat/.claude/", async () => { + // The personal tier mirrors the team tier: both put loopat-managed config + // under a `.loopat/` segment of the owning repo. Anything outside + // `.loopat/` in personal/ belongs to the user, not loopat. + const dir = personalClaudeDir("alice") + expect(dir.endsWith("/personal/alice/.loopat/.claude")).toBe(true) + }) + + test("team .claude/ lives at knowledge/.loopat/.claude/", async () => { + const dir = workspaceTeamClaudeDir() + expect(dir.endsWith("/knowledge/.loopat/.claude")).toBe(true) + }) + + test("profile .claude/ lives at knowledge/.loopat/profiles/<n>/.claude/", async () => { + const dir = workspaceProfileClaudeDir("role-eng") + expect(dir.endsWith("/knowledge/.loopat/profiles/role-eng/.claude")).toBe(true) + }) +}) + +// ─── 9. MCP server merge across tiers ──────────────────────────────────── + +describe("mergeSettings — mcpServers union", () => { + test("mcpServers from every tier appear in merged settings.json", async () => { + // Why this matters: session.ts reads mergedServers from the merged + // settings.json on disk and injects vault credentials into it before + // passing to SDK. If compose drops a tier's mcpServers, that server is + // invisible to the loop even if the tier declared it. + await makeTeam({ + settings: { mcpServers: { "team-mcp": { type: "http", url: "https://team.example/mcp" } } }, + }) + await makeProfile("oncall", { + settings: { mcpServers: { "pagerduty": { type: "http", url: "https://pd.example/mcp" } } }, + }) + await makePersonal("alice", { + defaultProfiles: ["oncall"], + settings: { mcpServers: { "personal-jira": { command: "node", args: ["./jira-mcp.js"] } } }, + }) + + const result = await composeLoopClaudeConfig("loop-mcp-merge", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + + expect(Object.keys(merged.mcpServers ?? {}).sort()).toEqual([ + "pagerduty", + "personal-jira", + "team-mcp", + ]) + expect(merged.mcpServers["team-mcp"]).toEqual({ type: "http", url: "https://team.example/mcp" }) + expect(merged.mcpServers["pagerduty"]).toEqual({ type: "http", url: "https://pd.example/mcp" }) + expect(merged.mcpServers["personal-jira"]).toEqual({ command: "node", args: ["./jira-mcp.js"] }) + }) + + test("personal-tier mcpServer with same name overrides team-tier (last-wins)", async () => { + // A user might point an MCP server at a personal endpoint while still + // benefiting from the team-declared config for the rest. + await makeTeam({ + settings: { mcpServers: { github: { type: "http", url: "https://team.example/gh" } } }, + }) + await makePersonal("alice", { + defaultProfiles: [], + settings: { mcpServers: { github: { type: "http", url: "https://alice.example/gh" } } }, + }) + + const result = await composeLoopClaudeConfig("loop-mcp-override", "alice") + const merged = JSON.parse(await readFile(result.settingsPath, "utf8")) + expect(merged.mcpServers.github.url).toBe("https://alice.example/gh") + }) + + test("mcpServer secrets are NEVER written to the composed settings.json", async () => { + // Locks in the auth/config split: sources declare server CONFIG; vault + // contributes CREDENTIALS at spawn time. The settings.json on disk must + // carry no apiKey/authorization values, even if a source accidentally + // included one. (Compose itself doesn't strip them — but our convention + // is "don't put secrets in .claude/settings.json". This test documents + // that the merge faithfully reflects what sources put in, so any leak + // would show up here as a deliberate-feeling failure pointing at the + // source tier rather than at compose.) + await makeTeam({ settings: { mcpServers: { svc: { type: "http", url: "https://svc/" } } } }) + await makePersonal("alice", { defaultProfiles: [] }) + const result = await composeLoopClaudeConfig("loop-mcp-no-secrets", "alice") + const text = await readFile(result.settingsPath, "utf8") + expect(text.toLowerCase()).not.toContain("authorization") + expect(text.toLowerCase()).not.toContain("apikey") + expect(text.toLowerCase()).not.toContain("bearer ") + }) +}) + +// ─── 10. CLAUDE.md tiered concatenation ────────────────────────────────── + +describe("composeFromPlan — CLAUDE.md", () => { + test("each tier's CLAUDE.md is included in the merged output", async () => { + await makeTeam({ claudeMd: "# Team rule\n\nAlways do X." }) + await makeProfile("role-eng", { claudeMd: "# Engineering\n\nUse strict TypeScript." }) + await makePersonal("alice", { + defaultProfiles: ["role-eng"], + claudeMd: "# Alice's notes\n\nLowercase variable names.", + }) + + const result = await composeLoopClaudeConfig("loop-claudemd", "alice") + const text = await readFile(result.claudeMdPath, "utf8") + expect(text).toContain("Always do X.") + expect(text).toContain("Use strict TypeScript.") + expect(text).toContain("Lowercase variable names.") + }) + + test("CLAUDE.md missing in some tiers is silently skipped", async () => { + await makeTeam({}) // no claudeMd + await makeProfile("role-eng", { claudeMd: "# Only profile speaks" }) + await makePersonal("alice", { defaultProfiles: ["role-eng"] }) // no claudeMd + + const result = await composeLoopClaudeConfig("loop-claudemd-partial", "alice") + const text = await readFile(result.claudeMdPath, "utf8") + expect(text).toContain("Only profile speaks") + }) +}) + +// ─── 11. installed_plugins.json (per-loop plugin version lock) ─────────── + +/** + * Helper: write a .claude/plugins/installed_plugins.json under the given + * .claude/ dir, in the CC-native shape. + */ +async function writeInstalledPlugins( + claudeDir: string, + plugins: Record<string, { version: string; gitCommitSha?: string; installPath?: string }>, +) { + const pluginsDir = join(claudeDir, "plugins") + await mkdir(pluginsDir, { recursive: true }) + const ip = { + version: 1, + plugins: Object.fromEntries( + Object.entries(plugins).map(([spec, v]) => [ + spec, + [{ + scope: "user", + installPath: v.installPath ?? `/host/cache/${spec}/${v.version}`, + version: v.version, + installedAt: "2026-05-24T00:00:00.000Z", + lastUpdated: "2026-05-24T00:00:00.000Z", + gitCommitSha: v.gitCommitSha ?? "0000000", + }], + ]), + ), + } + await writeFile(join(pluginsDir, "installed_plugins.json"), JSON.stringify(ip, null, 2)) +} + +describe("compose — installed_plugins.json (plugin version lock)", () => { + test("team's lock is snapshotted into loops/<id>/.claude/plugins/installed_plugins.json", async () => { + await makeTeam({ settings: { enabledPlugins: { "cicd@market": true } } }) + await writeInstalledPlugins(workspaceTeamClaudeDir(), { + "cicd@market": { version: "0.1.0", gitCommitSha: "abc123" }, + }) + await makePersonal("alice", { defaultProfiles: [] }) + + const result = await composeLoopClaudeConfig("loop-lock-1", "alice") + expect(result.installedPluginsPath).not.toBeNull() + const snapshot = JSON.parse(await readFile(result.installedPluginsPath!, "utf8")) + expect(snapshot.plugins["cicd@market"][0].version).toBe("0.1.0") + expect(snapshot.plugins["cicd@market"][0].gitCommitSha).toBe("abc123") + }) + + test("personal lock overrides team lock per spec (version + sha both replaced)", async () => { + await makeTeam({ settings: { enabledPlugins: { "cicd@market": true } } }) + await writeInstalledPlugins(workspaceTeamClaudeDir(), { + "cicd@market": { version: "0.1.0", gitCommitSha: "abc" }, + }) + await makePersonal("alice", { defaultProfiles: [] }) + await writeInstalledPlugins(personalClaudeDir("alice"), { + "cicd@market": { version: "9.9.9-alice-fork", gitCommitSha: "deadbeef" }, + }) + + const result = await composeLoopClaudeConfig("loop-lock-2", "alice") + const snapshot = JSON.parse(await readFile(result.installedPluginsPath!, "utf8")) + expect(snapshot.plugins["cicd@market"][0].version).toBe("9.9.9-alice-fork") + expect(snapshot.plugins["cicd@market"][0].gitCommitSha).toBe("deadbeef") + }) + + test("specs only in one tier coexist with specs only in another (per-spec union)", async () => { + await makeTeam({ settings: { enabledPlugins: { "team-only@m": true } } }) + await writeInstalledPlugins(workspaceTeamClaudeDir(), { + "team-only@m": { version: "1.0.0", gitCommitSha: "team-sha" }, + }) + await makePersonal("alice", { + defaultProfiles: [], + settings: { enabledPlugins: { "alice-only@m": true } }, + }) + await writeInstalledPlugins(personalClaudeDir("alice"), { + "alice-only@m": { version: "2.0.0", gitCommitSha: "alice-sha" }, + }) + + const result = await composeLoopClaudeConfig("loop-lock-3", "alice") + const snapshot = JSON.parse(await readFile(result.installedPluginsPath!, "utf8")) + expect(Object.keys(snapshot.plugins).sort()).toEqual(["alice-only@m", "team-only@m"]) + expect(snapshot.plugins["team-only@m"][0].version).toBe("1.0.0") + expect(snapshot.plugins["alice-only@m"][0].version).toBe("2.0.0") + }) + + test("no tier publishes installed_plugins.json → installedPluginsPath is null + file absent", async () => { + await makeTeam({ settings: { enabledPlugins: { "foo@m": true } } }) + await makePersonal("alice", { defaultProfiles: [] }) + + const result = await composeLoopClaudeConfig("loop-lock-4", "alice") + expect(result.installedPluginsPath).toBeNull() + expect(existsSync(join(loopClaudeDir("loop-lock-4"), "plugins", "installed_plugins.json"))).toBe(false) + }) + + test("re-running compose with the lock removed cleans up the previous snapshot", async () => { + // First compose: a lock exists + await makeTeam({ settings: { enabledPlugins: { "x@m": true } } }) + await writeInstalledPlugins(workspaceTeamClaudeDir(), { + "x@m": { version: "1.0.0" }, + }) + await makePersonal("alice", { defaultProfiles: [] }) + const first = await composeLoopClaudeConfig("loop-lock-5", "alice") + expect(first.installedPluginsPath).not.toBeNull() + + // Now wipe the lock at the source, re-compose: stale lock must be removed + await rm(join(workspaceTeamClaudeDir(), "plugins"), { recursive: true }) + const second = await composeLoopClaudeConfig("loop-lock-5", "alice") + expect(second.installedPluginsPath).toBeNull() + expect(existsSync(join(loopClaudeDir("loop-lock-5"), "plugins", "installed_plugins.json"))).toBe(false) + }) +}) + + diff --git a/server/test/config-vault.test.ts b/server/test/config-vault.test.ts new file mode 100644 index 00000000..b478ca32 --- /dev/null +++ b/server/test/config-vault.test.ts @@ -0,0 +1,212 @@ +/** + * L1: pure-function tests for config.ts vault-aware pieces: + * expandVars / providerEnvVarName / describeApiKeyRef / writeVaultEnv / + * deleteVaultEnv / loadPersonalConfig (with ${VAR} resolution). + */ +import { test, expect, describe, beforeAll, afterAll, beforeEach } from "bun:test" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { existsSync } from "node:fs" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-config-l1-${process.pid}` + +const { + expandVars, + providerEnvVarName, + describeApiKeyRef, + writeVaultEnv, + deleteVaultEnv, + loadPersonalConfig, + clearPersonalCache, +} = await import("../src/config") +const { + LOOPAT_HOME, + personalLoopatDir, + personalLoopatConfigPath, + personalVaultEnvsDir, + personalVaultEnvPath, +} = await import("../src/paths") + +const USER = "alice" + +async function resetUser() { + await rm(LOOPAT_HOME, { recursive: true, force: true }) + await mkdir(personalLoopatDir(USER), { recursive: true }) + clearPersonalCache(USER) +} + +beforeAll(resetUser) +afterAll(async () => { await rm(LOOPAT_HOME, { recursive: true, force: true }) }) + +describe("expandVars", () => { + test("substitutes single ${VAR}", () => { + expect(expandVars("${X}", { X: "hello" })).toBe("hello") + }) + test("substitutes multiple ${VAR} in one string", () => { + expect(expandVars("a${X}b${Y}c", { X: "1", Y: "2" })).toBe("a1b2c") + }) + test("unknown vars resolve to empty string", () => { + expect(expandVars("${MISSING}", {})).toBe("") + expect(expandVars("pre${MISSING}post", {})).toBe("prepost") + }) + test("literal string without $ passes through unchanged", () => { + expect(expandVars("plain literal", { X: "ignored" })).toBe("plain literal") + }) + test("empty input → empty output", () => { + expect(expandVars("", { X: "v" })).toBe("") + }) + test("does not substitute malformed refs", () => { + expect(expandVars("$X", { X: "v" })).toBe("$X") // missing braces + expect(expandVars("${1BAD}", { "1BAD": "v" })).toBe("${1BAD}") // var name must start with letter + }) +}) + +describe("providerEnvVarName", () => { + test("uppercases simple name + _API_KEY suffix", () => { + expect(providerEnvVarName("anthropic")).toBe("ANTHROPIC_API_KEY") + expect(providerEnvVarName("Anthropic")).toBe("ANTHROPIC_API_KEY") + }) + test("normalizes non-alphanumeric to underscore", () => { + expect(providerEnvVarName("deep-seek")).toBe("DEEP_SEEK_API_KEY") + expect(providerEnvVarName("My Provider")).toBe("MY_PROVIDER_API_KEY") + expect(providerEnvVarName("foo.bar")).toBe("FOO_BAR_API_KEY") + }) + test("trims leading/trailing underscores from sanitized portion", () => { + expect(providerEnvVarName("-foo-")).toBe("FOO_API_KEY") + }) + test("falls back to PROVIDER for all-junk names", () => { + expect(providerEnvVarName("---")).toBe("PROVIDER_API_KEY") + }) +}) + +describe("describeApiKeyRef", () => { + beforeEach(resetUser) + + test("empty / undefined → kind=empty exists=false", () => { + expect(describeApiKeyRef(undefined, USER).kind).toBe("empty") + expect(describeApiKeyRef("", USER).kind).toBe("empty") + expect(describeApiKeyRef(undefined, USER).exists).toBe(false) + }) + + test("literal string (no ${...}) → kind=literal exists=true", () => { + const d = describeApiKeyRef("sk-literal", USER) + expect(d.kind).toBe("literal") + expect(d.exists).toBe(true) + }) + + test("single ${VAR} → kind=var, exists tracks file presence", async () => { + const d1 = describeApiKeyRef("${MY_KEY}", USER) + expect(d1.kind).toBe("var") + expect(d1.varName).toBe("MY_KEY") + expect(d1.exists).toBe(false) + expect(d1.path).toContain("envs/MY_KEY") + + // Once the file is written, exists flips true. + await writeVaultEnv(USER, "default", "MY_KEY", "value") + const d2 = describeApiKeyRef("${MY_KEY}", USER) + expect(d2.exists).toBe(true) + }) + + test("mixed template (literal+ref or multi-ref) → kind=mixed", () => { + expect(describeApiKeyRef("Bearer ${X}", USER).kind).toBe("mixed") + expect(describeApiKeyRef("${X}${Y}", USER).kind).toBe("mixed") + }) +}) + +describe("writeVaultEnv / deleteVaultEnv", () => { + beforeEach(resetUser) + + test("writes value to envs/<name> with trailing newline + creates parent", async () => { + const r = await writeVaultEnv(USER, "default", "FOO", "bar") + expect(r.ok).toBe(true) + if (!r.ok) throw new Error("type narrowing") + expect(existsSync(r.path)).toBe(true) + const contents = await Bun.file(r.path).text() + expect(contents).toBe("bar\n") + }) + + test("rejects invalid env name", async () => { + const r = await writeVaultEnv(USER, "default", "bad-name", "v") + expect(r.ok).toBe(false) + }) + + test("delete removes the file; deleting missing is a no-op", async () => { + await writeVaultEnv(USER, "default", "TEMP", "x") + expect(existsSync(personalVaultEnvPath(USER, "default", "TEMP"))).toBe(true) + await deleteVaultEnv(USER, "default", "TEMP") + expect(existsSync(personalVaultEnvPath(USER, "default", "TEMP"))).toBe(false) + // again — no throw + await deleteVaultEnv(USER, "default", "TEMP") + }) + + test("ignores invalid names on delete (no-op, no throw)", async () => { + await deleteVaultEnv(USER, "default", "bad-name") // must not throw + }) +}) + +describe("loadPersonalConfig — ${VAR} resolution in provider.apiKey", () => { + beforeEach(resetUser) + + async function writeConfig(disk: any) { + await mkdir(personalLoopatDir(USER), { recursive: true }) + await writeFile(personalLoopatConfigPath(USER), JSON.stringify(disk, null, 2)) + } + + test("provider apiKey ${VAR} resolves from vault envs", async () => { + await writeVaultEnv(USER, "default", "IDEALAB_API_KEY", "sk-secret-xyz") + await writeConfig({ + providers: { + default: "idealab", + idealab: { + baseUrl: "https://idealab.example.com", + model: "claude-opus-4-7", + apiKey: "${IDEALAB_API_KEY}", + }, + }, + }) + const cfg = await loadPersonalConfig(USER, "default") + expect(cfg.providers.idealab.apiKey).toBe("sk-secret-xyz") + expect(cfg.vaultEnvs.IDEALAB_API_KEY).toBe("sk-secret-xyz") + }) + + test("missing ${VAR} resolves to empty string (provider effectively disabled)", async () => { + await writeConfig({ + providers: { + default: "anthropic", + anthropic: { + baseUrl: "https://api.anthropic.com", + model: "x", + apiKey: "${NOT_SET_ANYWHERE}", + }, + }, + }) + const cfg = await loadPersonalConfig(USER, "default") + expect(cfg.providers.anthropic.apiKey).toBe("") + }) + + test("literal apiKey (no ${...}) passes through unchanged", async () => { + await writeConfig({ + providers: { + default: "anthropic", + anthropic: { baseUrl: "x", model: "y", apiKey: "sk-literal-abc" }, + }, + }) + const cfg = await loadPersonalConfig(USER, "default") + expect(cfg.providers.anthropic.apiKey).toBe("sk-literal-abc") + }) + + test("vaultEnvs exposes the loaded env map on cfg", async () => { + await writeVaultEnv(USER, "default", "A_KEY", "av") + await writeVaultEnv(USER, "default", "B_KEY", "bv") + await writeConfig({ providers: { default: "x", x: { baseUrl: "u", model: "m" } } }) + const cfg = await loadPersonalConfig(USER, "default") + expect(cfg.vaultEnvs).toMatchObject({ A_KEY: "av", B_KEY: "bv" }) + }) + + test("missing config.json → in-memory template (no write)", async () => { + await rm(personalLoopatConfigPath(USER), { force: true }) + const cfg = await loadPersonalConfig(USER, "default") + expect(cfg.providers).toBeDefined() + expect(existsSync(personalLoopatConfigPath(USER))).toBe(false) + }) +}) diff --git a/server/test/driver-handoff.test.ts b/server/test/driver-handoff.test.ts new file mode 100644 index 00000000..aafd5d46 --- /dev/null +++ b/server/test/driver-handoff.test.ts @@ -0,0 +1,189 @@ +/** + * L1+L3: driver handoff (RFD) — the loop's "driver" is the user whose vault + * + personal config the sandbox runs under. Distinct from `createdBy`. + * + * - alice creates a loop → driver = alice (default = createdBy) + * - alice requests-for-drive → rfdRequestedAt set, sandbox torn down + * - bob takes over → driver = bob, pendingDriverNote set for first message + * - subsequent spawns: vault, personal CLAUDE.md, apiKey are BOB's not alice's + * + * We don't test the HTTP /api/loops/:id/drive endpoint here (covered in L2 if + * needed). We test the effectiveDriver primitive + downstream side effects: + * loadPersonalConfig and bwrap targeting follow the driver, not the creator. + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-driver-${process.pid}` + +const { effectiveDriver, isDriver } = await import("../src/loops") +const { loadPersonalConfig, clearPersonalCache } = await import("../src/config") +const { buildPodmanCreateArgs } = await import("../src/podman") +const { + LOOPAT_HOME, + loopWorkdir, + loopClaudeDir, + loopContextKnowledge, + loopContextNotes, + personalLoopatDir, + personalLoopatConfigPath, + personalVaultEnvsDir, + personalVaultEnvPath, +} = await import("../src/paths") +const TEST_HOME = LOOPAT_HOME + +async function seedUser(user: string, apiKey: string) { + await mkdir(personalLoopatDir(user), { recursive: true }) + await mkdir(personalVaultEnvsDir(user, "default"), { recursive: true }) + await writeFile(personalVaultEnvPath(user, "default", "ANTHROPIC_API_KEY"), apiKey + "\n") + await writeFile(personalLoopatConfigPath(user), JSON.stringify({ + providers: { + default: "anthropic", + anthropic: { baseUrl: `https://${user}.example`, model: "x", apiKey: "${ANTHROPIC_API_KEY}" }, + }, + })) + clearPersonalCache(user) +} + +async function setup() { + await rm(TEST_HOME, { recursive: true, force: true }) + await mkdir(TEST_HOME, { recursive: true }) + await writeFile(join(TEST_HOME, "config.json"), "{}") + await seedUser("alice", "sk-alice-handoff") + await seedUser("bob", "sk-bob-handoff") +} + +beforeAll(setup) +afterAll(() => rm(TEST_HOME, { recursive: true, force: true })) + +describe("effectiveDriver primitive", () => { + test("missing driver field → falls back to createdBy", () => { + expect(effectiveDriver({ createdBy: "alice" })).toBe("alice") + }) + + test("driver field set → that wins over createdBy", () => { + expect(effectiveDriver({ createdBy: "alice", driver: "bob" })).toBe("bob") + }) + + test("isDriver matches effective, not createdBy", () => { + const meta = { createdBy: "alice", driver: "bob" } + expect(isDriver(meta, "bob")).toBe(true) + expect(isDriver(meta, "alice")).toBe(false) + }) + + test("legacy loop with no driver → createdBy is the driver", () => { + const meta = { createdBy: "alice" } // no driver field — pre-handoff era + expect(isDriver(meta, "alice")).toBe(true) + expect(isDriver(meta, "bob")).toBe(false) + }) +}) + +describe("after handoff — spawn uses driver's vault, not createdBy's", () => { + test("loadPersonalConfig(driver) returns driver's apiKey", async () => { + // alice created, bob now drives + const driverCfg = await loadPersonalConfig("bob", "default") + expect(driverCfg.providers.anthropic.apiKey).toBe("sk-bob-handoff") + // alice still has her own apiKey, but a session for THIS loop would use bob's + const creatorCfg = await loadPersonalConfig("alice", "default") + expect(creatorCfg.providers.anthropic.apiKey).toBe("sk-alice-handoff") + }) + + test("podman argv binds driver's personal dir, not createdBy's", async () => { + const loopId = "11111111-2222-3333-4444-111111111111" + await mkdir(loopWorkdir(loopId), { recursive: true }) + await mkdir(loopClaudeDir(loopId), { recursive: true }) + await mkdir(loopContextKnowledge(loopId), { recursive: true }) + await mkdir(loopContextNotes(loopId), { recursive: true }) + + // session.ts would call buildPodmanCreateArgs(loopId, effectiveDriver(meta), ...) + // — simulate post-handoff (driver=bob, createdBy=alice). + const argv = await buildPodmanCreateArgs({ + loopId, + createdBy: "bob", + vaultName: "default", + }) + const personalBinds: string[] = [] + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--volume" && argv[i + 1].includes("/personal/")) { + personalBinds.push(argv[i + 1].split(":")[0]) + } + } + expect(personalBinds.every(p => p.includes("/personal/bob"))).toBe(true) + expect(personalBinds.some(p => p.includes("/personal/alice"))).toBe(false) + }) + + test("driver's vault envs reach sandbox --env after handoff", async () => { + const loopId = "11111111-2222-3333-4444-222222222222" + await mkdir(loopWorkdir(loopId), { recursive: true }) + await mkdir(loopClaudeDir(loopId), { recursive: true }) + await mkdir(loopContextKnowledge(loopId), { recursive: true }) + await mkdir(loopContextNotes(loopId), { recursive: true }) + + const bobCfg = await loadPersonalConfig("bob", "default") + const argv = await buildPodmanCreateArgs({ + loopId, + createdBy: "bob", + vaultName: "default", + extraEnv: { ...bobCfg.vaultEnvs, ANTHROPIC_API_KEY: bobCfg.providers.anthropic.apiKey }, + }) + + const findEnv = (k: string) => { + const prefix = `${k}=` + for (let i = 0; i < argv.length - 1; i++) { + if (argv[i] === "--env" && argv[i + 1].startsWith(prefix)) { + return argv[i + 1].slice(prefix.length) + } + } + return undefined + } + expect(findEnv("ANTHROPIC_API_KEY")).toBe("sk-bob-handoff") + // alice's key must not leak — search both env values and any other arg + for (let i = 0; i < argv.length; i++) { + expect(argv[i].includes("sk-alice-handoff")).toBe(false) + } + }) +}) + +describe("RFD (request-for-drive) state — meta-level", () => { + test("rfdRequestedAt set + driver unchanged = waiting-for-take-over", () => { + const meta = { + createdBy: "alice", + driver: "alice", + rfdRequestedAt: new Date().toISOString(), + rfdRequestedBy: "alice", + } + // effectiveDriver still alice (until someone drives) + expect(effectiveDriver(meta)).toBe("alice") + // But the session should be torn down — UI gates writes by checking rfd state + expect(meta.rfdRequestedAt).toBeTruthy() + }) + + test("after takeover: rfd cleared, driver replaced, history appended", () => { + const meta = { + createdBy: "alice", + driver: "bob", + driverHistory: [ + { driver: "alice", since: "2026-05-01T00:00:00.000Z" }, + { driver: "bob", since: "2026-05-25T00:00:00.000Z" }, + ], + // rfdRequestedAt cleared by the /drive endpoint + } + expect(effectiveDriver(meta)).toBe("bob") + expect(meta.driverHistory.length).toBeGreaterThan(1) + // The most recent entry must match the current driver + const last = meta.driverHistory[meta.driverHistory.length - 1] + expect(last.driver).toBe(meta.driver) + }) + + test("pendingDriverNote shape — consumed once on next user message", () => { + const meta = { + createdBy: "alice", + driver: "bob", + pendingDriverNote: { from: "alice", to: "bob", at: new Date().toISOString() }, + } + // It's a structural contract — sendUserText reads then clears. + expect(meta.pendingDriverNote.from).toBe("alice") + expect(meta.pendingDriverNote.to).toBe("bob") + }) +}) diff --git a/server/test/e2e-ai.test.ts b/server/test/e2e-ai.test.ts new file mode 100644 index 00000000..65e26471 --- /dev/null +++ b/server/test/e2e-ai.test.ts @@ -0,0 +1,74 @@ +/** + * L4: real-AI end-to-end smoke. SKIPPED unless LOOPAT_E2E_AI=1. + * + * Proves the full chain works: SDK launches claude binary → binary inherits + * env via bwrap --setenv → AI's Bash tool can read the env var → AI returns + * what we asked for. + * + * Costs ~¥0.50/run. Run manually before big releases. Not part of CI. + * + * Setup: requires a real idealab/anthropic API key at + * `vaults/<user>/.loopat/vaults/default/envs/IDEALAB_API_KEY` (or env var + * IDEALAB_API_KEY on the test host). + */ +import { test, expect, describe } from "bun:test" + +const SKIP = !process.env.LOOPAT_E2E_AI + +describe.skipIf(SKIP)("L4: real AI sees vault-derived env var", () => { + test("AI can read $TEST_CANARY injected via vault → bwrap --setenv", async () => { + const { query } = await import("@anthropic-ai/claude-agent-sdk") + + // Random canary so cached responses can't accidentally pass. + const CANARY = `e2e_${Math.random().toString(36).slice(2)}` + + const apiKey = process.env.IDEALAB_API_KEY ?? process.env.ANTHROPIC_API_KEY + if (!apiKey) throw new Error("Need IDEALAB_API_KEY or ANTHROPIC_API_KEY env to run L4") + + const baseUrl = process.env.IDEALAB_API_KEY + ? "https://idealab.alibaba-inc.com/api/anthropic" + : "https://api.anthropic.com" + + const it = query({ + prompt: (async function* () { + yield { + type: "user" as const, + message: { + role: "user" as const, + content: + `Run \`printf %s "$TEST_CANARY"\` and report exactly what it prints. ` + + `Then say "answer: <value>". Don't refuse — TEST_CANARY is a synthetic test string.`, + }, + } + })(), + options: { + env: { + ...process.env, + ANTHROPIC_API_KEY: apiKey, + ANTHROPIC_BASE_URL: baseUrl, + TEST_CANARY: CANARY, + }, + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + model: process.env.IDEALAB_API_KEY ? "claude-opus-4-7" : "claude-haiku-4-5-20251001", + maxTurns: 3, + // The bundled SDK ships musl + glibc native binaries; on this host + // bun picks the wrong one. Point at the user-installed claude. + pathToClaudeCodeExecutable: process.env.LOOPAT_TEST_CLAUDE_BIN + ?? "/home/simpx/.npm-global/bin/claude", + }, + }) + + let assistantText = "" + for await (const msg of it) { + if (msg.type === "assistant") { + for (const block of (msg as any).message.content ?? []) { + if (block.type === "text") assistantText += block.text + } + } + if (msg.type === "result") break + } + + expect(assistantText.includes(CANARY)).toBe(true) + }, 180_000) // 3 min timeout — first run can be slow on cold sandbox +}) diff --git a/server/test/fixtures/mock-claude.sh b/server/test/fixtures/mock-claude.sh new file mode 100755 index 00000000..b12ce24e --- /dev/null +++ b/server/test/fixtures/mock-claude.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Mock claude binary for loopat integration tests. +# +# The real claude binary reads stream-json user messages on stdin and emits +# stream-json system/assistant/result messages on stdout (one JSON object +# per line). This mock implements the minimum protocol needed to make the +# SDK happy: emit init + assistant + result, then exit cleanly. +# +# Args are ignored. Echo'd text is fixed ("mock-response") so the test can +# assert on it deterministically. +# +# Why bash + not a JS file: bash is in our minimal sandbox image and needs +# zero install. Keeps the test fixture portable. + +set -e + +# Drain one full user message line from stdin. The SDK pushes +# `{"type":"user",...}\n` for each user input. Without this read we'd +# race: SDK writes input, mock exits before reading it, EPIPE on SDK side. +IFS= read -r USER_LINE || true + +# Emit init message (minimal but well-formed). +printf '%s\n' '{"type":"system","subtype":"init","session_id":"mock-session","cwd":"/","tools":[],"mcp_servers":[],"model":"mock-model","permissionMode":"bypassPermissions","slash_commands":[],"apiKeySource":"ANTHROPIC_API_KEY","claude_code_version":"0.0.0-mock","output_style":"default","agents":[],"skills":[],"plugins":[],"analytics_disabled":true,"uuid":"mock-init-uuid","memory_paths":{"auto":"/loopat/context/personal/memory/"}}' + +# Emit assistant text response. +printf '%s\n' '{"type":"assistant","message":{"id":"msg_mock","type":"message","role":"assistant","model":"mock-model","content":[{"type":"text","text":"mock-response-OK"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}},"parent_tool_use_id":null,"session_id":"mock-session","uuid":"mock-asst-uuid"}' + +# Emit result (signals SDK that the turn is complete). +printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"duration_ms":1,"duration_api_ms":1,"num_turns":1,"result":"mock-response-OK","session_id":"mock-session","total_cost_usd":0,"usage":{"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard"},"modelUsage":{},"permission_denials":[],"terminal_reason":"completed","uuid":"mock-result-uuid"}' + +# Mimic real claude: stay alive after result. SDK either pushes another +# user message (and we'd handle a multi-turn loop) or eventually closes +# stdin → we exit. Reading stdin until EOF keeps us patient. +while IFS= read -r _; do :; done +exit 0 diff --git a/server/test/host-exec.test.ts b/server/test/host-exec.test.ts new file mode 100644 index 00000000..5b964b99 --- /dev/null +++ b/server/test/host-exec.test.ts @@ -0,0 +1,67 @@ +/** + * host-cli proxy: a cli runs on the host, in a per-loop host workdir, with the + * exit code / stdin / stdout propagated; a shim is generated per declared cli. + * No whitelist — mounting the socket is the trust decision. + */ +import { test, expect, beforeAll, afterAll } from "bun:test" +import { mkdtemp, readFile, rm, stat } from "node:fs/promises" +import { existsSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +let home: string +let hostExec: any +let cwd: string + +beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), "loopat-hostexec-")) + process.env.LOOPAT_HOME = home + hostExec = await import("../src/host-exec.ts") + cwd = join(home, "host-workdir") +}) + +afterAll(async () => { + await rm(home, { recursive: true, force: true }) +}) + +test("a cli runs on the host and returns stdout", async () => { + const r = await hostExec.runHostCli({ cli: "echo", args: ["hello", "world"], cwd }) + expect(r.ok).toBe(true) + expect(r.stdout.trim()).toBe("hello world") + expect(r.exitCode).toBe(0) +}) + +test("runs in the per-loop host workdir", async () => { + const r = await hostExec.runHostCli({ cli: "touch", args: ["marker"], cwd }) + expect(r.ok).toBe(true) + expect(existsSync(join(cwd, "marker"))).toBe(true) // landed in the loop's host workdir +}) + +test("missing host cli reports clearly", async () => { + const r = await hostExec.runHostCli({ cli: "no-such-cli-xyz", args: [], cwd }) + expect(r.ok).toBe(false) + expect(r.error).toContain("host has no") +}) + +test("exit code propagates", async () => { + const r = await hostExec.runHostCli({ cli: "sh", args: ["-c", "exit 3"], cwd }) + expect(r.ok).toBe(true) + expect(r.exitCode).toBe(3) +}) + +test("stdin is forwarded", async () => { + const r = await hostExec.runHostCli({ cli: "cat", args: [], cwd, stdin: "piped-in\n" }) + expect(r.ok).toBe(true) + expect(r.stdout.trim()).toBe("piped-in") +}) + +test("writeHostShims writes an executable shim per cli", async () => { + const binDir = join(home, "bin") + await hostExec.writeHostShims(binDir, ["aone", "company-cli"]) + for (const cli of ["aone", "company-cli"]) { + const p = join(binDir, cli) + expect(existsSync(p)).toBe(true) + expect(await readFile(p, "utf8")).toContain(`exec loopat-host "${cli}"`) + expect((await stat(p)).mode & 0o111).toBeGreaterThan(0) // executable + } +}) diff --git a/server/test/lifecycle-frozen.test.ts b/server/test/lifecycle-frozen.test.ts new file mode 100644 index 00000000..6131fbaf --- /dev/null +++ b/server/test/lifecycle-frozen.test.ts @@ -0,0 +1,131 @@ +/** + * L3: principle 1 — "loops are frozen at creation". + * + * composeLoopClaudeConfig writes the merged .claude/ once at loop creation. + * Admin pushes to team knowledge after that do NOT affect the existing loop's + * settings.json / CLAUDE.md / skills snapshot. New loops get the new state. + * + * Why this matters: lets users keep a long-running loop on a known toolchain + * even as the team evolves the team-tier config underneath them. + * + * session.ts re-runs compose ONLY when the snapshot is missing (self-heal for + * pre-snapshot loops). The test asserts: re-running compose on a loop that + * already has a snapshot is a no-op (overwrites with same content, but more + * importantly, the orchestrator never calls it again unless missing). + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile, readFile } from "node:fs/promises" +import { existsSync } from "node:fs" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-frozen-${process.pid}` + +const { composeFromPlan } = await import("../src/compose") +const { + LOOPAT_HOME, + loopClaudeDir, + loopWorkdir, + loopContextKnowledge, + loopContextNotes, + personalDir, + personalLoopatDir, + workspaceTeamClaudeDir, + workspaceTeamSettingsPath, + workspaceTeamClaudeMdPath, +} = await import("../src/paths") +const TEST_HOME = LOOPAT_HOME + +const USER = "alice" +const LOOP_ID = "ffffffff-0000-1111-2222-333333333333" + +async function setupLoop() { + await rm(TEST_HOME, { recursive: true, force: true }) + await mkdir(loopWorkdir(LOOP_ID), { recursive: true }) + await mkdir(loopClaudeDir(LOOP_ID), { recursive: true }) + await mkdir(loopContextKnowledge(LOOP_ID), { recursive: true }) + await mkdir(loopContextNotes(LOOP_ID), { recursive: true }) + await mkdir(personalLoopatDir(USER), { recursive: true }) + await writeFile(join(TEST_HOME, "config.json"), "{}") +} + +async function writeTeam(claudeMd: string, mcpServers: Record<string, any>) { + await mkdir(workspaceTeamClaudeDir(), { recursive: true }) + await writeFile(workspaceTeamClaudeMdPath(), claudeMd) + await writeFile(workspaceTeamSettingsPath(), JSON.stringify({ mcpServers })) +} + +beforeAll(setupLoop) +afterAll(() => rm(TEST_HOME, { recursive: true, force: true })) + +describe("frozen snapshot — compose writes once, downstream changes don't affect it", () => { + test("initial compose materializes team CLAUDE.md + settings.json", async () => { + await writeTeam("# TEAM_V1", { coop: { type: "http", url: "https://v1.example/mcp" } }) + const plan = { + user: USER, + claudeSources: [ + { source: "team", dir: workspaceTeamClaudeDir() }, + ], + } + await composeFromPlan(LOOP_ID, plan as any) + const md = await readFile(join(loopClaudeDir(LOOP_ID), "CLAUDE.md"), "utf8") + const settings = JSON.parse(await readFile(join(loopClaudeDir(LOOP_ID), "settings.json"), "utf8")) + expect(md.includes("TEAM_V1")).toBe(true) + expect(settings.mcpServers.coop.url).toBe("https://v1.example/mcp") + }) + + test("admin pushes a v2 team — loop snapshot unchanged until re-composed", async () => { + await writeTeam("# TEAM_V2_BREAKING_CHANGE", { coop: { type: "http", url: "https://v2.example/mcp" } }) + // ↑ this simulates admin git-pushing knowledge; we DO NOT call compose again. + // session.ts only re-runs compose when the snapshot is MISSING (self-heal). + const md = await readFile(join(loopClaudeDir(LOOP_ID), "CLAUDE.md"), "utf8") + const settings = JSON.parse(await readFile(join(loopClaudeDir(LOOP_ID), "settings.json"), "utf8")) + // Loop still on v1 — the v2 push is invisible to this loop + expect(md.includes("TEAM_V1")).toBe(true) + expect(md.includes("TEAM_V2_BREAKING_CHANGE")).toBe(false) + expect(settings.mcpServers.coop.url).toBe("https://v1.example/mcp") + }) + + test("a NEW loop created after the admin push picks up v2", async () => { + const NEW_LOOP = "ffffffff-1111-1111-2222-333333333333" + await mkdir(loopWorkdir(NEW_LOOP), { recursive: true }) + await mkdir(loopClaudeDir(NEW_LOOP), { recursive: true }) + await mkdir(loopContextKnowledge(NEW_LOOP), { recursive: true }) + await mkdir(loopContextNotes(NEW_LOOP), { recursive: true }) + const plan = { + user: USER, + claudeSources: [ + { source: "team", dir: workspaceTeamClaudeDir() }, + ], + } + await composeFromPlan(NEW_LOOP, plan as any) + const md = await readFile(join(loopClaudeDir(NEW_LOOP), "CLAUDE.md"), "utf8") + expect(md.includes("TEAM_V2_BREAKING_CHANGE")).toBe(true) + expect(md.includes("TEAM_V1")).toBe(false) + }) + + test("re-running compose on the OLD loop DOES regenerate (caller's choice)", async () => { + // compose itself is idempotent by *current source state*. Snapshot freeze + // is enforced upstream (session.ts: only compose when snapshot absent). + // We document this here so it's not surprising. + const plan = { + user: USER, + claudeSources: [ + { source: "team", dir: workspaceTeamClaudeDir() }, + ], + } + await composeFromPlan(LOOP_ID, plan as any) + const md = await readFile(join(loopClaudeDir(LOOP_ID), "CLAUDE.md"), "utf8") + // After explicit re-compose, the old loop got the v2 too. This is by design + // — compose is a "materialize current state" op, not a "stay frozen" op. + expect(md.includes("TEAM_V2_BREAKING_CHANGE")).toBe(true) + }) +}) + +describe("frozen snapshot — session.ts skip semantics (snapshot-presence guard)", () => { + test("settings.json existence acts as the snapshot sentinel", async () => { + // The actual gate lives in session.ts:331 — `if (!existsSync(composedSettingsPath))`. + // We assert the file IS at the expected path after compose, so the gate sees it. + const composedSettingsPath = join(loopClaudeDir(LOOP_ID), "settings.json") + expect(existsSync(composedSettingsPath)).toBe(true) + }) +}) diff --git a/server/test/mcp-oauth.test.ts b/server/test/mcp-oauth.test.ts new file mode 100644 index 00000000..622f3ced --- /dev/null +++ b/server/test/mcp-oauth.test.ts @@ -0,0 +1,138 @@ +/** + * L1: pure-function tests for parseBearerEnvName. Full OAuth flow + * (HTTP discovery + DCR + token exchange) lives in L2. + */ +import { test, expect, describe } from "bun:test" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-mcp-oauth-l1-${process.pid}` + +const { parseBearerEnvName } = await import("../src/mcp-oauth") + +describe("parseBearerEnvName", () => { + test("standard bearer template → captures env name", () => { + expect(parseBearerEnvName({ + type: "http", + url: "https://x.example/mcp", + headers: { Authorization: "Bearer ${GH_TOKEN}" }, + } as any)).toBe("GH_TOKEN") + }) + + test("authorization header is case-insensitive", () => { + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { authorization: "Bearer ${X}" }, + } as any)).toBe("X") + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { AUTHORIZATION: "Bearer ${X}" }, + } as any)).toBe("X") + }) + + test("'Bearer' keyword is case-insensitive", () => { + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { Authorization: "bearer ${TOKEN}" }, + } as any)).toBe("TOKEN") + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { Authorization: "BEARER ${TOKEN}" }, + } as any)).toBe("TOKEN") + }) + + test("trims surrounding whitespace", () => { + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { Authorization: " Bearer ${MY_TOKEN} " }, + } as any)).toBe("MY_TOKEN") + }) + + test("rejects half-static templates (Bearer ${X}_static)", () => { + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { Authorization: "Bearer ${PREFIX}_suffix" }, + } as any)).toBeNull() + }) + + test("rejects multi-ref templates (Bearer ${A}${B})", () => { + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { Authorization: "Bearer ${A}${B}" }, + } as any)).toBeNull() + }) + + test("rejects non-Bearer schemes", () => { + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { Authorization: "Basic ${CREDS}" }, + } as any)).toBeNull() + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { Authorization: "Token ${X}" }, + } as any)).toBeNull() + }) + + test("rejects when Authorization header is missing entirely", () => { + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { "X-Api-Key": "${KEY}" }, + } as any)).toBeNull() + }) + + test("rejects when no headers at all", () => { + expect(parseBearerEnvName({ type: "http", url: "https://x" } as any)).toBeNull() + }) + + test("rejects when value isn't a string", () => { + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { Authorization: 42 as any }, + } as any)).toBeNull() + }) + + test("rejects literal-token values (no env ref)", () => { + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { Authorization: "Bearer abc123" }, + } as any)).toBeNull() + }) + + test("rejects invalid env name characters", () => { + // env var must start with letter/underscore + be uppercase[A-Z0-9_] + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { Authorization: "Bearer ${lowercase}" }, + } as any)).toBeNull() + expect(parseBearerEnvName({ + type: "http", + url: "https://x", + headers: { Authorization: "Bearer ${1STARTS_WITH_DIGIT}" }, + } as any)).toBeNull() + }) + + test("null / undefined server returns null", () => { + expect(parseBearerEnvName(null)).toBeNull() + expect(parseBearerEnvName(undefined)).toBeNull() + }) + + test("stdio server (no headers) returns null", () => { + expect(parseBearerEnvName({ + type: "stdio", + command: "node", + args: ["server.js"], + env: { GITHUB_TOKEN: "${GH_TOKEN}" }, + } as any)).toBeNull() + }) +}) diff --git a/server/test/mcp-shadowing.test.ts b/server/test/mcp-shadowing.test.ts new file mode 100644 index 00000000..a6fd888b --- /dev/null +++ b/server/test/mcp-shadowing.test.ts @@ -0,0 +1,150 @@ +/** + * L2+L3: MCP server tier shadowing — when team + personal define the same + * server name, what reaches the spawned binary, and what does the API + * report? + * + * Merge semantics: server-key-granularity whole-object replacement. Personal + * entry shadows team's same-named entry completely (URLs, headers, all). + * + * Surface check: /api/mcp-servers reads the loop's merged settings.json + * (single source) and returns a flat `servers` list. No tier breakdown, + * because tokens are vault envs — the popover doesn't need provenance. + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile, readFile } from "node:fs/promises" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-mcp-shadow-${process.pid}` +process.env.PORT = "0" +process.env.LOOPAT_SERVE_PORT = "0" + +const HOME = process.env.LOOPAT_HOME! +await rm(HOME, { recursive: true, force: true }) +await mkdir(HOME, { recursive: true }) +await writeFile(join(HOME, "config.json"), JSON.stringify({ + knowledge: { git: "" }, notes: { git: "" }, repos: [], providers: {}, +})) + +const { app } = await import("../src/index") +const { composeFromPlan } = await import("../src/compose") +const { + loopWorkdir, loopClaudeDir, loopContextKnowledge, loopContextNotes, + personalDir, personalLoopatDir, personalClaudeDir, personalSettingsPath, + workspaceTeamClaudeDir, workspaceTeamSettingsPath, +} = await import("../src/paths") +const { createUser, createSession, COOKIE_NAME } = await import("../src/auth") + +const USER = "shadowtester" +let COOKIE = "" + +async function authed(): Promise<Record<string, string>> { + return { Cookie: `${COOKIE_NAME}=${COOKIE}` } +} + +beforeAll(async () => { + try { await createUser({ id: USER, password: "pw" }) } catch {} + COOKIE = createSession(USER) + await mkdir(personalLoopatDir(USER), { recursive: true }) + await mkdir(personalClaudeDir(USER), { recursive: true }) + // Team-tier MCPs: github + linear + await mkdir(workspaceTeamClaudeDir(), { recursive: true }) + await writeFile(workspaceTeamSettingsPath(), JSON.stringify({ + mcpServers: { + github: { type: "http", url: "https://team.github/mcp", headers: { Authorization: "Bearer ${GH_TOKEN}" } }, + linear: { type: "http", url: "https://team.linear/mcp" }, + }, + })) + // Personal-tier MCPs: github (shadow), private-only (no shadow) + await writeFile(personalSettingsPath(USER), JSON.stringify({ + mcpServers: { + github: { type: "http", url: "https://my-fork.github/mcp" }, + "private-only": { type: "stdio", command: "echo", args: ["personal"] }, + }, + })) +}) + +afterAll(async () => { await rm(HOME, { recursive: true, force: true }) }) + +async function setupLoop(loopId: string): Promise<void> { + await mkdir(loopWorkdir(loopId), { recursive: true }) + await mkdir(loopClaudeDir(loopId), { recursive: true }) + await mkdir(loopContextKnowledge(loopId), { recursive: true }) + await mkdir(loopContextNotes(loopId), { recursive: true }) + await composeFromPlan(loopId, { + user: USER, + claudeSources: [ + { source: "team", dir: workspaceTeamClaudeDir() }, + { source: `personal:${USER}`, dir: personalDir(USER) }, + ], + } as any) +} + +describe("MCP merge semantics — composed loop settings.json", () => { + test("personal entry wins over team (last-wins at server-key granularity)", async () => { + const loopId = "shadowsh-0000-0000-0000-000000000001" + await setupLoop(loopId) + const merged = JSON.parse(await readFile(join(loopClaudeDir(loopId), "settings.json"), "utf8")) + expect(merged.mcpServers.github.url).toBe("https://my-fork.github/mcp") + expect(merged.mcpServers.linear.url).toBe("https://team.linear/mcp") + expect(merged.mcpServers["private-only"]).toBeDefined() + }) + + test("personal entry without headers DROPS the team's headers (whole-object replace)", async () => { + // Important semantic: personal `github` has no headers, so the merged + // entry has no Authorization template — the OAuth flow would refuse this + // server. The team's headers do NOT leak through. + const loopId = "shadowsh-0000-0000-0000-000000000002" + await setupLoop(loopId) + const merged = JSON.parse(await readFile(join(loopClaudeDir(loopId), "settings.json"), "utf8")) + expect(merged.mcpServers.github.headers).toBeUndefined() + }) + + test("team-only server (no personal counterpart) keeps its full object", async () => { + const loopId = "shadowsh-0000-0000-0000-000000000003" + await setupLoop(loopId) + const merged = JSON.parse(await readFile(join(loopClaudeDir(loopId), "settings.json"), "utf8")) + expect(merged.mcpServers.linear).toEqual({ type: "http", url: "https://team.linear/mcp" }) + }) +}) + +describe("MCP inventory — /api/mcp-servers reads the loop's merged settings.json", () => { + test("without loopId, returns empty list", async () => { + const r = await app.request("/api/mcp-servers", { headers: await authed() }) + const j = await r.json() as any + expect(j.servers).toEqual([]) + }) + + test("with loopId, returns flat list reflecting merged settings.json", async () => { + const loopId = "shadowsh-0000-0000-0000-000000000010" + await setupLoop(loopId) + const r = await app.request(`/api/mcp-servers?loopId=${loopId}`, { headers: await authed() }) + const j = await r.json() as any + const names = j.servers.map((s: any) => s.name).sort() + expect(names).toEqual(["github", "linear", "private-only"]) + const github = j.servers.find((s: any) => s.name === "github") + expect(github.url).toBe("https://my-fork.github/mcp") // personal-shadowed URL + }) + + test("server with Bearer template exposes authTokenEnv", async () => { + // Use a fresh loop where team's github has the Bearer template AND no + // personal shadow drops the headers. + await writeFile(personalSettingsPath(USER), JSON.stringify({ mcpServers: {} })) + const loopId = "shadowsh-0000-0000-0000-000000000011" + await setupLoop(loopId) + const r = await app.request(`/api/mcp-servers?loopId=${loopId}`, { headers: await authed() }) + const j = await r.json() as any + const github = j.servers.find((s: any) => s.name === "github") + expect(github.authTokenEnv).toBe("GH_TOKEN") + expect(github.authed).toBe(false) // no env file written + }) + + test("server without Bearer template has authTokenEnv=null", async () => { + const loopId = "shadowsh-0000-0000-0000-000000000012" + await setupLoop(loopId) + const r = await app.request(`/api/mcp-servers?loopId=${loopId}`, { headers: await authed() }) + const j = await r.json() as any + const linear = j.servers.find((s: any) => s.name === "linear") + expect(linear.authTokenEnv).toBeNull() + expect(linear.authed).toBe(false) + }) +}) diff --git a/server/test/multi-user.test.ts b/server/test/multi-user.test.ts new file mode 100644 index 00000000..6b5c6171 --- /dev/null +++ b/server/test/multi-user.test.ts @@ -0,0 +1,228 @@ +/** + * L1+L3: multi-user (alice vs bob) isolation. + * + * Loopat is multi-tenant within a single workspace — many users share team + * knowledge but each has their own vault, personal CLAUDE.md, .claude/, and + * config.json. None of it should bleed across users. + * + * Covers G-COMPOSE-USER-* / G-VAULT-USER-* / G-ISO-USER-* goals. + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile, readFile } from "node:fs/promises" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-multi-user-${process.pid}` + +const { loadVaultEnvs } = await import("../src/vaults") +const { loadPersonalConfig, clearPersonalCache } = await import("../src/config") +const { composeFromPlan } = await import("../src/compose") +const { buildPodmanCreateArgs } = await import("../src/podman") +const { + LOOPAT_HOME, + loopWorkdir, + loopClaudeDir, + loopContextKnowledge, + loopContextNotes, + personalDir, + personalLoopatDir, + personalLoopatConfigPath, + personalClaudeDir, + personalClaudeMdPath, + personalVaultDir, + personalVaultEnvPath, + personalVaultEnvsDir, +} = await import("../src/paths") +const TEST_HOME = LOOPAT_HOME + +async function seedUser(user: string, opts: { + personalClaudeMd?: string + vaultEnvs?: Record<string, string> + providers?: Record<string, any> +}) { + await mkdir(personalLoopatDir(user), { recursive: true }) + await mkdir(personalClaudeDir(user), { recursive: true }) + await mkdir(personalVaultEnvsDir(user, "default"), { recursive: true }) + if (opts.personalClaudeMd) { + await writeFile(personalClaudeMdPath(user), opts.personalClaudeMd) + } + for (const [k, v] of Object.entries(opts.vaultEnvs ?? {})) { + await writeFile(personalVaultEnvPath(user, "default", k), v + "\n") + } + if (opts.providers) { + await writeFile(personalLoopatConfigPath(user), JSON.stringify({ providers: opts.providers })) + } + clearPersonalCache(user) +} + +async function setup() { + await rm(TEST_HOME, { recursive: true, force: true }) + await mkdir(TEST_HOME, { recursive: true }) + await writeFile(join(TEST_HOME, "config.json"), "{}") + await seedUser("alice", { + personalClaudeMd: "# ALICE_DOCTRINE — only alice should see this", + vaultEnvs: { GITHUB_TOKEN: "alice-gh-token", PRIVATE_VAR: "alice-secret" }, + providers: { + default: "anthropic", + anthropic: { baseUrl: "https://alice.example", model: "x", apiKey: "${ALICE_KEY}" }, + }, + }) + await writeFile(personalVaultEnvPath("alice", "default", "ALICE_KEY"), "sk-alice\n") + await seedUser("bob", { + personalClaudeMd: "# BOB_DOCTRINE — only bob should see this", + vaultEnvs: { GITHUB_TOKEN: "bob-gh-token", PRIVATE_VAR: "bob-secret" }, + providers: { + default: "anthropic", + anthropic: { baseUrl: "https://bob.example", model: "y", apiKey: "${BOB_KEY}" }, + }, + }) + await writeFile(personalVaultEnvPath("bob", "default", "BOB_KEY"), "sk-bob\n") + clearPersonalCache("alice") + clearPersonalCache("bob") +} + +beforeAll(setup) +afterAll(() => rm(TEST_HOME, { recursive: true, force: true })) + +describe("multi-user — vault isolation", () => { + test("loadVaultEnvs returns ONLY that user's vault, never the other", async () => { + const aliceEnvs = await loadVaultEnvs("alice", "default") + const bobEnvs = await loadVaultEnvs("bob", "default") + expect(aliceEnvs.PRIVATE_VAR).toBe("alice-secret") + expect(bobEnvs.PRIVATE_VAR).toBe("bob-secret") + expect(aliceEnvs.BOB_KEY).toBeUndefined() + expect(bobEnvs.ALICE_KEY).toBeUndefined() + }) + + test("same-named env in different vaults resolves to each owner's value", async () => { + const a = await loadVaultEnvs("alice", "default") + const b = await loadVaultEnvs("bob", "default") + expect(a.GITHUB_TOKEN).toBe("alice-gh-token") + expect(b.GITHUB_TOKEN).toBe("bob-gh-token") + }) +}) + +describe("multi-user — provider config isolation", () => { + test("each user's apiKey resolves from their own vault", async () => { + const aliceCfg = await loadPersonalConfig("alice", "default") + const bobCfg = await loadPersonalConfig("bob", "default") + expect(aliceCfg.providers.anthropic.apiKey).toBe("sk-alice") + expect(bobCfg.providers.anthropic.apiKey).toBe("sk-bob") + }) + + test("each user's baseUrl/model differ (configs don't leak)", async () => { + const aliceCfg = await loadPersonalConfig("alice", "default") + const bobCfg = await loadPersonalConfig("bob", "default") + expect(aliceCfg.providers.anthropic.baseUrl).toBe("https://alice.example") + expect(bobCfg.providers.anthropic.baseUrl).toBe("https://bob.example") + }) + + test("user without config gets template — does not inherit any other user's", async () => { + const cfg = await loadPersonalConfig("nobody-yet", "default") + expect(cfg.providers.anthropic?.apiKey).toBeFalsy() // template apiKey is empty + // nothing pulled from alice/bob + expect(JSON.stringify(cfg).includes("alice")).toBe(false) + expect(JSON.stringify(cfg).includes("bob")).toBe(false) + }) +}) + +describe("multi-user — composed CLAUDE.md doctrine", () => { + async function composeFor(user: string, loopId: string) { + await rm(loopClaudeDir(loopId), { recursive: true, force: true }) + await mkdir(loopWorkdir(loopId), { recursive: true }) + await mkdir(loopClaudeDir(loopId), { recursive: true }) + await mkdir(loopContextKnowledge(loopId), { recursive: true }) + await mkdir(loopContextNotes(loopId), { recursive: true }) + // Plan: team layer empty, personal layer only. + const plan = { + user, + claudeSources: [ + { source: `personal:${user}`, dir: personalDir(user) }, + ], + } + return await composeFromPlan(loopId, plan as any) + } + + test("alice's loop CLAUDE.md contains ALICE_DOCTRINE, NOT BOB_DOCTRINE", async () => { + const loopId = "a0000000-0000-0000-0000-000000000001" + await composeFor("alice", loopId) + const md = await readFile(join(loopClaudeDir(loopId), "CLAUDE.md"), "utf8") + expect(md.includes("ALICE_DOCTRINE")).toBe(true) + expect(md.includes("BOB_DOCTRINE")).toBe(false) + }) + + test("bob's loop CLAUDE.md contains BOB_DOCTRINE, NOT ALICE_DOCTRINE", async () => { + const loopId = "b0000000-0000-0000-0000-000000000001" + await composeFor("bob", loopId) + const md = await readFile(join(loopClaudeDir(loopId), "CLAUDE.md"), "utf8") + expect(md.includes("BOB_DOCTRINE")).toBe(true) + expect(md.includes("ALICE_DOCTRINE")).toBe(false) + }) +}) + +describe("multi-user — podman binds correct user's personal dir", () => { + function getPersonalVolumes(argv: string[]): string[] { + const out: string[] = [] + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--volume") { + const spec = argv[i + 1] + if (spec.includes("/personal/")) out.push(spec.split(":")[0]) + } + } + return out + } + + test("alice's spawn binds /personal/alice, not /personal/bob", async () => { + const loopId = "a0000000-0000-0000-0000-000000000002" + await mkdir(loopWorkdir(loopId), { recursive: true }) + await mkdir(loopClaudeDir(loopId), { recursive: true }) + await mkdir(loopContextKnowledge(loopId), { recursive: true }) + await mkdir(loopContextNotes(loopId), { recursive: true }) + const argv = await buildPodmanCreateArgs({ + loopId, + createdBy: "alice", + vaultName: "default", + }) + const personalBinds = getPersonalVolumes(argv) + expect(personalBinds.every(p => p.includes("/personal/alice"))).toBe(true) + expect(personalBinds.some(p => p.includes("/personal/bob"))).toBe(false) + }) + + test("bob's spawn binds /personal/bob, not /personal/alice", async () => { + const loopId = "b0000000-0000-0000-0000-000000000002" + await mkdir(loopWorkdir(loopId), { recursive: true }) + await mkdir(loopClaudeDir(loopId), { recursive: true }) + await mkdir(loopContextKnowledge(loopId), { recursive: true }) + await mkdir(loopContextNotes(loopId), { recursive: true }) + const argv = await buildPodmanCreateArgs({ + loopId, + createdBy: "bob", + vaultName: "default", + }) + const personalBinds = getPersonalVolumes(argv) + expect(personalBinds.every(p => p.includes("/personal/bob"))).toBe(true) + expect(personalBinds.some(p => p.includes("/personal/alice"))).toBe(false) + }) + + test("alice's vault envs do NOT appear in bob's podman --env", async () => { + const loopId = "b0000000-0000-0000-0000-000000000003" + await mkdir(loopWorkdir(loopId), { recursive: true }) + await mkdir(loopClaudeDir(loopId), { recursive: true }) + await mkdir(loopContextKnowledge(loopId), { recursive: true }) + await mkdir(loopContextNotes(loopId), { recursive: true }) + const bobCfg = await loadPersonalConfig("bob", "default") + const argv = await buildPodmanCreateArgs({ + loopId, + createdBy: "bob", + vaultName: "default", + extraEnv: bobCfg.vaultEnvs, + }) + const envs = argv + .map((a, i) => (a === "--env" ? argv[i + 1] : null)) + .filter((s): s is string => s !== null) + // ALICE_KEY belongs to alice; must not leak into bob's spawn + expect(envs.some((e) => e.startsWith("ALICE_KEY="))).toBe(false) + expect(envs.some((e) => e.includes("sk-alice"))).toBe(false) + // BOB_KEY is bob's own + expect(envs.some((e) => e.startsWith("BOB_KEY="))).toBe(true) + }) +}) diff --git a/server/test/multi-vault.test.ts b/server/test/multi-vault.test.ts new file mode 100644 index 00000000..366695a4 --- /dev/null +++ b/server/test/multi-vault.test.ts @@ -0,0 +1,211 @@ +/** + * L1+L3: multi-vault — one user can have several named vaults (default, dev, + * prod, ...). Each loop binds exactly ONE active vault. The same loop spawned + * with vault=dev vs vault=prod must yield completely different env/mount + * surfaces, even though the user and the workspace are identical. + * + * Also covers the realpath-escape guard in walkVaultFiles: vault symlinks + * pointing outside personal/<user>/ are rejected (anti-privilege-escalation). + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile, symlink } from "node:fs/promises" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-multi-vault-${process.pid}` + +const { + loadVaultEnvs, + listVaultHomeMounts, + listVaults, + resolveVaultRoot, + walkVaultFiles, + isValidVaultName, +} = await import("../src/vaults") +const { buildPodmanCreateArgs } = await import("../src/podman") +const { + LOOPAT_HOME, + loopWorkdir, + loopClaudeDir, + loopContextKnowledge, + loopContextNotes, + personalDir, + personalLoopatDir, + personalVaultDir, + personalVaultEnvsDir, + personalVaultEnvPath, + personalVaultMountsHomeDir, +} = await import("../src/paths") +const TEST_HOME = LOOPAT_HOME +const USER = "alice" + +async function setup() { + await rm(TEST_HOME, { recursive: true, force: true }) + await mkdir(TEST_HOME, { recursive: true }) + await writeFile(join(TEST_HOME, "config.json"), "{}") + await mkdir(personalLoopatDir(USER), { recursive: true }) + // Three vaults — default, dev, prod — with diverging contents. + for (const v of ["default", "dev", "prod"]) { + await mkdir(personalVaultEnvsDir(USER, v), { recursive: true }) + await mkdir(personalVaultMountsHomeDir(USER, v), { recursive: true }) + await writeFile(personalVaultEnvPath(USER, v, "ENV_NAME"), v + "\n") + await writeFile(personalVaultEnvPath(USER, v, `${v.toUpperCase()}_ONLY`), "secret-" + v + "\n") + } +} + +beforeAll(setup) +afterAll(() => rm(TEST_HOME, { recursive: true, force: true })) + +describe("vault catalog", () => { + test("listVaults returns all named vaults in alphabetic order", () => { + expect(listVaults(USER)).toEqual(["default", "dev", "prod"]) + }) + + test("listVaults returns [] when user has no vaults dir", () => { + expect(listVaults("nobody")).toEqual([]) + }) + + test("resolveVaultRoot returns absolute path for existing vault", () => { + const p = resolveVaultRoot(USER, "dev") + expect(p).not.toBeNull() + expect(p!.endsWith("/personal/alice/.loopat/vaults/dev")).toBe(true) + }) + + test("resolveVaultRoot returns null for missing vault", () => { + expect(resolveVaultRoot(USER, "ghost")).toBeNull() + }) + + test("isValidVaultName accepts good names, rejects bad", () => { + expect(isValidVaultName("default")).toBe(true) + expect(isValidVaultName("dev")).toBe(true) + expect(isValidVaultName("v_1")).toBe(true) + expect(isValidVaultName("../escape")).toBe(false) + expect(isValidVaultName(".hidden")).toBe(false) + expect(isValidVaultName("")).toBe(false) + expect(isValidVaultName("a".repeat(100))).toBe(false) // length cap + }) +}) + +describe("per-loop vault selection — same user, different sandbox env", () => { + test("loadVaultEnvs delivers ONLY the named vault's envs", async () => { + const d = await loadVaultEnvs(USER, "default") + const dev = await loadVaultEnvs(USER, "dev") + const prod = await loadVaultEnvs(USER, "prod") + expect(d.ENV_NAME).toBe("default") + expect(dev.ENV_NAME).toBe("dev") + expect(prod.ENV_NAME).toBe("prod") + // The vault-exclusive envs must not bleed + expect(d.DEV_ONLY).toBeUndefined() + expect(d.PROD_ONLY).toBeUndefined() + expect(dev.DEFAULT_ONLY).toBeUndefined() + expect(prod.DEV_ONLY).toBeUndefined() + }) + + test("podman argv reflects vault selection — dev spawn carries dev envs", async () => { + const loopId = "vvvvvvvv-dddd-0000-0000-000000000001" + await mkdir(loopWorkdir(loopId), { recursive: true }) + await mkdir(loopClaudeDir(loopId), { recursive: true }) + await mkdir(loopContextKnowledge(loopId), { recursive: true }) + await mkdir(loopContextNotes(loopId), { recursive: true }) + const cfg = await loadVaultEnvs(USER, "dev") + const argv = await buildPodmanCreateArgs({ + loopId, + createdBy: USER, + vaultName: "dev", + extraEnv: cfg, + }) + // DEV_ONLY must appear as `--env DEV_ONLY=...`; PROD_ONLY must not + const hasEnvKey = (k: string) => { + for (let i = 0; i < argv.length - 1; i++) { + if (argv[i] === "--env" && argv[i + 1].startsWith(`${k}=`)) return true + } + return false + } + expect(hasEnvKey("DEV_ONLY")).toBe(true) + expect(hasEnvKey("PROD_ONLY")).toBe(false) + }) + + test("the SAME loop respawned with a different vault gets different envs", async () => { + const loopId = "vvvvvvvv-eeee-0000-0000-000000000002" + await mkdir(loopWorkdir(loopId), { recursive: true }) + await mkdir(loopClaudeDir(loopId), { recursive: true }) + await mkdir(loopContextKnowledge(loopId), { recursive: true }) + await mkdir(loopContextNotes(loopId), { recursive: true }) + + const devEnvs = await loadVaultEnvs(USER, "dev") + const prodEnvs = await loadVaultEnvs(USER, "prod") + const argvDev = await buildPodmanCreateArgs({ + loopId, + createdBy: USER, + vaultName: "dev", + extraEnv: devEnvs, + }) + const argvProd = await buildPodmanCreateArgs({ + loopId, + createdBy: USER, + vaultName: "prod", + extraEnv: prodEnvs, + }) + const hasEnvKey = (argv: string[], k: string) => { + for (let i = 0; i < argv.length - 1; i++) { + if (argv[i] === "--env" && argv[i + 1].startsWith(`${k}=`)) return true + } + return false + } + expect(hasEnvKey(argvDev, "DEV_ONLY")).toBe(true) + expect(hasEnvKey(argvDev, "PROD_ONLY")).toBe(false) + expect(hasEnvKey(argvProd, "PROD_ONLY")).toBe(true) + expect(hasEnvKey(argvProd, "DEV_ONLY")).toBe(false) + }) + + test("vault-specific mounts/home/* land only when that vault is active", async () => { + // Add a mount only in dev vault + await mkdir(join(personalVaultMountsHomeDir(USER, "dev"), ".ssh"), { recursive: true }) + await writeFile(join(personalVaultMountsHomeDir(USER, "dev"), ".ssh", "id_ed25519"), "DEV_KEY") + const dev = listVaultHomeMounts(USER, "dev") + const prod = listVaultHomeMounts(USER, "prod") + expect(dev.some(m => m.rel === ".ssh")).toBe(true) + expect(prod.some(m => m.rel === ".ssh")).toBe(false) + }) +}) + +describe("walkVaultFiles — symlink escape rejection (security)", () => { + test("symlink within user tree is followed (legitimate cross-vault sharing)", async () => { + // Shared file in default, prod references it via symlink. + const sharedFile = personalVaultEnvPath(USER, "default", "SHARED_TOKEN") + await writeFile(sharedFile, "shared-value") + const linkPath = personalVaultEnvPath(USER, "prod", "SHARED_TOKEN") + await rm(linkPath, { force: true }) + await symlink(sharedFile, linkPath) + const found: string[] = [] + for await (const f of walkVaultFiles(USER, personalVaultDir(USER, "prod"))) { + found.push(f.rel) + } + expect(found).toContain("envs/SHARED_TOKEN") + }) + + test("symlink escaping personal/<user>/ is REJECTED (never yields)", async () => { + // Create an escaping symlink: vault/prod/envs/EVIL → /etc/passwd + const evilLink = personalVaultEnvPath(USER, "prod", "EVIL_ESCAPE") + await rm(evilLink, { force: true }) + await symlink("/etc/passwd", evilLink) + const found: string[] = [] + for await (const f of walkVaultFiles(USER, personalVaultDir(USER, "prod"))) { + found.push(f.rel) + } + expect(found.includes("envs/EVIL_ESCAPE")).toBe(false) + }) + + test("symlink to another user's vault is REJECTED", async () => { + // Bob shouldn't be able to symlink into alice's secrets + await mkdir(personalVaultEnvsDir("bob", "default"), { recursive: true }) + const aliceSecret = personalVaultEnvPath(USER, "default", "ENV_NAME") + const bobLink = personalVaultEnvPath("bob", "default", "STEAL_ALICE") + await rm(bobLink, { force: true }) + await symlink(aliceSecret, bobLink) + const found: string[] = [] + for await (const f of walkVaultFiles("bob", personalVaultDir("bob", "default"))) { + found.push(f.rel) + } + expect(found.includes("envs/STEAL_ALICE")).toBe(false) + }) +}) diff --git a/server/test/personal-sync.test.ts b/server/test/personal-sync.test.ts new file mode 100644 index 00000000..6647b7e4 --- /dev/null +++ b/server/test/personal-sync.test.ts @@ -0,0 +1,90 @@ +/** + * Personal sync = the loop-outside (no-AI) rule from docs/context-flow.md: + * ff-only, rebase local onto origin when it moved, hold back a real same-spot + * conflict (never lose the local edit), and `force` = take remote. + * + * Uses a local bare repo as `origin` + a second clone (`other`) to simulate a + * concurrent writer. No platform/network needed. + */ +import { test, expect, beforeAll, afterAll } from "bun:test" +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises" +import { existsSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" +import { execFile } from "node:child_process" + +const run = promisify(execFile) +const g = (args: string[], cwd?: string) => run("git", cwd ? ["-C", cwd, ...args] : args) + +let home: string +let loops: any +const user = "synctest" +let pdir: string +let origin: string +let other: string + +beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), "loopat-personal-sync-")) + process.env.LOOPAT_HOME = home + loops = await import("../src/loops.ts") + pdir = join(home, "personal", user) + origin = join(home, "origin.git") + other = join(home, "other") + + await g(["init", "--bare", "-b", "main", origin]) + await g(["clone", origin, pdir]) + await writeFile(join(pdir, "a.txt"), "a1\n") + await g(["add", "-A"], pdir) + await g(["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"], pdir) + await g(["push", "origin", "HEAD:main"], pdir) + await g(["clone", origin, other]) +}) + +afterAll(async () => { + await rm(home, { recursive: true, force: true }) +}) + +/** Simulate a concurrent writer landing a commit on origin/main. */ +async function remoteEdit(file: string, content: string, msg: string) { + await g(["fetch", "origin"], other) + await g(["reset", "--hard", "origin/main"], other) + await writeFile(join(other, file), content) + await g(["add", "-A"], other) + await g(["-c", "user.email=o@o", "-c", "user.name=o", "commit", "-m", msg], other) + await g(["push", "origin", "HEAD:main"], other) +} + +// Ordered: each test builds on the previous one's state, like a real session. + +test("clean push fast-forwards", async () => { + await writeFile(join(pdir, "b.txt"), "b1\n") + const r = await loops.pushPersonalToRemote(user) + expect(r.ok).toBe(true) +}) + +test("remote moved elsewhere → rebase keeps local AND pulls remote", async () => { + await remoteEdit("remote.txt", "r1\n", "remote change") + await writeFile(join(pdir, "c.txt"), "c1\n") + const r = await loops.pushPersonalToRemote(user) + expect(r.ok).toBe(true) + expect(existsSync(join(pdir, "c.txt"))).toBe(true) // local edit kept + expect(existsSync(join(pdir, "remote.txt"))).toBe(true) // remote folded in +}) + +test("real same-spot conflict is held back; local edit is NOT lost", async () => { + await remoteEdit("a.txt", "a-remote\n", "remote edits a") + await writeFile(join(pdir, "a.txt"), "a-local\n") + const r = await loops.pushPersonalToRemote(user) + expect(r.ok).toBe(false) + expect(r.conflict).toBe(true) + expect(r.files).toContain("a.txt") + // the whole point: the local edit survives the held-back push + expect((await readFile(join(pdir, "a.txt"), "utf8")).trim()).toBe("a-local") +}) + +test("force pull = take remote (discards local edit)", async () => { + const r = await loops.pullPersonalFromRemote(user, { force: true }) + expect(r.ok).toBe(true) + expect((await readFile(join(pdir, "a.txt"), "utf8")).trim()).toBe("a-remote") +}) diff --git a/server/test/plugin-installer.test.ts b/server/test/plugin-installer.test.ts new file mode 100644 index 00000000..2dc393fa --- /dev/null +++ b/server/test/plugin-installer.test.ts @@ -0,0 +1,109 @@ +/** + * Tests for plugin-installer's host-side helpers — `sourcesMatch` (marketplace + * URL drift detection) and `lookupPluginInstallPath` (spec → host path + * resolution used by slash-command pre-seed, loop-stats, and mcp-oauth). + * + * Note: ensureLoopPluginsInstalled is intentionally NOT covered here — it + * shells out to the host `claude plugin install` CLI, which would either be + * slow or require a CC binary in the test environment. The end-to-end + * behavior is exercised by manually running a loop with plugins enabled. + */ +import { test, expect, describe } from "bun:test" + +// paths.ts captures LOOPAT_HOME at module load. If another test file in this +// run loaded it first, our assignment here is a no-op — but plugin-installer +// itself doesn't touch LOOPAT_HOME, so collisions are harmless. +process.env.LOOPAT_HOME ??= `/tmp/loopat-plugin-test-${process.pid}` + +const { sourcesMatch, lookupPluginInstallPath, BUILTIN_LOOPAT_PLUGIN_PATH } = + await import("../src/plugin-installer") + +// ─── sourcesMatch ──────────────────────────────────────────────────────── + +describe("sourcesMatch — marketplace source equivalence", () => { + test("identical objects match", () => { + const a = { source: "git", url: "git@example.com:team/mp.git" } + expect(sourcesMatch(a, a)).toBe(true) + }) + + test("git sources match on url, ignore other fields", () => { + expect( + sourcesMatch( + { source: "git", url: "git@a.com:x/y.git" }, + { source: "git", url: "git@a.com:x/y.git", lastUpdated: 12345 }, + ), + ).toBe(true) + }) + + test("github source matches on repo or repository", () => { + expect( + sourcesMatch( + { source: "github", repo: "anthropics/claude-for-legal" }, + { source: "github", repository: "anthropics/claude-for-legal" }, + ), + ).toBe(true) + }) + + test("git URL drift fails the match (used to trigger re-register)", () => { + expect( + sourcesMatch( + { source: "git", url: "git@old.example:team/mp.git" }, + { source: "git", url: "git@new.example:team/mp.git" }, + ), + ).toBe(false) + }) + + test("different source type fails the match", () => { + expect( + sourcesMatch( + { source: "git", url: "x" }, + { source: "github", repo: "x/y" }, + ), + ).toBe(false) + }) + + test("asymmetric null / undefined fails the match", () => { + expect(sourcesMatch(null, { source: "git", url: "x" })).toBe(false) + expect(sourcesMatch({ source: "git", url: "x" }, undefined)).toBe(false) + }) + + test("both undefined is trivially a match — no drift to detect", () => { + // If both sides are missing, neither side declared anything, so there's + // nothing to re-register. The caller treats true as "stay put". + expect(sourcesMatch(undefined, undefined)).toBe(true) + }) +}) + +// ─── lookupPluginInstallPath ───────────────────────────────────────────── + +describe("lookupPluginInstallPath — resolve spec → host path", () => { + // We don't try to mock homedir — the function reads ~/.claude/plugins/. + // Instead we use a spec name no real marketplace could provide; if the + // host's CC happens to know that exact name, that's an environment bug, + // not a test bug. + const FAKE_SPEC = "this-plugin-cannot-possibly-exist@xyzzy-test-marketplace" + + test("returns null when host has no CC plugin cache for the spec", async () => { + const result = await lookupPluginInstallPath(FAKE_SPEC) + expect(result).toBeNull() + }) + + test("returns null when spec is malformed", async () => { + const result = await lookupPluginInstallPath("no-at-sign-here") + // No "@" in the spec — function still tries to look up; result depends + // on host CC state but should never throw. Most likely null. + expect(result === null || typeof result === "string").toBe(true) + }) + + test("BUILTIN_LOOPAT_PLUGIN_PATH is exported and points under LOOPAT_INSTALL_DIR", () => { + // The builtin's location is the one plugin that is NOT in ~/.claude/plugins/ + // — it ships with loopat itself. The session.ts spawn passes this via the + // `plugins:` SDK option (everything else goes via enabledPlugins + + // wholesale bind). + expect(typeof BUILTIN_LOOPAT_PLUGIN_PATH).toBe("string") + expect(BUILTIN_LOOPAT_PLUGIN_PATH.length).toBeGreaterThan(0) + expect(BUILTIN_LOOPAT_PLUGIN_PATH).toContain("templates") + expect(BUILTIN_LOOPAT_PLUGIN_PATH).toContain("plugins") + expect(BUILTIN_LOOPAT_PLUGIN_PATH).toContain("loopat") + }) +}) diff --git a/server/test/podman-e2e.test.ts b/server/test/podman-e2e.test.ts new file mode 100644 index 00000000..40649281 --- /dev/null +++ b/server/test/podman-e2e.test.ts @@ -0,0 +1,224 @@ +/** + * L4 E2E: actually spawn a podman container and exercise the full + * ensureContainer → podman exec → namespace-sharing flow. This is the + * test that proves the architectural goal of the refactor — SDK and PTY + * sharing one PID namespace via container exec. + * + * Skipped automatically if `podman` is not installed on the host. + * + * The fixture builds a minimal loop dir tree then drives the lifecycle. + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" + +const execFileP = promisify(execFile) + +process.env.LOOPAT_HOME ??= `/tmp/loopat-e2e-podman-${process.pid}` +// Aggressive idle timeout so the test doesn't hang on cleanup. +process.env.LOOPAT_CONTAINER_IDLE_MS = "1000" + +const { + ensureContainer, + stopContainer, + removeContainer, + buildPodmanExecArgs, + containerName, + containerExists, + containerRunning, + probePodman, + V_LOOP_WORKDIR, + markActive, + markInactive, + _resetActivityRegistryForTests, +} = await import("../src/podman") +const { + LOOPAT_HOME, + loopWorkdir, + loopClaudeDir, + loopContextKnowledge, + loopContextNotes, + loopHomeUpper, + personalDir, +} = await import("../src/paths") +const TEST_HOME = LOOPAT_HOME +const LOOP_ID = "e2e1111-2222-3333-4444-555566667777" +const USER = "alice" + +// Probe podman at module load so describe.skipIf sees the right value. +const podmanAvailable = (await probePodman()).ok +const podmanBin = process.env.LOOPAT_PODMAN_BIN || "podman" + +async function setupFixture() { + await rm(TEST_HOME, { recursive: true, force: true }) + await mkdir(loopWorkdir(LOOP_ID), { recursive: true }) + await mkdir(loopClaudeDir(LOOP_ID), { recursive: true }) + await mkdir(loopContextKnowledge(LOOP_ID), { recursive: true }) + await mkdir(loopContextNotes(LOOP_ID), { recursive: true }) + await mkdir(loopHomeUpper(LOOP_ID), { recursive: true }) + await mkdir(join(personalDir(USER), ".loopat", "vaults", "default"), { recursive: true }) + await writeFile(join(personalDir(USER), ".loopat", "config.json"), "{}") + await writeFile(join(TEST_HOME, "config.json"), "{}") +} + +async function cleanup() { + if (podmanAvailable) { + await removeContainer(LOOP_ID).catch(() => {}) + } + await rm(TEST_HOME, { recursive: true, force: true }) +} + +beforeAll(setupFixture) +afterAll(cleanup) + +describe.skipIf(!podmanAvailable)("podman E2E lifecycle", () => { + test("ensureContainer creates+starts a container the first time", async () => { + await removeContainer(LOOP_ID).catch(() => {}) + expect(await containerExists(LOOP_ID)).toBe(false) + await ensureContainer({ loopId: LOOP_ID, createdBy: USER }) + expect(await containerRunning(LOOP_ID)).toBe(true) + }) + + test("ensureContainer is idempotent when args are unchanged", async () => { + await ensureContainer({ loopId: LOOP_ID, createdBy: USER }) + await ensureContainer({ loopId: LOOP_ID, createdBy: USER }) + expect(await containerRunning(LOOP_ID)).toBe(true) + }) + + test("different extraEnv between callers does NOT recreate the container (regression: PTY exit-137 bug)", async () => { + // term.ts passes vault envs only; session.ts adds ANTHROPIC_* + others. + // If env participated in the config hash, the second call would tear + // down the container, killing any process exec'd by the first caller + // with SIGKILL — exactly the bug the user reported. Verify the + // container's id is preserved across the two calls. + await removeContainer(LOOP_ID).catch(() => {}) + await ensureContainer({ + loopId: LOOP_ID, + createdBy: USER, + extraEnv: { VAULT_KEY: "v" }, + }) + const { stdout: id1 } = await execFileP(podmanBin, ["inspect", "--format", "{{.Id}}", containerName(LOOP_ID)]) + await ensureContainer({ + loopId: LOOP_ID, + createdBy: USER, + extraEnv: { + VAULT_KEY: "v", + ANTHROPIC_API_KEY: "sk-test", + ANTHROPIC_BASE_URL: "https://example", + CLAUDE_CONFIG_DIR: "/loopat/loop/foo/.claude", + }, + }) + const { stdout: id2 } = await execFileP(podmanBin, ["inspect", "--format", "{{.Id}}", containerName(LOOP_ID)]) + expect(id1.trim()).toBe(id2.trim()) + expect(await containerRunning(LOOP_ID)).toBe(true) + }) + + test("podman exec -i forwards stdin into the container (regression: chat-sends-but-never-responds bug)", async () => { + // SDK pumps user messages as line-delimited stream-json on the claude + // binary's stdin. Without `-i` on `podman exec`, that stdin is NOT + // forwarded to the inner process — claude reads EOF, exits clean with + // code 0, no output, and the chat appears frozen. + await ensureContainer({ loopId: LOOP_ID, createdBy: USER }) + const args = buildPodmanExecArgs({ + loopId: LOOP_ID, + command: "/bin/bash", + args: ["-c", "read line && echo got=$line"], + interactive: true, + }) + const child = (await import("node:child_process")).spawn(podmanBin, args, { + stdio: ["pipe", "pipe", "pipe"], + }) + child.stdin.write("hello-stdin\n") + child.stdin.end() + let out = "" + child.stdout.on("data", (b) => (out += b.toString())) + const code = await new Promise<number>((res) => child.on("exit", (c) => res(c ?? -1))) + expect(code).toBe(0) + expect(out.trim()).toBe("got=hello-stdin") + }, 15_000) + + test("podman exec yields container uid 2000 (loopat) + right cwd", async () => { + await ensureContainer({ loopId: LOOP_ID, createdBy: USER }) + const args = buildPodmanExecArgs({ + loopId: LOOP_ID, + command: "/bin/bash", + args: ["-c", "echo $(id -u):$(whoami):$(pwd)"], + workdir: V_LOOP_WORKDIR(LOOP_ID), + }) + const { stdout } = await execFileP(podmanBin, args) + const [uid, user, pwd] = stdout.trim().split(":") + // Fixed by --userns=keep-id:uid=2000,gid=2000 + image's loopat user + // at uid 2000. Host uid varies; container view is constant. + expect(Number(uid)).toBe(2000) + expect(user).toBe("loopat") + expect(pwd).toBe(V_LOOP_WORKDIR(LOOP_ID)) + }) + + test("two execs share the same PID namespace (the whole point of the refactor)", async () => { + await ensureContainer({ loopId: LOOP_ID, createdBy: USER }) + // Exec A: spawn a background sentinel that sleeps; record its PID. + const sentinelArgs = buildPodmanExecArgs({ + loopId: LOOP_ID, + command: "/bin/bash", + args: [ + "-c", + // background sleep, print its pid, exit (sleep keeps running as + // container's PID 1 reaps later — we use --detach to make it survive) + "sleep 30 & echo $!", + ], + }) + const { stdout: pid } = await execFileP(podmanBin, ["exec", "--detach-keys", "ctrl-q,ctrl-q", ...sentinelArgs.slice(1)]) + // The detach-keys flag is irrelevant for non-interactive; we just want + // to read the printed PID. (Strip leading "exec" because we pass it + // again in the slice.) + const sentinelPid = Number(pid.trim().split("\n").pop()) + expect(Number.isFinite(sentinelPid)).toBe(true) + + // Exec B: a new exec, ask `ps` whether the sentinel PID is alive. + const psArgs = buildPodmanExecArgs({ + loopId: LOOP_ID, + command: "/bin/bash", + args: ["-c", `ps -p ${sentinelPid} -o pid= 2>/dev/null | tr -d ' '`], + }) + const { stdout: psOut } = await execFileP(podmanBin, psArgs) + expect(psOut.trim()).toBe(String(sentinelPid)) + // Kill the sentinel before next test. + const killArgs = buildPodmanExecArgs({ + loopId: LOOP_ID, + command: "/bin/sh", + args: ["-c", `kill ${sentinelPid} 2>/dev/null; true`], + }) + await execFileP(podmanBin, killArgs).catch(() => {}) + }, 30_000) + + test("stopContainer brings the container down; subsequent ensureContainer restarts it", async () => { + await ensureContainer({ loopId: LOOP_ID, createdBy: USER }) + await stopContainer(LOOP_ID) + expect(await containerRunning(LOOP_ID)).toBe(false) + // Container record persists; the upper-layer overlay is intact. + expect(await containerExists(LOOP_ID)).toBe(true) + await ensureContainer({ loopId: LOOP_ID, createdBy: USER }) + expect(await containerRunning(LOOP_ID)).toBe(true) + }) + + test("idle scheduler: container stops shortly after the last source goes inactive", async () => { + _resetActivityRegistryForTests() + await ensureContainer({ loopId: LOOP_ID, createdBy: USER }) + expect(await containerRunning(LOOP_ID)).toBe(true) + // Simulate one active source (e.g. a chat session). Container stays up. + markActive(LOOP_ID, "sdk") + markActive(LOOP_ID, "pty") + // Releasing one of two sources keeps container up. + markInactive(LOOP_ID, "sdk") + // Wait less than the idle window — container is still running because pty + // source is still active. + await new Promise((r) => setTimeout(r, 400)) + expect(await containerRunning(LOOP_ID)).toBe(true) + // Now release the second source — the idle timer fires after 1s. + markInactive(LOOP_ID, "pty") + await new Promise((r) => setTimeout(r, 2500)) + expect(await containerRunning(LOOP_ID)).toBe(false) + }, 20_000) +}) diff --git a/server/test/podman-vault.test.ts b/server/test/podman-vault.test.ts new file mode 100644 index 00000000..cf1e966c --- /dev/null +++ b/server/test/podman-vault.test.ts @@ -0,0 +1,117 @@ +/** + * L3: vault delivery via podman volume + env args. + * + * Asserts the vault refactor's contract: vault/mounts/home/<rel> → --volume + * at $HOME/<rel>; vault/envs/<NAME> → --env NAME=value at container create + * time. Also asserts the old vault entrypoint behaviors are absent. + * + * Build args only, no actual podman exec. + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-podman-vault-${process.pid}` + +const { buildVolumeMounts, buildPodmanCreateArgs, V_HOME } = await import("../src/podman") +const { loadVaultEnvs } = await import("../src/vaults") +const { + LOOPAT_HOME, + loopWorkdir, + loopClaudeDir, + loopContextKnowledge, + loopContextNotes, + loopHomeUpper, + personalDir, + personalVaultMountsHomeDir, +} = await import("../src/paths") +const TEST_HOME = LOOPAT_HOME +const LOOP_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +const USER = "alice" +const SANDBOX_HOME = V_HOME(USER) + +async function reset() { + await rm(TEST_HOME, { recursive: true, force: true }) + await mkdir(loopWorkdir(LOOP_ID), { recursive: true }) + await mkdir(loopClaudeDir(LOOP_ID), { recursive: true }) + await mkdir(loopContextKnowledge(LOOP_ID), { recursive: true }) + await mkdir(loopContextNotes(LOOP_ID), { recursive: true }) + await mkdir(loopHomeUpper(LOOP_ID), { recursive: true }) + await mkdir(join(personalDir(USER), ".loopat", "vaults", "default"), { recursive: true }) + await writeFile(join(personalDir(USER), ".loopat", "config.json"), "{}") + await writeFile(join(TEST_HOME, "config.json"), "{}") +} + +beforeAll(reset) +afterAll(() => rm(TEST_HOME, { recursive: true, force: true })) + +describe("buildVolumeMounts — vault/mounts/home/ auto-bind", () => { + test("each top-level entry under mounts/home/ becomes a --volume at $HOME/<rel>", async () => { + await reset() + const mh = personalVaultMountsHomeDir(USER, "default") + await mkdir(join(mh, ".ssh"), { recursive: true }) + await mkdir(join(mh, ".config", "gh"), { recursive: true }) + await writeFile(join(mh, ".gitconfig"), "[user]\nname = test\n") + + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER, vaultName: "default" }) + + expect(mounts.some((m) => m.src === join(mh, ".ssh") && m.dst === join(SANDBOX_HOME, ".ssh"))).toBe(true) + expect(mounts.some((m) => m.src === join(mh, ".config") && m.dst === join(SANDBOX_HOME, ".config"))).toBe(true) + expect(mounts.some((m) => m.src === join(mh, ".gitconfig") && m.dst === join(SANDBOX_HOME, ".gitconfig"))).toBe(true) + }) + + test("no entries → no vault mounts emitted (no spurious args)", async () => { + await reset() + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER, vaultName: "default" }) + const vaultMounts = mounts.filter((m) => + m.src.startsWith(personalVaultMountsHomeDir(USER, "default")), + ) + expect(vaultMounts).toEqual([]) + }) + + test("subdirectories inside top-level entries are NOT separately bound", async () => { + await reset() + const mh = personalVaultMountsHomeDir(USER, "default") + await mkdir(join(mh, ".config", "gh"), { recursive: true }) + await mkdir(join(mh, ".config", "a1"), { recursive: true }) + + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER, vaultName: "default" }) + const vaultMounts = mounts.filter((m) => + m.src.startsWith(personalVaultMountsHomeDir(USER, "default")), + ) + expect(vaultMounts.length).toBe(1) + expect(vaultMounts[0].src).toBe(join(mh, ".config")) + }) + + test("vault selection: dev vault entries used when vaultName=dev", async () => { + await reset() + const mhDefault = personalVaultMountsHomeDir(USER, "default") + const mhDev = personalVaultMountsHomeDir(USER, "dev") + await mkdir(join(mhDefault, ".gitconfig"), { recursive: true }) + await mkdir(join(mhDev, ".ssh"), { recursive: true }) + + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER, vaultName: "dev" }) + expect(mounts.some((m) => m.src === join(mhDev, ".ssh"))).toBe(true) + expect(mounts.some((m) => m.src === join(mhDefault, ".gitconfig"))).toBe(false) + }) +}) + +describe("buildPodmanCreateArgs — vault env injection via --env", () => { + test("vault envs (loaded by caller and passed via extraEnv) land as --env K=V", async () => { + await reset() + // Pretend caller already loaded vault envs and passed them through. + const envs = { DEV_KEY: "secret-dev", ENV_NAME: "dev" } + const args = await buildPodmanCreateArgs({ + loopId: LOOP_ID, + createdBy: USER, + vaultName: "dev", + extraEnv: envs, + }) + const envArgs = args + .map((a, i) => (a === "--env" ? args[i + 1] : null)) + .filter((s): s is string => s !== null) + expect(envArgs).toContain("DEV_KEY=secret-dev") + expect(envArgs).toContain("ENV_NAME=dev") + }) +}) diff --git a/server/test/podman.test.ts b/server/test/podman.test.ts new file mode 100644 index 00000000..a5101fe8 --- /dev/null +++ b/server/test/podman.test.ts @@ -0,0 +1,326 @@ +/** + * Pure-function tests for the podman sandbox builders. We exercise + * buildVolumeMounts, buildContainerEnv, buildPodmanCreateArgs, and + * buildPodmanExecArgs without ever invoking the podman binary. + * + * NOTE: LOOPAT_HOME must be set BEFORE source imports (paths.ts captures it + * at module load time). + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { homedir } from "node:os" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-podman-test-${process.pid}` + +const { + buildVolumeMounts, + buildContainerEnv, + buildPodmanCreateArgs, + buildPodmanExecArgs, + containerName, + V_LOOP_CLAUDE, + V_LOOP_WORKDIR, + V_CONTEXT_PERSONAL, + V_CONTEXT_KNOWLEDGE, + V_CONTEXT_NOTES, + V_HOME, +} = await import("../src/podman") +const { + LOOPAT_HOME, + WORKSPACE, + loopWorkdir, + loopClaudeDir, + loopContextKnowledge, + loopContextNotes, + loopHomeUpper, + personalDir, + LOOPAT_INSTALL_DIR, +} = await import("../src/paths") +const TEST_HOME = LOOPAT_HOME +const HOST_HOME = homedir() + +const LOOP_ID = "11111111-2222-3333-4444-555555555555" +const USER = "alice" + +async function setupFixture() { + await rm(TEST_HOME, { recursive: true, force: true }) + await mkdir(loopWorkdir(LOOP_ID), { recursive: true }) + await mkdir(loopClaudeDir(LOOP_ID), { recursive: true }) + await mkdir(loopContextKnowledge(LOOP_ID), { recursive: true }) + await mkdir(loopContextNotes(LOOP_ID), { recursive: true }) + await mkdir(loopHomeUpper(LOOP_ID), { recursive: true }) + await mkdir(join(personalDir(USER), ".loopat", "vaults", "default"), { recursive: true }) + await writeFile(join(personalDir(USER), ".loopat", "config.json"), "{}") + await writeFile(join(TEST_HOME, "config.json"), "{}") +} + +beforeAll(setupFixture) +afterAll(() => rm(TEST_HOME, { recursive: true, force: true })) + +describe("buildVolumeMounts — core loop visibility", () => { + test("loops/<id>/.claude is bound at V_LOOP_CLAUDE (rw)", async () => { + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER }) + const m = mounts.find((m) => m.src === loopClaudeDir(LOOP_ID) && m.dst === V_LOOP_CLAUDE(LOOP_ID)) + expect(m).toBeDefined() + expect(m!.ro).toBeFalsy() + }) + + test("loops/<id>/workdir is bound at V_LOOP_WORKDIR (rw)", async () => { + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER }) + const m = mounts.find((m) => m.src === loopWorkdir(LOOP_ID) && m.dst === V_LOOP_WORKDIR(LOOP_ID)) + expect(m).toBeDefined() + expect(m!.ro).toBeFalsy() + }) + + test("personal/<user>/ is bound at BOTH virtual path AND host-absolute path", async () => { + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER }) + const dsts = mounts.filter((m) => m.src === personalDir(USER)).map((m) => m.dst) + expect(dsts).toContain(V_CONTEXT_PERSONAL) + expect(dsts).toContain(personalDir(USER)) + }) + + test("LOOPAT_INSTALL_DIR is ro-bound at host-absolute path", async () => { + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER }) + const m = mounts.find((m) => m.src === LOOPAT_INSTALL_DIR && m.dst === LOOPAT_INSTALL_DIR) + expect(m).toBeDefined() + expect(m!.ro).toBe(true) + }) + + test("$HOME is bound at V_HOME (= image passwd home, not host's homedir — see V_HOME comment)", async () => { + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER }) + const m = mounts.find((m) => m.dst === V_HOME(USER)) + expect(m).toBeDefined() + expect(m!.src).toBe(loopHomeUpper(LOOP_ID)) + // And NOT at host home — the whole point. + expect(mounts.find((m) => m.dst === HOST_HOME)).toBeUndefined() + }) + + test("knowledge is ro by default", async () => { + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER }) + const m = mounts.find((m) => m.dst === V_CONTEXT_KNOWLEDGE) + expect(m).toBeDefined() + expect(m!.ro).toBe(true) + }) + + test("knowledge becomes rw when knowledgeRw=true", async () => { + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER, knowledgeRw: true }) + const m = mounts.find((m) => m.dst === V_CONTEXT_KNOWLEDGE) + expect(m).toBeDefined() + expect(m!.ro).toBeFalsy() + }) + + test("notes is always rw", async () => { + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER }) + const m = mounts.find((m) => m.dst === V_CONTEXT_NOTES) + expect(m).toBeDefined() + expect(m!.ro).toBeFalsy() + }) +}) + +describe("buildVolumeMounts — plugin visibility via ~/.claude/plugins/", () => { + test("when host's ~/.claude/plugins/ exists, it's ro-bound under the sandbox $HOME", async () => { + const hostPluginsDir = join(HOST_HOME, ".claude", "plugins") + const sandboxPluginsDir = join(V_HOME(USER), ".claude", "plugins") + await mkdir(hostPluginsDir, { recursive: true }) + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER }) + const m = mounts.find((mt) => mt.src === hostPluginsDir && mt.dst === sandboxPluginsDir) + expect(m).toBeDefined() + expect(m!.ro).toBe(true) + }) + + test("when loop has a snapshot installed_plugins.json, it's bound OVER the sandbox's", async () => { + const loopIp = join(loopClaudeDir(LOOP_ID), "plugins", "installed_plugins.json") + await mkdir(join(loopClaudeDir(LOOP_ID), "plugins"), { recursive: true }) + await writeFile(loopIp, JSON.stringify({ version: 1, plugins: {} })) + + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER }) + const sandboxIp = join(V_HOME(USER), ".claude", "plugins", "installed_plugins.json") + const m = mounts.find((mt) => mt.src === loopIp && mt.dst === sandboxIp) + expect(m).toBeDefined() + expect(m!.ro).toBe(true) + }) + + test("when loop has NO snapshot, no file-level bind is added", async () => { + const loopIp = join(loopClaudeDir(LOOP_ID), "plugins", "installed_plugins.json") + await rm(loopIp, { force: true }) + + const mounts = await buildVolumeMounts({ loopId: LOOP_ID, createdBy: USER }) + const m = mounts.find((mt) => mt.src === loopIp) + expect(m).toBeUndefined() + }) +}) + +describe("buildPodmanCreateArgs — container shape", () => { + test("container is named loopat-<workspace>-<loopId>", async () => { + const args = await buildPodmanCreateArgs({ loopId: LOOP_ID, createdBy: USER }) + const nameIdx = args.indexOf("--name") + expect(nameIdx).toBeGreaterThanOrEqual(0) + expect(args[nameIdx + 1]).toBe(`loopat-${WORKSPACE}-${LOOP_ID}`) + expect(containerName(LOOP_ID)).toBe(`loopat-${WORKSPACE}-${LOOP_ID}`) + }) + + test("uses the content-hash loopat-sandbox image (locally built, no docker hub at runtime)", async () => { + const args = await buildPodmanCreateArgs({ loopId: LOOP_ID, createdBy: USER }) + // Content-addressed (no workspace prefix) so images are reused across + // workspaces instead of rebuilt per workspace; see podman.ts. + expect(args).toContain(`loopat-sandbox:latest`) + }) + + test("includes --userns keep-id:uid=2000,gid=2000, --init, --network loopat-<ws>", async () => { + const args = await buildPodmanCreateArgs({ loopId: LOOP_ID, createdBy: USER }) + const usernsIdx = args.indexOf("--userns") + // Fixed loopat user at uid 2000 inside; see Containerfile + podman.ts. + expect(args[usernsIdx + 1]).toBe("keep-id:uid=2000,gid=2000") + expect(args).toContain("--init") + const netIdx = args.indexOf("--network") + expect(args[netIdx + 1]).toBe(`loopat-${WORKSPACE}`) + }) + + test("workdir defaults to V_LOOP_WORKDIR", async () => { + const args = await buildPodmanCreateArgs({ loopId: LOOP_ID, createdBy: USER }) + const idx = args.indexOf("--workdir") + expect(idx).toBeGreaterThanOrEqual(0) + expect(args[idx + 1]).toBe(V_LOOP_WORKDIR(LOOP_ID)) + }) + + test("ends with <image> /bin/sleep infinity as the entrypoint", async () => { + const args = await buildPodmanCreateArgs({ loopId: LOOP_ID, createdBy: USER }) + expect(args.slice(-3)).toEqual([`loopat-sandbox:latest`, "/bin/sleep", "infinity"]) + }) + + test("each volume mount becomes a --volume src:dst[:ro] arg", async () => { + const args = await buildPodmanCreateArgs({ loopId: LOOP_ID, createdBy: USER }) + // Find at least one rw and one ro volume. + const volumes = args + .map((a, i) => (a === "--volume" ? args[i + 1] : null)) + .filter((s): s is string => s !== null) + expect(volumes.length).toBeGreaterThan(0) + expect(volumes.some((v) => v === `${loopWorkdir(LOOP_ID)}:${V_LOOP_WORKDIR(LOOP_ID)}`)).toBe(true) + expect(volumes.some((v) => v === `${LOOPAT_INSTALL_DIR}:${LOOPAT_INSTALL_DIR}:ro`)).toBe(true) + }) + + test("each env var becomes a --env K=V arg", async () => { + const args = await buildPodmanCreateArgs({ + loopId: LOOP_ID, + createdBy: USER, + extraEnv: { ANTHROPIC_API_KEY: "sk-test", MY_VAR: "v" }, + }) + const envs = args + .map((a, i) => (a === "--env" ? args[i + 1] : null)) + .filter((s): s is string => s !== null) + expect(envs).toContain("ANTHROPIC_API_KEY=sk-test") + expect(envs).toContain("MY_VAR=v") + }) + + test("includes a config-hash label so we can detect spec drift on restart", async () => { + const args = await buildPodmanCreateArgs({ loopId: LOOP_ID, createdBy: USER }) + const labels = args + .map((a, i) => (a === "--label" ? args[i + 1] : null)) + .filter((s): s is string => s !== null) + expect(labels.some((l) => l.startsWith("loopat.config-hash="))).toBe(true) + expect(labels.some((l) => l === `loopat.loop-id=${LOOP_ID}`)).toBe(true) + }) + + test("config-hash changes when vault changes", async () => { + const a = await buildPodmanCreateArgs({ loopId: LOOP_ID, createdBy: USER, vaultName: "default" }) + const b = await buildPodmanCreateArgs({ loopId: LOOP_ID, createdBy: USER, vaultName: "prod" }) + const hashA = a.find((s) => s.startsWith("loopat.config-hash=")) + const hashB = b.find((s) => s.startsWith("loopat.config-hash=")) + expect(hashA).toBeDefined() + expect(hashB).toBeDefined() + expect(hashA).not.toBe(hashB) + }) + + test("config-hash does NOT change when only extraEnv differs (regression: PTY exit-137 bug)", async () => { + // term.ts and session.ts call ensureContainer with the same loop opts + // but DIFFERENT extraEnv (PTY only needs vault envs; SDK adds + // ANTHROPIC_API_KEY / CLAUDE_CONFIG_DIR / etc.). If env were part of + // the hash, the second caller would force-recreate the container, + // SIGKILL'ing the first caller's exec'd process (the actual bug + // behind "PTY exits 137 when chat starts"). + const ptyLike = await buildPodmanCreateArgs({ + loopId: LOOP_ID, + createdBy: USER, + extraEnv: { VAULT_KEY: "v" }, + }) + const sdkLike = await buildPodmanCreateArgs({ + loopId: LOOP_ID, + createdBy: USER, + extraEnv: { + VAULT_KEY: "v", + ANTHROPIC_API_KEY: "sk-test", + ANTHROPIC_BASE_URL: "https://example", + CLAUDE_CONFIG_DIR: "/loopat/loop/.../.claude", + }, + }) + const hashPty = ptyLike.find((s) => s.startsWith("loopat.config-hash=")) + const hashSdk = sdkLike.find((s) => s.startsWith("loopat.config-hash=")) + expect(hashPty).toBe(hashSdk) + }) +}) + +describe("buildPodmanExecArgs — shape", () => { + test("exec into the right container with the right command", () => { + const args = buildPodmanExecArgs({ + loopId: LOOP_ID, + command: "/bin/bash", + args: ["-c", "echo hi"], + }) + expect(args[0]).toBe("exec") + expect(args).toContain(containerName(LOOP_ID)) + // command + arg tail + const tail = args.slice(-3) + expect(tail).toEqual(["/bin/bash", "-c", "echo hi"]) + }) + + test("--tty / --interactive flags wire through", () => { + const args = buildPodmanExecArgs({ + loopId: LOOP_ID, + command: "/bin/bash", + args: [], + tty: true, + interactive: true, + }) + expect(args).toContain("--tty") + expect(args).toContain("--interactive") + }) + + test("env vars become --env K=V before container name", () => { + const args = buildPodmanExecArgs({ + loopId: LOOP_ID, + command: "/bin/true", + args: [], + env: { FOO: "bar" }, + }) + const idx = args.indexOf("--env") + expect(idx).toBeGreaterThanOrEqual(0) + expect(args[idx + 1]).toBe("FOO=bar") + // env arg comes before container name + expect(idx).toBeLessThan(args.indexOf(containerName(LOOP_ID))) + }) + + test("--workdir option wires to -w", () => { + const args = buildPodmanExecArgs({ + loopId: LOOP_ID, + command: "/bin/pwd", + args: [], + workdir: "/loopat/loop/abc/workdir", + }) + const idx = args.indexOf("--workdir") + expect(idx).toBeGreaterThanOrEqual(0) + expect(args[idx + 1]).toBe("/loopat/loop/abc/workdir") + }) +}) + +describe("buildContainerEnv — caller env wins", () => { + test("extraEnv entries land in the resulting env map", async () => { + const env = await buildContainerEnv({ + loopId: LOOP_ID, + createdBy: USER, + extraEnv: { ANTHROPIC_API_KEY: "sk-test", VAULT_KEY: "v" }, + }) + expect(env.ANTHROPIC_API_KEY).toBe("sk-test") + expect(env.VAULT_KEY).toBe("v") + }) +}) diff --git a/server/test/profile-description.test.ts b/server/test/profile-description.test.ts new file mode 100644 index 00000000..a55d484d --- /dev/null +++ b/server/test/profile-description.test.ts @@ -0,0 +1,133 @@ +/** + * L1: extractProfileDescription — pull a one-line summary from a profile's + * CLAUDE.md. + * + * Priority order: + * 1. YAML frontmatter `description:` field + * 2. First non-empty heading (`# foo`), legacy fallback + * 3. Otherwise null (no description) + */ +import { test, expect, describe } from "bun:test" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-profile-desc-${process.pid}` +const { extractProfileDescription: extract } = await import("../src/tiers") + +describe("extractProfileDescription — frontmatter (preferred)", () => { + test("plain frontmatter description wins over heading", () => { + const md = `--- +description: ML training oncall — sls + spectrum CLI ready +--- + +# Some Heading +body...` + expect(extract(md)).toBe("ML training oncall — sls + spectrum CLI ready") + }) + + test("double-quoted description is unquoted", () => { + const md = `--- +description: "with: colons and special chars" +--- + +# Heading` + expect(extract(md)).toBe("with: colons and special chars") + }) + + test("single-quoted description is unquoted", () => { + const md = `--- +description: 'has \"nested\" quotes' +---` + expect(extract(md)).toBe(`has \"nested\" quotes`) + }) + + test("frontmatter with other fields — description still extracted", () => { + const md = `--- +title: Foo +version: 1 +description: Real description here +tags: [a, b] +--- + +content` + expect(extract(md)).toBe("Real description here") + }) + + test("empty description in frontmatter → falls through to heading fallback", () => { + const md = `--- +description: +--- + +# Fallback heading` + expect(extract(md)).toBe("Fallback heading") + }) +}) + +describe("extractProfileDescription — first-heading fallback (legacy)", () => { + test("# heading line → text", () => { + expect(extract("# Patent mode\nbody")).toBe("Patent mode") + }) + + test("## level-2 heading also works", () => { + expect(extract("## Sub-doctrine\nbody")).toBe("Sub-doctrine") + }) + + test("heading with trailing whitespace is trimmed", () => { + expect(extract("# spaced out heading \nbody")).toBe("spaced out heading") + }) + + test("leading blank lines are skipped", () => { + expect(extract("\n\n\n# Heading after blanks")).toBe("Heading after blanks") + }) + + test("file with content but NO heading and NO frontmatter → null", () => { + // First non-empty non-heading line returns null per spec (we don't + // promote arbitrary prose to "description" — that's noise). + expect(extract("just some prose, no heading anywhere")).toBeNull() + }) +}) + +describe("extractProfileDescription — edge cases", () => { + test("null input → null", () => { + expect(extract(null)).toBeNull() + }) + + test("empty string → null", () => { + expect(extract("")).toBeNull() + }) + + test("only frontmatter, no body → description still extracted", () => { + const md = `--- +description: Only-frontmatter file +--- +` + expect(extract(md)).toBe("Only-frontmatter file") + }) + + test("malformed frontmatter (no closing ---) → null (don't guess)", () => { + // We intentionally don't try to recover from a broken frontmatter fence: + // returning null surfaces the bug to the author (UI shows no description, + // they fix the file). Silent recovery would mask config errors. + const md = `--- +description: never closes + +# Real heading` + expect(extract(md)).toBeNull() + }) + + test("frontmatter with no description field → first heading fallback", () => { + const md = `--- +title: Has Title +tags: [x] +--- + +# Heading wins` + expect(extract(md)).toBe("Heading wins") + }) + + test("only whitespace → null", () => { + expect(extract(" \n\n\t \n")).toBeNull() + }) + + test("CR/LF (windows) line endings — heading still found", () => { + expect(extract("# CRLF heading\r\nbody")).toBe("CRLF heading") + }) +}) diff --git a/server/test/provider-resolution.test.ts b/server/test/provider-resolution.test.ts new file mode 100644 index 00000000..8cead1c2 --- /dev/null +++ b/server/test/provider-resolution.test.ts @@ -0,0 +1,170 @@ +/** + * L1+L2: provider resolution chain. + * + * Priority order (mirrors session.ts pickProvider): + * 1. explicit candidates (WS override, loop.meta.config.default_model) + * 2. personal config's `default` field + * 3. workspace config's `default` field + * 4. enumeration (personal first, then workspace) + * + * `requireKey=true` skips providers with empty apiKey. + * + * The "user sets default in their personal config" flow is the common case. + * "Workspace provides a fallback" supports admins seeding a shared provider + * for new users without forcing them to configure their own. "Switching + * provider mid-loop" is restart-session driven (covered in lifecycle). + */ +import { test, expect, describe } from "bun:test" +import type { ProviderConfig } from "../src/config" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-provider-${process.pid}` +const { pickProvider } = await import("../src/session") + +// Tiny builder so tests stay readable +function p(apiKey = "sk-x", baseUrl = "https://x"): ProviderConfig { + return { models: [{ id: "m", enabled: true }], baseUrl, apiKey, enabled: true } +} + +describe("pickProvider — priority order", () => { + test("explicit candidate beats personal default", () => { + const r = pickProvider( + { default: "personal-pref", providers: { "personal-pref": p(), "explicit": p() } }, + {}, + ["explicit"], + true, + ) + expect(r?.name).toBe("explicit") + }) + + test("personal default beats workspace default", () => { + const r = pickProvider( + { default: "personal-pref", providers: { "personal-pref": p() } }, + { default: "workspace-pref", providers: { "workspace-pref": p() } }, + [], + true, + ) + expect(r?.name).toBe("personal-pref") + }) + + test("workspace default used when personal has no default", () => { + const r = pickProvider( + { default: "", providers: {} }, + { default: "workspace-pref", providers: { "workspace-pref": p() } }, + [], + true, + ) + expect(r?.name).toBe("workspace-pref") + }) + + test("personal providers enumerated before workspace providers", () => { + // No default anywhere — enumerate. Personal "z" beats workspace "a" + // because personal is walked first. + const r = pickProvider( + { default: "", providers: { z: p() } }, + { default: "", providers: { a: p() } }, + [], + true, + ) + expect(r?.name).toBe("z") + }) + + test("explicit candidate falls through to next when missing", () => { + const r = pickProvider( + { default: "fallback", providers: { fallback: p() } }, + {}, + ["nonexistent"], + true, + ) + expect(r?.name).toBe("fallback") + }) +}) + +describe("pickProvider — requireKey semantics", () => { + test("with requireKey=true, providers with empty apiKey are skipped", () => { + const r = pickProvider( + { default: "no-key", providers: { "no-key": p(""), "has-key": p("sk-yes") } }, + {}, + [], + true, + ) + // no-key is the default but lacks apiKey → walk to next + expect(r?.name).toBe("has-key") + }) + + test("with requireKey=false, the default wins even without apiKey", () => { + const r = pickProvider( + { default: "no-key", providers: { "no-key": p("") } }, + {}, + [], + false, + ) + expect(r?.name).toBe("no-key") + }) + + test("returns null when no provider has an apiKey and requireKey=true", () => { + const r = pickProvider( + { default: "", providers: { a: p(""), b: p("") } }, + { providers: { c: p("") } }, + [], + true, + ) + expect(r).toBeNull() + }) + + test("returns null on completely empty configs", () => { + expect(pickProvider({ default: "", providers: {} }, {}, [], true)).toBeNull() + expect(pickProvider({ default: "", providers: {} }, {}, [], false)).toBeNull() + }) +}) + +describe("pickProvider — workspace fallback (admin scenario)", () => { + test("new user with no personal config falls through to workspace-shared provider", () => { + const r = pickProvider( + { default: "", providers: {} }, // bare personal + { default: "team-shared", providers: { "team-shared": p() } }, // admin seeded + [], + true, + ) + expect(r?.name).toBe("team-shared") + }) + + test("personal override SHADOWS workspace under the same name", () => { + const personalSpecial = p("sk-personal") + const workspaceShared = p("sk-workspace") + const r = pickProvider( + { default: "", providers: { foo: personalSpecial } }, + { providers: { foo: workspaceShared } }, + ["foo"], + true, + ) + // Personal wins on same name (line: `pCfg.providers[name] ?? wCfg.providers?.[name]`) + expect(r?.provider.apiKey).toBe("sk-personal") + }) +}) + +describe("pickProvider — explicit candidate with provider/model syntax", () => { + test('candidate "anthropic/claude-opus-4-7" matches provider "anthropic"', () => { + // pickProvider takes pre-parsed provider names. session.ts already strips + // the model part via parseDefault upstream. This test asserts the contract. + const r = pickProvider( + { default: "", providers: { anthropic: p() } }, + {}, + ["anthropic"], // already-parsed name + true, + ) + expect(r?.name).toBe("anthropic") + }) +}) + +describe("pickProvider — dedup", () => { + test("the same name appearing twice in candidates only resolves once", () => { + const r = pickProvider( + { default: "a", providers: { a: p(""), b: p("sk-b") } }, + {}, + ["a", "a", "a"], // would loop forever if not deduped + true, + ) + // a has no apiKey + dedup → walk through default "a" again (deduped) → enumeration → b + expect(r?.name).toBe("b") + }) +}) diff --git a/server/test/spawn-composition.test.ts b/server/test/spawn-composition.test.ts new file mode 100644 index 00000000..94943892 --- /dev/null +++ b/server/test/spawn-composition.test.ts @@ -0,0 +1,182 @@ +/** + * L3+: spawn-time composition glue. + * + * Asserts the chain session.ts executes at every spawn: + * + * vault/envs/* + provider.apiKey (${VAR}) + merged settings.json mcpServers + * ↓ (loadPersonalConfig + buildPodmanCreateArgs) + * podman argv with --env K=V entries and the mcpServers JSON ready to + * be passed through to the spawned claude binary verbatim. + * + * This is the integration that the vault refactor's behavior depends on. + * We don't spawn a real claude here; that's L4. + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile, readFile } from "node:fs/promises" +import { existsSync } from "node:fs" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-spawn-comp-${process.pid}` + +const { buildPodmanCreateArgs, V_LOOP_CLAUDE, V_HOME } = await import("../src/podman") +const { loadPersonalConfig, clearPersonalCache } = await import("../src/config") +const { + LOOPAT_HOME, + loopWorkdir, + loopClaudeDir, + loopContextKnowledge, + loopContextNotes, + personalDir, + personalLoopatDir, + personalLoopatConfigPath, + personalVaultEnvsDir, + personalVaultEnvPath, + personalVaultMountsHomeDir, + workspaceTeamClaudeDir, + workspaceTeamSettingsPath, +} = await import("../src/paths") +const TEST_HOME = LOOPAT_HOME + +const LOOP_ID = "cccccccc-1111-2222-3333-444444444444" +const USER = "alice" + +async function setup() { + await rm(TEST_HOME, { recursive: true, force: true }) + await mkdir(loopWorkdir(LOOP_ID), { recursive: true }) + await mkdir(loopClaudeDir(LOOP_ID), { recursive: true }) + await mkdir(loopContextKnowledge(LOOP_ID), { recursive: true }) + await mkdir(loopContextNotes(LOOP_ID), { recursive: true }) + await mkdir(personalLoopatDir(USER), { recursive: true }) + await mkdir(personalVaultEnvsDir(USER, "default"), { recursive: true }) + await writeFile(join(TEST_HOME, "config.json"), "{}") + // Vault envs: provider key + an MCP token + a generic env + await writeFile(personalVaultEnvPath(USER, "default", "IDEALAB_API_KEY"), "sk-idealab-test\n") + await writeFile(personalVaultEnvPath(USER, "default", "MCP_COOP_TOKEN"), "mcpa_coop_xxx\n") + await writeFile(personalVaultEnvPath(USER, "default", "GENERIC_VAR"), "generic-value\n") + // Personal config — provider uses ${VAR} + await writeFile(personalLoopatConfigPath(USER), JSON.stringify({ + providers: { + default: "idealab", + idealab: { + baseUrl: "https://idealab.example.com/api/anthropic", + model: "claude-opus-4-7", + apiKey: "${IDEALAB_API_KEY}", + }, + }, + })) + // Merged loop settings.json with mcpServers that reference ${VAR} + await writeFile(join(loopClaudeDir(LOOP_ID), "settings.json"), JSON.stringify({ + mcpServers: { + coop: { + type: "http", + url: "https://mcp.example.com/coop/mcp", + headers: { Authorization: "Bearer ${MCP_COOP_TOKEN}" }, + }, + }, + })) + // Team-tier settings (not strictly needed for this test, but realistic) + await mkdir(workspaceTeamClaudeDir(), { recursive: true }) + await writeFile(workspaceTeamSettingsPath(), JSON.stringify({ mcpServers: {} })) + clearPersonalCache(USER) +} + +beforeAll(setup) +afterAll(() => rm(TEST_HOME, { recursive: true, force: true })) + +function findEnv(argv: string[], key: string): string | undefined { + const prefix = `${key}=` + for (let i = 0; i < argv.length - 1; i++) { + if (argv[i] === "--env" && argv[i + 1].startsWith(prefix)) { + return argv[i + 1].slice(prefix.length) + } + } + return undefined +} + +describe("spawn-time composition — vault envs reach sandbox via podman --env", () => { + test("loadPersonalConfig resolves provider apiKey from vault envs", async () => { + const cfg = await loadPersonalConfig(USER, "default") + expect(cfg.providers.idealab.apiKey).toBe("sk-idealab-test") + }) + + test("vaultEnvs on cfg includes ALL envs/* (provider + MCP + generic)", async () => { + const cfg = await loadPersonalConfig(USER, "default") + expect(cfg.vaultEnvs).toMatchObject({ + IDEALAB_API_KEY: "sk-idealab-test", + MCP_COOP_TOKEN: "mcpa_coop_xxx", + GENERIC_VAR: "generic-value", + }) + }) + + test("podman argv carries every vault env as --env (the actual session.ts pattern)", async () => { + const cfg = await loadPersonalConfig(USER, "default") + const extraEnv = { + ...cfg.vaultEnvs, + ANTHROPIC_API_KEY: cfg.providers.idealab.apiKey, + ANTHROPIC_BASE_URL: cfg.providers.idealab.baseUrl, + CLAUDE_CONFIG_DIR: V_LOOP_CLAUDE(LOOP_ID), + } + const argv = await buildPodmanCreateArgs({ + loopId: LOOP_ID, + createdBy: USER, + vaultName: "default", + extraEnv, + }) + expect(findEnv(argv, "IDEALAB_API_KEY")).toBe("sk-idealab-test") + expect(findEnv(argv, "MCP_COOP_TOKEN")).toBe("mcpa_coop_xxx") + expect(findEnv(argv, "GENERIC_VAR")).toBe("generic-value") + // ANTHROPIC_API_KEY is the alias that the claude SDK reads + expect(findEnv(argv, "ANTHROPIC_API_KEY")).toBe("sk-idealab-test") + expect(findEnv(argv, "ANTHROPIC_BASE_URL")).toBe("https://idealab.example.com/api/anthropic") + }) + + test("merged settings.json mcpServers headers preserve ${VAR} (substituted by spawned binary)", async () => { + const merged = JSON.parse(await readFile(join(loopClaudeDir(LOOP_ID), "settings.json"), "utf8")) + const coop = merged.mcpServers.coop + // CRUCIAL: literal ${VAR} must survive — the spawned claude binary + // substitutes it from its own process.env, which is what we just injected. + expect(coop.headers.Authorization).toBe("Bearer ${MCP_COOP_TOKEN}") + }) + + test("provider with missing ${VAR} → empty apiKey (provider effectively unusable)", async () => { + // Add a provider whose ${VAR} doesn't exist in vault + await writeFile(personalLoopatConfigPath(USER), JSON.stringify({ + providers: { + default: "idealab", + idealab: { baseUrl: "u", model: "m", apiKey: "${IDEALAB_API_KEY}" }, + ghostly: { baseUrl: "u", model: "m", apiKey: "${NOT_IN_VAULT}" }, + }, + })) + clearPersonalCache(USER) + const cfg = await loadPersonalConfig(USER, "default") + expect(cfg.providers.idealab.apiKey).toBe("sk-idealab-test") + expect(cfg.providers.ghostly.apiKey).toBe("") + }) +}) + +describe("spawn-time composition — mounts/home/ → $HOME via podman --volume", () => { + test("vault mounts/home/<entry> reaches sandbox $HOME alongside vault envs", async () => { + // Add a mount alongside the envs + const mh = personalVaultMountsHomeDir(USER, "default") + await mkdir(join(mh, ".ssh"), { recursive: true }) + await writeFile(join(mh, ".ssh", "id_ed25519"), "FAKE_PRIV_KEY") + await writeFile(join(mh, ".gitconfig"), "[user]\nname = test\n") + + const cfg = await loadPersonalConfig(USER, "default") + const argv = await buildPodmanCreateArgs({ + loopId: LOOP_ID, + createdBy: USER, + vaultName: "default", + extraEnv: cfg.vaultEnvs, + }) + + // Each top-level entry under mounts/home/ → --volume at sandbox $HOME/<rel> + const SANDBOX_HOME = V_HOME(USER) + const volumes: string[] = [] + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--volume") volumes.push(argv[i + 1]) + } + expect(volumes).toContain(`${join(mh, ".ssh")}:${join(SANDBOX_HOME, ".ssh")}`) + expect(volumes).toContain(`${join(mh, ".gitconfig")}:${join(SANDBOX_HOME, ".gitconfig")}`) + }) +}) diff --git a/server/test/test-goal.md b/server/test/test-goal.md new file mode 100644 index 00000000..a766b177 --- /dev/null +++ b/server/test/test-goal.md @@ -0,0 +1,487 @@ +# Loopat Test Goals + +This file is the **contract**, not a case list. Each goal describes what the +system must do from a user/integrator's perspective. AI (or humans) translate +each goal to executable cases at one or more levels. + +## Levels + +| Level | What runs | Cost | When | +|-------|-----------|------|------| +| **L1** | Pure functions, in-process | ~ms, 0 token | Every push | +| **L2** | HTTP via Hono `app.request()` (no listener) | ~100ms, 0 token | Every push | +| **L3** | Compose / bwrap argv assertions (no real spawn) | ~10ms, 0 token | Every push | +| **L3+** | Spawn-time chain end-to-end (vault → loadCfg → bwrap argv), still no real claude | ~ms, 0 token | Every push | +| **L4** | Real claude binary + provider API (gated by `LOOPAT_E2E_AI=1`) | ~15s, ~¥0.5 | Before release | + +## Layout + +``` +server/test/ + ── core refactor (vault delivery) ───────────────────────────────────── + vault.test.ts L1 — loadVaultEnvs, listVaultHomeMounts + config-vault.test.ts L1 — expandVars, providerEnvVarName, describeApiKeyRef, + writeVaultEnv, deleteVaultEnv, loadPersonalConfig + mcp-oauth.test.ts L1 — mcpServerEnvVarName + bwrap-vault.test.ts L3 — vault/mounts/home/* → bwrap --bind-try args + spawn-composition.test.ts L3+ — vault envs → bwrap argv chain + api-mcp.test.ts L2 — /api/mcp-servers, /api/mcp-auth + api-settings.test.ts L2 — /api/settings/personal/{disk,value} + + ── pre-existing (composition / sandbox) ──────────────────────────────── + bwrap.test.ts L3 — composed .claude/ visibility + compose.test.ts L3 — tier merge + plugin-installer.test.ts L3 — plugin install + + ── usage scenarios & team collab ────────────────────────────────────── + multi-user.test.ts L1+L3 — alice vs bob isolation across vault/personal/binds + driver-handoff.test.ts L1+L3 — effectiveDriver, vault follows driver not creator, RFD shape + multi-vault.test.ts L1+L3 — multi-vault per user, selection, escape-symlink rejection + provider-resolution.test.ts L1 — pickProvider priority order, workspace fallback, requireKey + lifecycle-frozen.test.ts L3 — principle 1: admin pushes don't change existing loop snapshot + mcp-shadowing.test.ts L2+L3 — personal mcpServer shadows team in compose + UI flagging + + ── E2E gated ────────────────────────────────────────────────────────── + e2e-ai.test.ts L4 — real AI reads vault env (LOOPAT_E2E_AI=1) +``` + +## Conventions + +- **Set `LOOPAT_HOME` before importing source.** `paths.ts` captures it at module + load. Use `??=` so test files can compose into the same bun run. +- **Pre-seed `config.json` before importing `index.ts`** in L2 tests — otherwise + the bootstrap tries to clone the workspace's `repos[]`. +- **`PORT=0` + `LOOPAT_SERVE_PORT=0`** in L2 tests to avoid listener collisions. +- **Call `clearPersonalCache(user)` between writes** that target the same + `(user, vault)` — the personal config cache key is mtime-based, and bun-fast + back-to-back writes can hit the same millisecond. +- **Goals can map to multiple cases** at different levels. Cross-reference both + ways: goal → which test file/level cover it, file → which goals each test + asserts. + +## Goal Format + +```markdown +### G-CATEGORY-NN: short title + +**Given** +- preconditions about world state + +**When** +- the action under test + +**Then** +- L?: the assertion at that level +- L?: ... + +**Variations** (optional) +- "what if X also happens" + +**Notes** (optional) +- known limitations / out-of-scope +``` + +--- + +## Goal Catalog + +### Composition: `.claude/` tier merge + +**G-COMPOSE-01: team CLAUDE.md reaches sandbox** +- **Given** `knowledge/.loopat/.claude/CLAUDE.md` contains "TEAM_MARKER" +- **When** user creates a loop (no profile, no personal CLAUDE.md) +- **Then** + - L3 (`compose.test.ts`): composed `loops/<id>/.claude/CLAUDE.md` contains "TEAM_MARKER" + +**G-COMPOSE-02: profile layers stack onto team in declared order** +- **Given** team + profile A + profile B each have a CLAUDE.md +- **When** loop spawned with profiles=[A, B] +- **Then** + - L3: composed CLAUDE.md = team → profile A → profile B (in that order) + +**G-COMPOSE-03: personal CLAUDE.md is the outermost user-tier layer** +- **Given** team + profile + personal CLAUDE.md all present +- **When** loop spawned +- **Then** + - L3: order is team → profile(s) → personal in the composed CLAUDE.md + +**G-COMPOSE-04: skills merge by name with later-wins shadowing** +- **Given** team and personal both define a skill named "foo" +- **When** loop spawned +- **Then** + - L3: composed `skills/foo/SKILL.md` is the personal version (last-wins) + - L3: composed `skills/` is a directory of symlinks union'd across tiers + +**G-COMPOSE-05: settings.json enabledPlugins union + disable-wins** +- **Given** team enables plugin X, personal disables it +- **When** compose runs +- **Then** + - L3: merged settings.json has X explicitly disabled (false beats true) + +### Vault Delivery + +**G-VAULT-01: vault/envs/<NAME> → $NAME in sandbox** +- **Given** `vaults/default/envs/GITHUB_TOKEN` contains "ghp_xxx" +- **When** loop spawns +- **Then** + - L1 (`vault.test.ts`): `loadVaultEnvs()` returns `{GITHUB_TOKEN: "ghp_xxx"}` + - L3+ (`spawn-composition.test.ts`): bwrap argv includes `--setenv GITHUB_TOKEN ghp_xxx` + - L4 (`e2e-ai.test.ts`): AI's `Bash` tool can read `$GITHUB_TOKEN` + +**G-VAULT-02: vault/mounts/home/<rel> → $HOME/<rel>** +- **Given** `vaults/default/mounts/home/.ssh/id_ed25519` exists +- **When** loop spawns +- **Then** + - L1 (`vault.test.ts`): `listVaultHomeMounts()` includes `.ssh` + - L3 (`bwrap-vault.test.ts`): bwrap argv includes `--bind-try <src> $HOME/.ssh` + +**G-VAULT-03: top-level only — subdirs not separately bound** +- **Given** `mounts/home/.config/gh/` + `mounts/home/.config/a1/` both exist +- **When** loop spawns +- **Then** + - L3: exactly ONE bind for `.config` (the whole dir), NOT separate binds for `gh` and `a1` + +**G-VAULT-04: missing vault → no envs, no mounts, graceful** +- **Given** vault dir doesn't exist +- **When** loop spawns +- **Then** + - L1: `loadVaultEnvs()` returns `{}` + - L1: `listVaultHomeMounts()` returns `[]` + - L3: bwrap argv has no vault-derived setenv or bind + +**G-VAULT-05: multi-vault — only active vault delivered** +- **Given** vaults default + dev each have different envs +- **When** loop spawns with `meta.config.vault="dev"` +- **Then** + - L1: `loadVaultEnvs(user, "dev")` returns only dev envs + - L3: bwrap argv setenv reflects dev envs, not default + +**G-VAULT-06: invalid env filenames are skipped** +- **Given** `envs/bad-name`, `envs/1starts_digit`, `envs/.dotfile` exist +- **When** vault envs loaded +- **Then** + - L1: those filenames are silently ignored (no env, no error) + +### Provider apiKey resolution + +**G-PROV-01: ${VAR} in provider.apiKey resolves from vault envs** +- **Given** `vaults/default/envs/IDEALAB_API_KEY=sk-xxx`, config.json apiKey=`${IDEALAB_API_KEY}` +- **When** loadPersonalConfig runs +- **Then** + - L1: `cfg.providers.idealab.apiKey === "sk-xxx"` + - L3+: bwrap argv has `--setenv ANTHROPIC_API_KEY sk-xxx` + +**G-PROV-02: missing ${VAR} → empty apiKey** +- **Given** apiKey=`${NOT_IN_VAULT}`, no matching env file +- **Then** + - L1: `cfg.providers.X.apiKey === ""` + - L2 (`api-mcp.test.ts`): `/api/settings/personal/disk` refExists shows kind=var, exists=false + +**G-PROV-03: literal apiKey (no ${...}) passes through unchanged** +- **Given** apiKey="sk-literal-here" +- **Then** + - L1: `cfg.providers.X.apiKey === "sk-literal-here"` + - L2: `describeApiKeyRef` reports kind=literal + +**G-PROV-04: providerEnvVarName derivation is stable across UI + server** +- **Given** provider name "DeepSeek" +- **Then** + - L1 (server + web mirror): both yield "DEEPSEEK_API_KEY" + +### MCP + +**G-MCP-01: workspace mcpServer + envs/MCP_*_TOKEN → spawned binary substitutes header** +- **Given** workspace settings.json has http server with `headers.Authorization="Bearer ${MCP_COOP_TOKEN}"`, + vault envs has `MCP_COOP_TOKEN=mcpa_xxx` +- **When** loop spawns +- **Then** + - L3+: merged settings.json on disk preserves literal `${MCP_COOP_TOKEN}` (spawned binary expands) + - L3+: bwrap argv has `--setenv MCP_COOP_TOKEN mcpa_xxx` + - L4 (future): AI's `mcp__coop__*` tool call succeeds against a stub server that requires the header + +**G-MCP-02: OAuth callback persists token to vault env file** +- **Given** mcp-oauth completes the auth exchange +- **When** completeMcpAuth() returns +- **Then** + - L1: `vaults/<v>/envs/MCP_<NAME>_TOKEN` exists with the access token as content (only — no metadata sidecar) + - L2: subsequent `GET /api/mcp-auth` reports `MCP_<NAME>_TOKEN: { connected: true }` + +**G-MCP-03: DELETE /api/mcp-auth/:server removes vault env** +- **Given** vault has `MCP_GITHUB_TOKEN` +- **When** DELETE /api/mcp-auth/github +- **Then** + - L2 (`api-mcp.test.ts`): file removed; subsequent GET shows connected=false + +**G-MCP-04: GET /api/mcp-servers returns team + plugin + personal tiers** +- **Given** team settings.json defines 2 servers, no profiles, no personal MCP +- **When** GET /api/mcp-servers +- **Then** + - L2: response has 3 tiers in order [team, plugin, personal] + - L2: team tier surfaces the 2 servers by name + type + url + +**G-MCP-05: personal entry with same name as team flags shadowsWorkspace** +- **Given** team + personal both define a server "github" +- **When** GET /api/mcp-servers +- **Then** + - L2: personal tier's github entry has `shadowsWorkspace: true` + +**G-MCP-06: mcpServerEnvVarName collisions are tolerated (not surprising)** +- **Given** server names "foo-bar" and "foo_bar" +- **Then** + - L1: both map to same env var name; documented as expected behavior + +**G-MCP-07: `/api/mcp-auth` response is keyed by env-var name (not server name)** +- **Given** vault has MCP_GITHUB_TOKEN + MCP_LINEAR_TOKEN +- **When** GET /api/mcp-auth +- **Then** + - L2: response keys are "MCP_GITHUB_TOKEN" / "MCP_LINEAR_TOKEN", each with `{connected, varName}` +- **Notes**: UI must call `mcpServerEnvVarName(serverName)` to look up status by server name + +### Settings UI contract + +**G-SET-01: GET /api/settings/personal/disk never leaks resolved apiKey values** +- **Given** config.json has apiKey=`${IDEALAB_API_KEY}` and vault has actual value "sk-secret" +- **When** GET /api/settings/personal/disk +- **Then** + - L2: response body does NOT contain "sk-secret" anywhere + - L2: disk.providers.idealab.apiKey is the template string `"${IDEALAB_API_KEY}"` + +**G-SET-02: POST /api/settings/personal/value writes vault env** +- **When** POST with `{name, value, vault}` +- **Then** + - L2: `vaults/<vault>/envs/<name>` contains the value (with trailing newline) + - L2: invalid name / invalid vault → 400 + +**G-SET-03: PUT /api/settings/personal/disk validates provider shape** +- **Given** patch with provider missing baseUrl, OR default not string +- **Then** + - L2: 400 with error message; config.json unchanged + +### CLI usability (relies on G-VAULT-02) + +**G-CLI-01: gh CLI reads its config from sandbox $HOME/.config/gh/** +- **Given** vault has `mounts/home/.config/gh/{config.yml, hosts.yml}` +- **When** loop spawns, AI runs `gh auth status` +- **Then** + - L3: bwrap argv binds `.config` correctly + - L4 (manual): `gh auth status` exits 0 inside sandbox + +**G-CLI-02: ssh-based git uses ~/.ssh/id_ed25519 from vault** +- **Given** vault has `mounts/home/.ssh/{id_ed25519, config}` +- **When** AI runs `ssh -T git@github.com` +- **Then** + - L4 (manual): ssh succeeds (exit 1 with "Hi <user>!" message) + +**G-CLI-03: git commit picks up identity from vault .gitconfig** +- **Given** vault has `mounts/home/.gitconfig` with [user] section +- **When** AI commits in workdir +- **Then** + - L4 (manual): commit author matches .gitconfig + +### Loop lifecycle + +**G-LIFE-01: restart-session re-reads vault** +- **Given** loop running, user rotates MCP token (writes new envs/MCP_*_TOKEN) +- **When** POST /api/loops/:id/restart-session, then next user message +- **Then** + - L2: endpoint returns ok + - L3+ (future): subsequent spawn carries the NEW token in --setenv + +**G-LIFE-02: principle 1 — old loop's settings/skills never change** +- **Given** loop created at time T +- **When** admin pushes new team settings.json at time T+1 +- **Then** + - L3: re-spawning the old loop still uses the snapshot from T +- **Notes**: composeLoopClaudeConfig writes once at creation; settings.json snapshot is frozen + +### Isolation + +**G-ISO-01: vault is NOT exposed as /loopat/context/vault directory** +- **When** any loop spawns +- **Then** + - L3 (`bwrap-vault.test.ts`): no `--symlink` targets `/loopat/context/vault` + - L3: bwrap module does not export `V_CONTEXT_VAULT` + +**G-ISO-02: sandbox sees no host paths beyond explicitly bound ones** +- **Given** operator config has no `mounts`, vault has no `mounts/home/` +- **When** loop spawns +- **Then** + - L3: bwrap argv only binds /usr /etc /lib /lib64 /bin /sbin /opt /var /run (RO) + plus per-loop dirs, plus LOOPAT_INSTALL_DIR, plus ~/.claude/plugins/ + +**G-ISO-03: vaults are per-user (other users invisible)** +- **Given** user alice + user bob each have their own vault +- **When** alice's loop spawns +- **Then** + - L3: only alice's personal/ is bound at /loopat/context/personal/, bob's never appears + +### Doctrine + +**G-DOC-01: bundled CLAUDE.md does not mention vault / mounts** +- **When** scan `server/templates/CLAUDE.md` +- **Then** + - L1 (text grep): no occurrence of `/loopat/context/vault`, "vault", or "mount" as a concept + - **Why**: AI should see a configured machine, not a framework. Per-user habit lives in + `personal/<u>/.loopat/.claude/CLAUDE.md`. + +### Multi-user isolation + +**G-USR-01: vault contents per-user — no cross-user leak** +- **Given** alice + bob each have their own `vaults/default/envs/PRIVATE_VAR` +- **Then** + - L1 (`multi-user.test.ts`): `loadVaultEnvs("alice")` never returns bob's keys; same-named var resolves to each owner's value + +**G-USR-02: each user's provider apiKey resolves from their own vault** +- **Given** alice + bob both have provider "anthropic" with `apiKey: "${ANTHROPIC_API_KEY}"` but different vault contents +- **Then** + - L1: `loadPersonalConfig("alice")` and `("bob")` produce different `provider.apiKey` + +**G-USR-03: personal CLAUDE.md only reaches its owner's loops** +- **Given** alice has `# ALICE_DOCTRINE`, bob has `# BOB_DOCTRINE` +- **Then** + - L3 (compose): alice's loop CLAUDE.md contains ALICE_DOCTRINE only, bob's contains BOB_DOCTRINE only + +**G-USR-04: bwrap binds only the driving user's `/personal/` dir** +- **Given** alice spawns a loop, bob spawns another loop +- **Then** + - L3: alice's bwrap argv contains `/personal/alice` binds, no `/personal/bob`; symmetric for bob + +**G-USR-05: vault envs do not leak across user spawns** +- **Given** alice has `ALICE_KEY` in her vault +- **When** bob's loop spawns with `bobCfg.vaultEnvs` +- **Then** + - L3: bob's bwrap argv does not contain "ALICE_KEY" or its value + +### Driver handoff (RFD) + +**G-DRIVE-01: `effectiveDriver` falls back to createdBy when driver field absent** +- **Then** L1 (`driver-handoff.test.ts`): `effectiveDriver({createdBy: "alice"}) === "alice"` + +**G-DRIVE-02: driver field, when set, beats createdBy for `effectiveDriver` and `isDriver`** +- **Then** L1: `effectiveDriver({createdBy: "alice", driver: "bob"}) === "bob"`; `isDriver(meta, "alice") === false` + +**G-DRIVE-03: after handoff, sandbox uses the driver's vault — not the creator's** +- **Given** alice created loop, bob now drives, both have configured apiKey +- **Then** + - L1: `loadPersonalConfig(driver)` returns bob's resolved apiKey + - L3: `buildBwrapArgs(loopId, "bob", ...)` binds `/personal/bob/`; no `/personal/alice/` + +**G-DRIVE-04: driver's vault envs reach setenv post-handoff** +- **Then** L3: bwrap argv has bob's ANTHROPIC_API_KEY value, never alice's + +**G-DRIVE-05: RFD state machine — rfdRequestedAt set + driver unchanged** +- **Then** L1: structural assertion on meta shape; UI gates writes by this field + +**G-DRIVE-06: after takeover — rfd cleared, driver replaced, driverHistory appended** +- **Then** L1: last `driverHistory` entry matches `driver` + +**G-DRIVE-07: pendingDriverNote — one-shot, consumed by next user message** +- **Then** L1: structural shape contract (sendUserText reads then clears) + +### Multi-vault (per-loop identity selection) + +**G-MVAULT-01: `listVaults` returns all user's vaults in sorted order** +- **Then** L1 (`multi-vault.test.ts`): default, dev, prod sorted alpha + +**G-MVAULT-02: `isValidVaultName` rejects path-escape names** +- **Then** L1: `../escape`, `.hidden`, empty, > length cap all rejected + +**G-MVAULT-03: per-loop vault selection — exclusive envs don't bleed** +- **Given** dev has `DEV_ONLY`, prod has `PROD_ONLY` +- **Then** + - L1: `loadVaultEnvs(user, "dev")` returns DEV_ONLY but not PROD_ONLY + - L3: bwrap argv reflects only the selected vault's envs + +**G-MVAULT-04: same loop spawned with different vault → different env surface** +- **Then** L3: argvDev contains DEV_ONLY, argvProd does NOT + +**G-MVAULT-05: in-vault and cross-vault symlinks within user tree are allowed** +- **Then** L1: walkVaultFiles yields targets under same user tree + +**G-MVAULT-06: symlinks escaping the user tree are REJECTED** +- **Given** `vault/prod/envs/EVIL` → `/etc/passwd` (or another user's vault) +- **Then** + - L1: walkVaultFiles silently drops; warning to stderr + +### Provider resolution chain + +**G-PROVCH-01: priority order — explicit > personal default > workspace default > enumeration** +- **Then** L1 (`provider-resolution.test.ts`): each tier asserted individually + +**G-PROVCH-02: personal default beats workspace default** +- **Then** L1: `pickProvider({default: P}, {default: W}, [], true).name === P` + +**G-PROVCH-03: workspace default is the fallback for new users (admin-seeded)** +- **Then** L1: bare personal + workspace-default → workspace wins + +**G-PROVCH-04: personal providers shadow workspace under same name** +- **Then** L1: `pickProvider({providers: {foo: P}}, {providers: {foo: W}}, ["foo"], true).provider === P` + +**G-PROVCH-05: `requireKey=true` skips empty-apiKey providers and walks to next** +- **Then** L1: default with no apiKey + other with apiKey → other wins under requireKey + +**G-PROVCH-06: returns null when nothing matches (no provider, or all keyless under requireKey)** +- **Then** L1 + +**G-PROVCH-07: candidate names are deduped (no infinite loop, no redundant lookup)** +- **Then** L1: `pickProvider(cfg, {}, ["a", "a", "a"], true)` walks each unique name once + +### Loop lifecycle — frozen snapshot + +**G-FROZEN-01: compose materializes team CLAUDE.md + settings.json on first run** +- **Then** L3 (`lifecycle-frozen.test.ts`): contents reflect team source state + +**G-FROZEN-02: admin pushes new team — existing loop snapshot unchanged** +- **Given** loop has snapshot at TEAM_V1; admin overwrites team source to TEAM_V2 +- **Then** L3: reading loop's CLAUDE.md/settings.json still returns V1 +- **Why**: principle 1 — "loops are frozen at creation"; session.ts only re-composes when snapshot missing + +**G-FROZEN-03: a NEW loop after the push picks up the new team state** +- **Then** L3: new loop's snapshot reflects TEAM_V2 + +**G-FROZEN-04: re-running compose explicitly DOES regenerate (caller's choice)** +- **Then** L3: compose is "materialize current source state", not "stay frozen". Snapshot freeze is upstream gate (session.ts: only compose if absent) + +**G-FROZEN-05: settings.json existence acts as the snapshot sentinel** +- **Then** L3: after compose, settings.json exists at the gate path session.ts checks + +### MCP tier shadowing + +**G-MCPSH-01: team tier surfaces all team-defined servers via API** +- **Then** L2 (`mcp-shadowing.test.ts`): `/api/mcp-servers` returns team servers under `tiers[id=team]` + +**G-MCPSH-02: personal entry shadowing team flagged with shadowsWorkspace=true** +- **Given** team has "github", personal also has "github" +- **Then** L2: personal tier's github has `shadowsWorkspace: true`; non-shadowing entry has `false` + +**G-MCPSH-03: personal entry wins over team in composed settings.json (last-wins)** +- **Then** L3: merged `loops/<id>/.claude/settings.json` `mcpServers.github` is the personal URL, NOT team + +**G-MCPSH-04: shadowing is full-object replacement, not shallow merge** +- **Given** team's github has `headers: { Authorization: ... }`; personal's github has no headers +- **Then** L3: composed entry has no `headers` field (personal won wholesale, including absence) + +**G-MCPSH-05: team-only server survives compose intact** +- **Given** only team defines "linear" +- **Then** L3: composed has team's full linear object + +--- + +## Adding goals — workflow + +1. Write the goal in this file. Be specific about Given/When/Then per level. +2. Find or create the test file matching the level. +3. Implement the case; mark which goal(s) it covers in a `// G-XXX-NN` comment. +4. Run `bun test` from `server/`. Iterate until green. +5. For L4, gate with `describe.skipIf(SKIP)` checking `LOOPAT_E2E_AI`. + +## Open follow-ups (goals not yet covered) + +- **G-COMPOSE-04 / -05** — partially covered by existing `compose.test.ts`; verify + shadowing matches the spec. +- **G-MCP-01** L4 — need a stub MCP server fixture for full chain assertion. Currently + only L3+ verifies the header template survives. +- **G-LIFE-01** L3+ — requires spawning twice with token rotation between. Useful for + catching cache bugs in `loadPersonalConfig`. +- **G-CLI-01..03** L4 — manual sanity for now; could automate with a fake git server. +- **G-ISO-03** — currently implicit; explicit assertion welcomed. diff --git a/server/test/toolchain-count.test.ts b/server/test/toolchain-count.test.ts new file mode 100644 index 00000000..1d6083a7 --- /dev/null +++ b/server/test/toolchain-count.test.ts @@ -0,0 +1,137 @@ +/** + * L1+L3: toolchainCount — count tools declared in mise.toml. + * + * Each top-level key under [tools] counts as one (bare like `python = "3.12"` + * or nested like `[tools."http:a1"]`). Missing/malformed file → 0. + * + * Display sites assert presence in: + * - NewLoopDialog footer (LoopStats.toolchain via /api/loop-stats) + * - ClaudeConfigPanel StatChip (TierInfo.toolchainCount via /api/tiers) + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" + +process.env.LOOPAT_HOME ??= `/tmp/loopat-toolchain-${process.pid}` +const { countToolchainTools, computeLoopStats } = await import("../src/loop-stats") +const { + LOOPAT_HOME, + workspaceTeamClaudeDir, + workspaceProfileClaudeDir, +} = await import("../src/paths") +const TEST_HOME = LOOPAT_HOME + +async function reset() { await rm(TEST_HOME, { recursive: true, force: true }) } + +beforeAll(reset) +afterAll(() => rm(TEST_HOME, { recursive: true, force: true })) + +describe("countToolchainTools", () => { + test("returns [] when mise.toml missing", () => { + expect(countToolchainTools("/tmp/nope-" + Math.random())).toEqual([]) + }) + + test("counts bare-key entries: `name = \"version\"`", async () => { + const dir = join(TEST_HOME, "fixture-bare") + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "mise.toml"), `[tools] +python = "3.12" +bun = "latest" +gh = "latest" +`) + expect(countToolchainTools(dir).sort()).toEqual(["bun", "gh", "python"]) + }) + + test("counts nested [tools.<name>] sections", async () => { + const dir = join(TEST_HOME, "fixture-nested") + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "mise.toml"), `[tools] +node = "20" + +[tools."http:a1"] +version = "0.1.87" + +[tools."http:dashctl"] +version = "v0.4.3" +`) + expect(countToolchainTools(dir).sort()).toEqual(["http:a1", "http:dashctl", "node"]) + }) + + test("counts quoted-key bare entries (ubi:..., http:..., etc.)", async () => { + const dir = join(TEST_HOME, "fixture-quoted") + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "mise.toml"), `[tools] +"ubi:fish-shell/fish-shell" = { version = "latest", exe = "fish" } +jq = "latest" +`) + expect(countToolchainTools(dir).sort()).toEqual(["jq", "ubi:fish-shell/fish-shell"]) + }) + + test("empty [tools] → []", async () => { + const dir = join(TEST_HOME, "fixture-empty") + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "mise.toml"), `[tools]\n`) + expect(countToolchainTools(dir)).toEqual([]) + }) + + test("no [tools] section at all → []", async () => { + const dir = join(TEST_HOME, "fixture-no-tools") + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "mise.toml"), `[env]\nFOO = "bar"\n`) + expect(countToolchainTools(dir)).toEqual([]) + }) + + test("malformed toml → [] (no throw)", async () => { + const dir = join(TEST_HOME, "fixture-bad") + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "mise.toml"), `not valid toml [[[\n`) + expect(countToolchainTools(dir)).toEqual([]) + }) +}) + +describe("computeLoopStats — toolchain field", () => { + beforeAll(async () => { + await reset() + // Team has 3 tools: python, bun, gh + await mkdir(workspaceTeamClaudeDir(), { recursive: true }) + await writeFile(join(workspaceTeamClaudeDir(), "mise.toml"), `[tools] +python = "3.12" +bun = "latest" +gh = "latest" +`) + // Profile A adds 2 new tools: jq, uv (deduped: 5 total) + await mkdir(workspaceProfileClaudeDir("a"), { recursive: true }) + await writeFile(join(workspaceProfileClaudeDir("a"), "mise.toml"), `[tools] +jq = "latest" +uv = "latest" +`) + // Profile B overrides bun + adds node (deduped: 1 new = 6 total when both selected) + await mkdir(workspaceProfileClaudeDir("b"), { recursive: true }) + await writeFile(join(workspaceProfileClaudeDir("b"), "mise.toml"), `[tools] +bun = "1.0.0" +node = "20" +`) + }) + + test("team-only → 3 toolchain", async () => { + const stats = await computeLoopStats([]) + expect(stats.toolchain).toBe(3) + }) + + test("team + profile A → 5 (3 + 2 new)", async () => { + const stats = await computeLoopStats(["a"]) + expect(stats.toolchain).toBe(5) + }) + + test("team + profile A + profile B → 6 (3 team + 2 A + 1 new from B; bun is shared)", async () => { + const stats = await computeLoopStats(["a", "b"]) + // python, bun, gh, jq, uv, node = 6 distinct + expect(stats.toolchain).toBe(6) + }) + + test("team + profile B only → 4 (3 + 1 new; bun is overridden but same key)", async () => { + const stats = await computeLoopStats(["b"]) + // python, bun, gh, node = 4 (bun dedupes with team's bun) + expect(stats.toolchain).toBe(4) + }) +}) diff --git a/server/test/ui-notes-sync.test.ts b/server/test/ui-notes-sync.test.ts new file mode 100644 index 00000000..5a454e7f --- /dev/null +++ b/server/test/ui-notes-sync.test.ts @@ -0,0 +1,118 @@ +/** + * git-as-database stage 0: notes edited through a per-user UI-loop worktree + * (opened from origin/main), saved back with the same ff-only + rebase + + * held-back rule as personal. Local bare repo as origin + a second clone to + * simulate a concurrent writer. + */ +import { test, expect, beforeAll, afterAll } from "bun:test" +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises" +import { existsSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, dirname } from "node:path" +import { promisify } from "node:util" +import { execFile } from "node:child_process" + +const run = promisify(execFile) +const g = (args: string[], cwd?: string) => run("git", cwd ? ["-C", cwd, ...args] : args) + +let home: string +let loops: any +let paths: any +let wt: string +let other: string +const user = "uitest" + +beforeAll(async () => { + home = await mkdtemp(join(tmpdir(), "loopat-uinotes-")) + process.env.LOOPAT_HOME = home + loops = await import("../src/loops.ts") + paths = await import("../src/paths.ts") + + const origin = join(home, "notes-origin.git") + other = join(home, "other") + await g(["init", "--bare", "-b", "main", origin]) + const ctx = paths.workspaceNotesDir() + await mkdir(dirname(ctx), { recursive: true }) + await g(["clone", origin, ctx]) + await writeFile(join(ctx, "seed.md"), "seed\n") + await g(["add", "-A"], ctx) + await g(["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"], ctx) + await g(["push", "origin", "HEAD:main"], ctx) + await g(["clone", origin, other]) + + await loops.ensureUiNotesWorktree(user) + wt = paths.uiNotesDir(user) +}) + +afterAll(async () => { + await rm(home, { recursive: true, force: true }) +}) + +async function remoteEdit(f: string, c: string, m: string) { + await g(["fetch", "origin"], other) + await g(["reset", "--hard", "origin/main"], other) + await writeFile(join(other, f), c) + await g(["add", "-A"], other) + await g(["-c", "user.email=o@o", "-c", "user.name=o", "commit", "-m", m], other) + await g(["push", "origin", "HEAD:main"], other) +} + +test("UI-loop worktree opens from origin/main", () => { + expect(existsSync(join(wt, ".git"))).toBe(true) + expect(existsSync(join(wt, "seed.md"))).toBe(true) +}) + +test("saving a note ff-pushes to origin", async () => { + await writeFile(join(wt, "note1.md"), "n1\n") + const r = await loops.syncUiNotes(user) + expect(r.ok).toBe(true) + await g(["fetch", "origin"], other) + await g(["reset", "--hard", "origin/main"], other) + expect(existsSync(join(other, "note1.md"))).toBe(true) +}) + +test("remote moved elsewhere → rebase keeps local note AND pulls remote", async () => { + await remoteEdit("remote.md", "r\n", "remote change") + await writeFile(join(wt, "note2.md"), "n2\n") + const r = await loops.syncUiNotes(user) + expect(r.ok).toBe(true) + expect(existsSync(join(wt, "note2.md"))).toBe(true) + expect(existsSync(join(wt, "remote.md"))).toBe(true) +}) + +test("notesBehind detects a remote update", async () => { + await remoteEdit("hint.md", "h\n", "remote update for behind") + expect(await loops.notesBehind(user)).toBeGreaterThan(0) +}) + +test("refresh (ffUpdateUiNotes) ff-pulls and clears behind", async () => { + const r = await loops.ffUpdateUiNotes(user) + expect(r.ok).toBe(true) + expect(existsSync(join(wt, "hint.md"))).toBe(true) + expect(await loops.notesBehind(user)).toBe(0) +}) + +test("kanban writes land in the user's worktree and push to origin", async () => { + const kanban = await import("../src/kanban.ts") + await kanban.kanbanUserCtx.run(user, async () => { + await kanban.addCard("default", "todo.md", { text: "hello-kanban" }) + }) + // written into the per-user notes worktree, under focus/boards/ + expect(existsSync(join(wt, "focus", "boards", "default", "todo.md"))).toBe(true) + // pushed by the explicit notes save, like any edit + const r = await loops.syncUiNotes(user) + expect(r.ok).toBe(true) + await g(["fetch", "origin"], other) + await g(["reset", "--hard", "origin/main"], other) + expect(existsSync(join(other, "focus", "boards", "default", "todo.md"))).toBe(true) +}) + +test("real conflict held back; the local edit is NOT lost", async () => { + await remoteEdit("seed.md", "seed-remote\n", "remote edits seed") + await writeFile(join(wt, "seed.md"), "seed-local\n") + const r = await loops.syncUiNotes(user) + expect(r.ok).toBe(false) + expect(r.conflict).toBe(true) + expect(r.files).toContain("seed.md") + expect((await readFile(join(wt, "seed.md"), "utf8")).trim()).toBe("seed-local") +}) diff --git a/server/test/vault.test.ts b/server/test/vault.test.ts new file mode 100644 index 00000000..930bd02a --- /dev/null +++ b/server/test/vault.test.ts @@ -0,0 +1,127 @@ +/** + * L1: pure-function tests for the vault refactor — loadVaultEnvs + + * listVaultHomeMounts. Heavy fixtures (LOOPAT_HOME tree, git repos) live in + * later tiers; here we just exercise the file-walking helpers against a + * disposable temp dir. + */ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { mkdir, rm, writeFile, symlink } from "node:fs/promises" +import { join } from "node:path" + +// paths.ts captures LOOPAT_HOME at module load — set it before any import. +process.env.LOOPAT_HOME ??= `/tmp/loopat-vault-l1-${process.pid}` + +const { loadVaultEnvs, listVaultHomeMounts } = await import("../src/vaults") +const { + LOOPAT_HOME, + personalVaultEnvsDir, + personalVaultMountsHomeDir, + personalVaultDir, +} = await import("../src/paths") + +const USER = "alice" +const VAULT = "default" + +async function reset() { + await rm(LOOPAT_HOME, { recursive: true, force: true }) + await mkdir(personalVaultDir(USER, VAULT), { recursive: true }) +} + +beforeAll(reset) +afterAll(async () => { await rm(LOOPAT_HOME, { recursive: true, force: true }) }) + +describe("loadVaultEnvs", () => { + test("returns empty object when vault dir missing", async () => { + await rm(LOOPAT_HOME, { recursive: true, force: true }) + expect(await loadVaultEnvs(USER, VAULT)).toEqual({}) + }) + + test("returns empty object when envs/ dir missing", async () => { + await reset() + expect(await loadVaultEnvs(USER, VAULT)).toEqual({}) + }) + + test("reads filename → trimmed content for each file", async () => { + await reset() + const envs = personalVaultEnvsDir(USER, VAULT) + await mkdir(envs, { recursive: true }) + await writeFile(join(envs, "ANTHROPIC_API_KEY"), "sk-ant-xxxxx\n") + await writeFile(join(envs, "GITHUB_TOKEN"), "ghp_yyyyy") // no newline + const result = await loadVaultEnvs(USER, VAULT) + expect(result).toEqual({ + ANTHROPIC_API_KEY: "sk-ant-xxxxx", + GITHUB_TOKEN: "ghp_yyyyy", + }) + }) + + test("strips only trailing newlines, preserves interior whitespace", async () => { + await reset() + const envs = personalVaultEnvsDir(USER, VAULT) + await mkdir(envs, { recursive: true }) + await writeFile(join(envs, "MULTILINE"), "line1\n indented \nline3\n\n") + const r = await loadVaultEnvs(USER, VAULT) + expect(r.MULTILINE).toBe("line1\n indented \nline3") + }) + + test("skips filenames that aren't valid POSIX env var names", async () => { + await reset() + const envs = personalVaultEnvsDir(USER, VAULT) + await mkdir(envs, { recursive: true }) + await writeFile(join(envs, "GOOD_NAME"), "ok") + await writeFile(join(envs, "bad-name"), "skipped") // hyphen → invalid + await writeFile(join(envs, "1starts_with_digit"), "skipped") + await writeFile(join(envs, ".dotfile"), "skipped") + await writeFile(join(envs, "with space"), "skipped") + const r = await loadVaultEnvs(USER, VAULT) + expect(Object.keys(r).sort()).toEqual(["GOOD_NAME"]) + }) + + test("skips subdirectories under envs/", async () => { + await reset() + const envs = personalVaultEnvsDir(USER, VAULT) + await mkdir(join(envs, "subdir"), { recursive: true }) + await writeFile(join(envs, "subdir", "INSIDE"), "ignored") + await writeFile(join(envs, "TOP"), "kept") + const r = await loadVaultEnvs(USER, VAULT) + expect(r).toEqual({ TOP: "kept" }) + }) + + test("isolates per-vault — dev vault doesn't see default vault envs", async () => { + await reset() + await mkdir(personalVaultEnvsDir(USER, "default"), { recursive: true }) + await mkdir(personalVaultEnvsDir(USER, "dev"), { recursive: true }) + await writeFile(join(personalVaultEnvsDir(USER, "default"), "K"), "dval") + await writeFile(join(personalVaultEnvsDir(USER, "dev"), "K"), "devval") + expect((await loadVaultEnvs(USER, "default")).K).toBe("dval") + expect((await loadVaultEnvs(USER, "dev")).K).toBe("devval") + }) +}) + +describe("listVaultHomeMounts", () => { + test("returns [] when mounts/home dir missing", () => { + expect(listVaultHomeMounts(USER, VAULT)).toEqual([]) + }) + + test("returns one entry per top-level child (file or dir)", async () => { + await reset() + const mh = personalVaultMountsHomeDir(USER, VAULT) + await mkdir(join(mh, ".ssh"), { recursive: true }) + await mkdir(join(mh, ".config", "gh"), { recursive: true }) + await writeFile(join(mh, ".gitconfig"), "[user]\n name = test\n") + const r = listVaultHomeMounts(USER, VAULT) + expect(r.map(m => m.rel).sort()).toEqual([".config", ".gitconfig", ".ssh"]) + // Each src must be an absolute path under the vault dir + for (const m of r) { + expect(m.src.startsWith(personalVaultMountsHomeDir(USER, VAULT))).toBe(true) + } + }) + + test("does NOT recurse into subdirectories — top-level only", async () => { + await reset() + const mh = personalVaultMountsHomeDir(USER, VAULT) + await mkdir(join(mh, ".config", "gh"), { recursive: true }) + const r = listVaultHomeMounts(USER, VAULT) + expect(r.map(m => m.rel)).toEqual([".config"]) + // .config is the single bind point; bwrap mounts the whole tree under it + }) +}) diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 00000000..ecc686dd --- /dev/null +++ b/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/thoughts/claude-code-as-person.md b/thoughts/claude-code-as-person.md deleted file mode 100644 index 430242d8..00000000 --- a/thoughts/claude-code-as-person.md +++ /dev/null @@ -1,75 +0,0 @@ -# 协作架构(最终):loop-tui = 富文本 IRC 客户端 - -## 决定(2026-05-04,多次迭代后收敛) - -**Claude Code 不动**。loop-tui 是一个**纯 IRC 客户端**(带富文本渲染),AI 在 channel 的方式 = bot(用 Claude API,跟 coo 一样)。 - -## 架构 - -``` -你 host A: Claude Code(不改) ← 你的私 AI(单人) - + - loop-tui ← IRC 客户端,进 channel 聊 - -阿尔萨斯 host B: loop-tui ← IRC 客户端 -泰兰德 host C: loop-tui ← IRC 客户端 - -服务器: ergo(IRC server) - coo bot ← AI 在 channel 的方式(Claude API) -``` - -## 关键定位 - -| 组件 | 角色 | 改造 | -|---|---|---| -| Claude Code | 单人 + 个人 AI 助手 | **不改** | -| **loop-tui** | IRC 客户端 + 富文本渲染 | **从 claw-code fork,剥离 Anthropic API** | -| coo bot | AI 在 channel | 已有 | -| ergo | IRC server | 已有 | - -## 为什么这样设计 - -1. **Claude Code 本来就好用** —— 不要尝试改它的角色 -2. **claw-code 的价值在 TUI**(markdown / code block / syntect 高亮)—— 普通 IRC client 不够富文本 -3. **AI 在 channel ≠ AI 嵌在客户端** —— bot 模式更解耦、更去中心化 -4. **channel 不必总有 AI** —— 纯人 IM 也是合法 loop - -## 三个设计点确认 - -1. **没人带 AI 的 channel** —— OK,就是 IM。AI 是增强不是必需 -2. **Claude 读全不一定回** —— 标准 bot 行为(coo 现状) -3. **Claude 自己管 context** —— channel 只 feed message stream,Claude 自己决定 summarize / 保留 / 取舍 - -## loop-tui 实施路线(基于 claw-code) - -| 步骤 | 改动 | 估算 | -|---|---|---| -| 1 | Fork claw → `~/workspace/loop-tui`,build 通过 | 半天 | -| 2 | 删除 Anthropic API 集成(剥离 api crate)| 半天 | -| 3 | 加 `irc` crate(连 ergo + 收发)| 1 天 | -| 4 | REPL loop 改:从"和 Claude 聊"→"和 channel 聊" | 1 天 | -| 5 | 多 speaker 渲染(保留 markdown / code 高亮) | 半天 | -| 6 | Slash commands:`/join` `/leave` `/nick` `/list` | 半天 | -| 7 | 联调:你 + 我 + coo 在同 channel | 半天 | - -**~4 天到 demo**。比之前估算简单(不用维护多 Claude 协调逻辑)。 - -## 副产品 - -loop-tui 解耦后是个干净 chat client: - -- 可以集成公司内 LLM -- 可以接 OpenAI / Gemini / 本地模型作为 channel bot -- 跨端(手机 web 用 thelounge 桥接) -- 跟 Claude Code 完全解耦 - -## 思想演化(journey) - -之前的几次架构迭代(仅作历史): - -1. **chat.log + git**:异步协作 → 不够,多人实时不行 -2. **改 Claude Code 加 IRC**:技术不可行(闭源) -3. **改 claw-code 让 Claude Code 多人化**:过度设计,每人多 Claude 复杂 -4. **现在**:claw-code → loop-tui(纯 IRC 客户端),Claude Code 不动,AI 走 bot - -每次迭代都让设计更解耦、更简单。 diff --git a/thoughts/entity-log-view.md b/thoughts/entity-log-view.md deleted file mode 100644 index 5c531a1b..00000000 --- a/thoughts/entity-log-view.md +++ /dev/null @@ -1,64 +0,0 @@ -# Thread 数据模型:Entity / Log / View - -## 三层 - -| 层 | 是什么 | 当前承载 | SoT? | -|---|---|---|---| -| **Entity(实体)** | 一件事的本体 —— 代码、文件、context | `~/workspace/<name>/` | ✅ | -| **Log(事件)** | raw 上下文、发生了什么 | `<dir>/log.md`(暂定) | ✅ | -| **View(视图)** | 上面两层的投影 | todo doc / IM / tracker / 汇报 / ... | ❌(消耗品) | - -## 关键洞察 1:View 不应该是 SoT - -之前 `ccx/notes/todo.md` 失败的原因正是把 log 写进了 INDEX —— 让视图扛了 SoT 的责任。一旦如此: - -- 没法多视图(个人 ≠ 团队 ≠ 汇报) -- 没法多人协作(IM doc 没人同步回 todo.md) -- 同一件事在多处重写 → desync - -View 是**消耗品**:可抛弃、可重建、可定制。 - -## 关键洞察 2:汇报也是 View,不是 Log - -更进一步:**有的"log"其实是"汇报"**,也属于 View 范畴。 - -| | 真 Log | 汇报 | -|---|---|---| -| 受众 | 自己 / 接力的 AI | 老板 / 团队 / 客户 | -| 形式 | 原始、带上下文、append-only | 精炼、结构化、阶段性 | -| 性质 | SoT | View | -| 例子 | "11:33 跑 v6d connector 失败,trace 见 X" | "本周修 bug N,进度 60%" | - -tracker 工单更新、IM 进度、周报、todo doc —— 全是 View。 - -## Log 的 SoT:workspace dir 内(暂定) - -决定:log 跟实体走,存 `<dir>/log.md`。 - -理由: -- 跟实体一起 handoff —— 把目录交出去,log 自带 -- AI cd 进 dir 立即看到完整上下文 -- filesystem-native,无外部依赖 - -代价:跨人协作不天然,需要靠 View 投影出去。 - -## "all-in-one" 的真实形态:SoT 一份,View 任意多个 - -``` - ┌─→ todo doc (个人扫一眼) -~/workspace/<name>/ ├─→ IM 文档 (团队同步) -├── log.md ← Log SoT ────┼─→ tracker 工单更新 (formal record) -└── (代码/文件) ├─→ 周报 (老板汇报) - └─→ AI handoff context -``` - -**不再"同步",只"投影"**。 - -投影的执行者:人工 / AI 自动 / CLI 工具 —— 都可以,是 runtime 层的问题。 - -## 待讨论 - -1. **log.md 的格式**:完全自由?还是有最小约定(时间戳前缀?分段?) -2. **投影的执行者**:AI 自动从 log 生成汇报?还是人工,AI 辅助? -3. **跨 dir 的 INDEX**:50 个 workspace dir,怎么"扫一眼所有 thread"?这是原"扩展"问题的回归 —— 但 log 不在 INDEX 里之后,INDEX 该长什么样? -4. **团队 thread**:实体不在我本地的情况怎么处理?(比如别人在主导一件事) diff --git a/thoughts/loop-as-cloud-dir.md b/thoughts/loop-as-cloud-dir.md deleted file mode 100644 index 63d74675..00000000 --- a/thoughts/loop-as-cloud-dir.md +++ /dev/null @@ -1,54 +0,0 @@ -# Loop = 云端共享目录 - -## 决定(2026-05-04,待最终确认) - -**Loop 的物理形式 = 一个云端目录。** - -协作 = 给目录加 ACL 让别人能读 / 写。其他都是这个的派生。 - -## 这个简化解决了什么 - -| 之前的复杂问题 | "云端 dir"下的解 | -|---|---| -| 协作的物理形式(git remote / fs / SSH …)| **就是个云端 dir** | -| Chat 共享 | chat history 是 dir 里的文件 | -| AI 进共享 loop | AI 本地 mount 跑,看到同一份 dir | -| Read vs write | dir 的 ACL | -| 跟 tracker 衔接 | tracker 是这个 dir 的一种外部 view | - -## 现成模式映射 - -| 模式 | 借鉴 | -|---|---| -| **GitHub repo** | 共享代码 + issues + actions 都在 repo 内 | -| **Replit / Codespaces** | 云端 dir + AI + terminal 一体 | -| **Google Drive 共享文件夹** | dir + 权限 | -| **Slack channel** | 共享 chat 但缺 workspace | - -**1001 要的 = GitHub repo + Replit + Slack channel 三者合一**:dir + chat + AI + 权限,全在一个 dir 内。市面没有现成的。 - -## 心理模型 - -``` -~/workspace/<loop>/ ← 本地 mount / clone - ↕ (sync) -cloud://loops/<loop>/ ← 云端权威副本,带 ACL -``` - -每个 loop 在云端有权威副本,本地是 working copy。授权 = 给 cloud 那份加 ACL。 - -## 工程问题(待选) - -| 问题 | 选项 | -|---|---| -| 怎么实现"云端 dir" | git repo / live sync (NFS / S3FS / Syncthing) / 集中 SSH host / 自写 sync | -| 哪个云 | 内网(tracker / S3 / 公司 NAS)/ 公网(GitHub)/ 自建 server | -| Chat 怎么落地 | 单文件 append `chat.log` / 每消息一文件 / SQLite / IRC bridge | -| Discovery | 50 个 loop 怎么 list 出来 | - -## 推论 - -- **tracker 不是协作系统**,是外部任务跟踪 + view 渲染面 -- 真正的协作系统 = **可分享的云端 loop dir** -- IRC 是 chat 那一面的现成方案;剩下的是 dir + sync + ACL -- 这个方向有 ready-made 的 building blocks(git、NFS、IRC),关键是组合方式 diff --git a/thoughts/loop-physical-form.md b/thoughts/loop-physical-form.md deleted file mode 100644 index cb0ab25b..00000000 --- a/thoughts/loop-physical-form.md +++ /dev/null @@ -1,162 +0,0 @@ -# Loop 的物理形式 - -## 决定(2026-05-04) - -**Loop 的核心三件 = dir + IM + AI bot(universal)。** -**直接 access dir 的方式 = 因角色而异(role-specific)。** - -- **dir**:所有人都需要 —— 文件、artifact、workspace -- **IM**:所有人都需要 —— chat / 协调 / 社交层 -- **AI bot**:所有人都需要 —— 听 chat + 操作 dir,是非 power user 的主要"代理" -- **直接 access dir**:因角色而异(见下表) - -**dir 是统一的 SoT。** 不论用什么入口操作,最后都落到 dir 上。 - -## 不同角色的 access 层 - -| 角色 | 直接 access dir | -|---|---| -| 工程师 | **SSH**(vim / shell / git)| -| 设计师 | 文件浏览器 + Figma / Sketch / Photoshop 集成 | -| PM | Web view + 富文本 / 表单 / 表格 | -| 数据分析 | Jupyter / SQL 控制台 | -| 通用 fallback | Web 文件管理器(任意角色都能用)| - -**Power user 直接操作;非 power user 通过 AI bot 间接操作(chat)**。 - -工具只按需启用 —— 一个 loop 可以混合多角色(同 channel 不同人用不同入口)。 - -## 工程师变体(当前主要 focus) - -``` - ┌──────────────┐ -IRC ──→│ │ - │ workspace │ -SSH ──→│ dir │ - │ (SoT) │ -bot ──→│ │ - └──────────────┘ -``` - -| 身份 | 入口 | 能做什么 | -|---|---|---| -| 普通参与者 | IRC client(thelounge / weechat / 手机)| chat、@bot 让 AI 干活、看 bot 报告 | -| Power user | SSH | vim、shell、git、直接跑命令 | -| AI bot | host 上的进程 | 听 IRC + 操作 dir | - -**SoT 是 dir。** IRC 和 SSH 都只是工程师场景的入口。 - -## 最小实现拓扑 - -``` -loop-host: -├── ircd (ergo) -├── sshd -├── /var/loops/<channel>/ ← workspace dirs -└── coo bot ← 听 IRC + 操作 /var/loops -``` - -每个 loop = -- 一个 IRC channel -- 同 host 上一个 dir -- 该 channel 的 IRC ACL -- 该 dir 的 Unix ACL - -## 已有 vs 待补(基于 ~/workspace/im) - -| | 已有 | 待加 | -|---|---|---| -| IRC server | ✓ ergo | — | -| Web IRC | ✓ thelounge | — | -| AI bot | ✓ coo | 操作公共 dir,而不是 bot 私有 dir | -| Per-channel dir | ✓ bot/conversations/<channel>/workspace/ | 移到 `/var/loops/` + 加 SSH 入口 | -| SSH | sshd 通用 | 每个 channel 一个 unix group + 同步成员 | -| 身份映射 | ❓ | NickServ nick → unix user 映射(手动 / 脚本)| - -**差的就是几条胶水脚本。** - -## 这个方向解决你关心的所有点 - -| 之前关心 | 解 | -|---|---| -| 共享 loop(非进展)| 共享 IRC channel + 共享 dir | -| 痛点 b(两人协作)| 私 channel + 私 dir,两人都进 | -| 痛点 a(个人投团队)| 给团队 IRC voice + dir read | -| AI 一阶 | bot 是 channel 成员且能操作 dir | -| 直接操作 dir | SSH | -| 终端友好 | IRC + SSH 都终端原生 | -| filesystem-native | dir 是 SoT | -| 零 lock-in | dir + 开放协议 | -| 跨设备 | thelounge / 手机 IRC / SSH | -| 通知 | IRC mention 自带 | - -## MVP 路径:async 优先,IRC 是 phase 2 - -IRC 太重(实时、server、多客户端)。但**真正承载 chat 的是 `chat.log` 文件**,IRC 只是实时推送层。如果接受 async,整个 IRC 不需要。 - -**Phase 0(现在能做,50 行级别)**: - -``` -Loop dir: -├── chat.log ← append-only 文件 + 约定格式 -├── workspace/ ← 工作文件 -└── .loop/meta.md ← 元数据 -``` - -``` -[2026-05-04T14:32:15] <simpx> 看下 RDMA trace -[2026-05-04T14:32:50] <claude> 已分析,问题在... -[2026-05-04T15:10:03] <阿尔萨斯> 我也看到了,但是 ... -``` - -工具: -```bash -loop new <name> # mkdir; git init; touch chat.log -loop chat # 启动 claude,hook 把 turn append 到 chat.log -loop sync # git add/commit/push -loop pull # git pull -``` - -**阶段升级**: - -| Phase | 形态 | 复杂度 | -|---|---|---| -| 0 | 文件 + git + 本地 Claude Code | 50 行 | -| 1 | + chat.log watcher → mac/手机通知 | 半天 | -| 2 | + IRC 做实时通道(SoT 还是 chat.log)| 复用 ~/workspace/im | -| 3 | 改 Claude Code 原生支持 multi-participant | 大工程 | - -**关键**:SoT 永远是 chat.log,传输层(git / IRC / etc)可以替换。 - -## 关于"Claude Code + IRC"的两件事 - -a) **让 Claude Code session 同步到 chat.log**:Claude Code 的 jsonl session 是私有的;加 hook 把每 turn export 成 chat.log 行 → 多人能共享。简单。 - -b) **让 Claude Code TUI 显示别人的输入**:需要 Claude Code 原生支持 multi-participant。大改造,先放一边。 - -a 是 phase 0 的核心;b 是 phase 3。 - -## 还要回答的小事 - -1. **本地编辑**: - - sshfs mount 远端 dir 到本地 ~/workspace/<loop>/ → 本地 vim 远程文件 - - 离线场景:git clone 本地副本(loop dir 也是 git repo) -2. **多 AI 并存**: - - 现在一 channel 一 bot(coo) - - 是否支持每人带自己 AI bot 进 channel? -3. **Discovery**: - - IRC `/list` 列所有 channel = 所有 loop - - 可以加 metadata(topic / status / owner)增强 - -## 替代了哪些之前讨论 - -- **"云端共享 dir + ACL"**(loop-as-cloud-dir.md):现在具体化为 SSH 同 host 模式,不需要单独的 cloud 协议 -- **"chat 协议待选"**:定为 IRC -- **"AI 进 loop 形式"**:bot 进 channel 已成型 -- **"读写权限"**:IRC ACL(chat)+ Unix ACL(dir),双层 - -## 推论 - -- 1001 的核心实现 = 把 ~/workspace/im 升级为多人 host -- 不需要造大轮子;都是组合现成 building blocks(ergo、sshd、coo bot、git) -- 每加一个 loop = `/join` channel + 创建 dir + 加 group + 拉 bot 进 channel —— 一行脚本可完成 diff --git a/thoughts/loop.md b/thoughts/loop.md deleted file mode 100644 index ab4ef0c3..00000000 --- a/thoughts/loop.md +++ /dev/null @@ -1,115 +0,0 @@ -# Loop —— 1001 的基本单元 - -## 命名(2026-05-04) - -之前讨论里的"channel"是借 Slack 的占位词,正式命名为 **Loop**。 - -理由: -- 短(4 字母) -- 动词形式天然:"loop someone in" = 把人拉进来 -- 捕捉 AI 时代的本质 —— 跟 AI 协作就是 feedback loop -- 与 Bret Victor 的 "feedback loop tightness" 暗合 -- **杀手点**:"close the loop" 直接对应 runtime 闭环原则 - -## Loop 中心论(2026-05-04 进一步收敛) - -模型不是"Loop + Runtime 两支柱",而是 **Loop 是中心,其他都为它服务**: - -``` - ┌─────────────────────┐ - │ │ -context ──→ LOOP ──→ context (new) + artifacts - │ (人 ↔ AI) │ - │ │ - └─────────────────────┘ - ↑ - Runtime - (让"现实"能进入 loop) -``` - -- **Loop** = 唯一的"目的"。一切存在为了让 loop 跑起来 -- **Knowledge** = 流经 loop 的"流"。上一个 loop 的输出 → 下一个 loop 的输入。同一物,相对时间不同 -- **Runtime** = loop 与现实之间的**薄膜**。没 runtime → 很多东西活在 loop 之外(deploy 只在脑子里、logs 只在某终端、状态只在生产环境)。有 runtime → 这些能进入 loop。**Runtime 决定什么能进入 loop** - -## 一句话 - -**Loop is everything. Runtime is the membrane. Knowledge is the flow.** - -中文:**Loop 是一切。Runtime 是膜。Knowledge 是流。** - -## Loop 是什么 - -``` -Loop = Workspace dir + Chat history + Participants + Artifacts - -- workspace dir: 代码、文件、md、ppt(filesystem-native) -- chat history: timeline,自带时间序 -- participants: 人 + AI bot(单人或多人) -- artifacts: loop 产出的可验证产物 -``` - -## 生命周期 - -| 状态 | 含义 | -|---|---| -| **Open** | 新建 dir,开始 chat | -| **Active** | 正在产生 / 推进 | -| **Forked** | 分叉成新 loop | -| **Closed** | runtime 验证 artifact 完成 | -| **Archived** | 归档;artifacts 沉淀为 knowledge | - -## 折叠的旧概念 - -之前讨论里区分的几个东西,在 Loop 模型里都是同一物的不同面: - -| 旧概念 | 在 Loop 模型里 | -|---|---| -| Entity(workspace dir) | Loop 的物理面 | -| Log(log.md) | Loop 的 chat history | -| Issue(公开 timeline) | 多人 loop | -| log vs issue | 同一东西,参与者多寡之别 | -| Knowledge | 已归档 loop 的 artifacts 沉淀 | - -## 框架演化 - -| | v1 | v2 | v3(现)| -|---|---|---|---| -| 一阶概念 | Context(Knowledge + Thread)+ Runtime | Loop + Runtime | **Loop**(中心)| -| Knowledge 地位 | 一阶支柱 | Loop 归档析出 | **流经 loop 的流** | -| Runtime 地位 | 与 Context 平级 | 与 Loop 平级 | **服务于 loop 的薄膜** | -| Thread / Issue / Log / Chat | 各自分立 | 折叠进 Loop | 同 v2 | - -## 用法示例 - -``` -"我开一个 loop 来推 loopctl deploy" -"把无厚也 loop 进来" -"gateway 那个 loop 关掉了" -"fork 这个 loop 试另一条路" -"今天有 6 个 active loop" -"close the loop" → runtime 验证 + artifact 落地 -``` - -## 已确认细则(2026-05-04) - -- **物理位置不强求**:chat history 跟 workspace dir 逻辑同源即可,物理可分(claude-code session 在 `~/.claude/` 里 OK) -- **思考型 loop 的 closure**:artifact + mtime 衰减,不需要 runtime 验证 -- **Loop 不嵌套**:保持扁平,没有 sub-loop -- **Fork 不需要显式机制**:隐式(对话里 fork)够用,至少现在不需要 - -## 待解 - -1. **Chat 协议**:IRC?claude-code log?markdown? -2. **持久化**:append-only file?SQLite? -3. **INDEX**:50 个 loop 怎么扫一眼? -4. **协作怎么办**(当前焦点):本地单人 + AI 已经 work,协作场景未解 - -## 当前焦点:协作 - -本地 loop 已经 work(dir + chat + artifact + AI 自然成立)。但协作场景未解: - -- 多 workspace 怎么共享? -- chat 在私 chat / 公 channel 之间怎么分? -- AI 是每人自带还是 channel 共享? -- 投影到外部系统(tracker / IM / GitHub)? -- handoff / 邀请的形式? diff --git a/thoughts/multi-person-ui.md b/thoughts/multi-person-ui.md deleted file mode 100644 index a8d237b5..00000000 --- a/thoughts/multi-person-ui.md +++ /dev/null @@ -1,55 +0,0 @@ -# 多人 Loop 的 UI 选项 - -## 已确认(2026-05-04) - -多人 Loop 的最佳形态 = **共享 channel + 一个 Claude bot**(用户 `~/workspace/im` 已是雏形)。 - -模型对了,UI 是另一个独立的轴。 - -## UI 选项空间 - -| | 方案 | 工作量 | 体验 | -|---|---|---|---| -| A | 换现代 IRC client(senpai / halloy / catgirl)| 几小时 | 好于 weechat,差于 Claude Code | -| B | 换协议:IRC → Matrix(Element 客户端,桥接现有 IRC)| 几天 | 接近 Slack/Discord,开源 | -| C | **自建 "Loop TUI" 对标 Claude Code** | ~一周(500-1500 行)| 完全可控,最贴 | -| D | 等 Anthropic 给 Claude Code 加 multi-participant | 不可控 | 理想 | - -## C 的形态草稿 - -``` -┌──────────────────────────────────────┐ -│ #gateway-launch active loop │ -├──────────────────────────────────────┤ -│ <simpx> 看下 RDMA trace │ -│ <coo> ✓ 已分析,问题在 mr_register │ -│ <阿尔萨斯> 是 cuda alignment 问题 │ -├──────────────────────────────────────┤ -│ > _ │ -└──────────────────────────────────────┘ -``` - -特征: -- 侧栏 channel 列表(active loop 列表) -- Markdown / code block 渲染(像 Claude Code) -- @mention highlight -- bot 消息特殊样式 -- artifact 引用展开 -- terminal 体验对标 Claude Code - -技术栈候选:Textual(Python)/ Bubble Tea(Go)/ Ratatui(Rust) - -## 推荐路径 - -| 时间 | 做法 | -|---|---| -| 今天 | 打开 thelounge / weechat,跟 coo 在 `#general` 聊半小时,感受"我 + AI 在 channel"的体感 | -| 明天 | 拉同事进 channel,三人协作干小事,验证多人 + 一 AI 体感 | -| 体验对 → | 投入做 C(Loop TUI)| -| 体验不对 → | 回头改模型 | - -## 关键原则 - -**UI 不应决定模型选型**。先用 ugly UI 跑通模型,验证形态对 → 再投入漂亮 UI。 - -模型对 + UI 丑 ≠ 模型错。 diff --git a/thoughts/obsidian-deep-dive.md b/thoughts/obsidian-deep-dive.md deleted file mode 100644 index b0879f7c..00000000 --- a/thoughts/obsidian-deep-dive.md +++ /dev/null @@ -1,85 +0,0 @@ -# Obsidian 深度解读 —— 与我们 Thread 模型的对应 - -## 一句话 - -Obsidian 是 local-first 的 markdown 笔记工具,**Thread + Knowledge 那一面几乎是我们模型的现成实现**,特别是 SoT/View 分离。 - -## 核心模型 - -- **Vault** = 磁盘上一个文件夹 -- **Note** = 一个 `.md` 文件 -- 内置:wiki link `[[]]` / 双向链接 / backlinks / graph / tags / daily notes -- 力量在**插件生态** - -## Folder vs Note:原子是 note - -虽然 vault = folder,但 Obsidian 的一阶对象是 **note(文件)**: -- 文件夹没 metadata、没"内容"、不能写描述 -- 你打开的总是一个 note - -**对应到我们**:thread = dir 这个想法,要靠在每个 thread 文件夹里放 `THREAD.md` 当代表来 workaround。 - -## Log 模式:两种 + 一个杀手锏 - -### 模式 A:Daily Notes(时间索引) - -每天一个 `2026-05-03.md`,所有事往里追加。低成本。 - -### 模式 B:Per-thread Log(thread 索引) - -每个 thread 的 `THREAD.md` 留 `## Log` 段,事件按 thread 归档。高价值。 - -### 杀手锏:Backlinks 自动连接 - -`[[Thread Foo]]` 写在 daily note 里 → Obsidian 自动建反向索引 → 打开 `Thread Foo.md` 看到"哪些 daily note 提到我"。 - -**结果**:即使忘了整理 thread log,daily note 写了就自动反映。两个 SoT 通过引用结构自动 cross-reference。 - -## View 实现:五层 - -| 层 | 形式 | 复杂度 | -|---|---|---| -| 1 | 文件树 + 全文搜索 | built-in,零配置 | -| 2 | Tags(`#active` `#blocked`) | built-in | -| 3 | Backlinks + Graph view | built-in | -| 4 | **Tasks 插件**:`- [ ]` 跨文件查询 | 关键插件 | -| 5 | **Dataview**:SQL on YAML frontmatter + DataviewJS | 关键插件 | - -Tasks 和 Dataview 是把"SoT/View 分离"工程化的现成产品。 - -## 完整对应 - -| 我们模型 | Obsidian 实现 | -|---|---| -| Entity(workspace dir) | folder + `THREAD.md` | -| Log(thread 内 raw) | `THREAD.md` 里 `## Log` | -| Log(每日流) | Daily Note + backlinks | -| View(todo / 汇报) | Tasks query + Dataview table | -| 状态 / 元数据 | YAML frontmatter | -| 引用 | `[[wiki link]]` 双向 | - -## 不解的四点 - -1. **代码/产物**:`.py` `.go` 等非 markdown 文件在 vault 里只是 attachment,二等公民 -2. **Runtime 闭环**:Obsidian 不会响应 `loopctl deploy`,需要外部脚本写文件触发 -3. **Folder 非一阶**:要 `THREAD.md` workaround -4. **Mobile 同步**:付费或自建(git / iCloud / Syncthing) - -## 借鉴 vs 直接采用 - -**借鉴的**: -- ✅ 把 daily note + per-thread log 通过 backlinks 自动连接 -- ✅ 用 frontmatter YAML 存状态/元数据 -- ✅ Tasks 风格的 query 语言 -- ✅ Dataview 风格的多视图渲染 - -**不一定要绑定 Obsidian**: -- 整套机制都是 markdown + 文件系统,理论上可以脱离 Obsidian 独立工程化 -- 但 Obsidian 已经把这些做成开箱即用,是最快验证模型的路径 - -## 实操建议 - -1. 把 `~/workspace/` 当 vault 试一下(即使有非 markdown 文件,Obsidian 也忍受) -2. 给两三个活跃 thread 写 `THREAD.md`,带 frontmatter -3. 写一个 Dataview 查询,看实时聚合视图 -4. 评估"现成 vs 自研" —— 决定要不要绑定 Obsidian、还是只借鉴模式 diff --git a/thoughts/runtime-as-context-shrinker.md b/thoughts/runtime-as-context-shrinker.md deleted file mode 100644 index e11e9c42..00000000 --- a/thoughts/runtime-as-context-shrinker.md +++ /dev/null @@ -1,31 +0,0 @@ -# Runtime as Context Shrinker - -## 心路历程 - -- 以前觉得 knowledge 是一切 —— 所有东西塞进 docs 就好 -- 最近觉得 runtime 也很重要,甚至在某些场景比 knowledge 更关键 - -## 触发:loopctl - -以前 `ccx/docs/` 里放了很多 runtime、loopey 的知识。 - -最近实现的 `~/workspace/loopctl` —— 一个完整 kubectl 风格的 CLI —— 把这堆复杂知识**收敛**到了一起。 - -相当于做了一次 **shrink context**: - -- **以前**:AI 要理解很多东西才能干活 -- **现在**:`loopctl -h` 就可以自行探索 - -## 为什么有效 - -loopctl 刻意采用 kubectl 的概念体系和命名约定: - -- 复用通用心智(resource / verb / scope / namespace…),几乎不引入新概念 -- 极小的 context footprint 就能完成工作 -- 不再需要 AI 自由阅读 doc、每次临时组装一堆功能 - -## 提炼 - -**好的 runtime 不只是"让事情发生",还是 knowledge 的压缩**:把分散在 docs 里的概念、最佳实践、操作流程,编码进 CLI 的命令结构、子命令、flag 和 help 文本里。AI 按需取用,不必预加载全部。 - -约定 + 自描述(-h) = 极低 context footprint。 diff --git a/thoughts/runtime-closes-the-loop.md b/thoughts/runtime-closes-the-loop.md deleted file mode 100644 index 11720bfb..00000000 --- a/thoughts/runtime-closes-the-loop.md +++ /dev/null @@ -1,52 +0,0 @@ -# Runtime 闭环:让 closure 成为做事的副产品 - -## 核心洞察 - -> **工具能不能存活,看它是不是在工作的"必经之路"上。** - -GitHub issue 不死的根本原因: -- 代码的必经之路 = PR 合入 -- 合入 = 天然的关单事件 -- **绕不开** - -todo.md / 普通 issue tracker 死的原因: -- 必经之路上没它 -- 关单是**额外动作** -- 能省就省 → 信任崩塌 → 没人用 - -todo.md 当年弃用的真正原因,到这里才完整:不是因为不能扩展,而是因为没有闭环。 - -## 设计原则 - -> **Thread closure 必须是"做事的副产品",不是单独的动作。** - -决定一个 thread 该不该用 issue/timeline 跟踪,看的是 **runtime 能不能给它 close 信号**。 - -## GitHub 的好处是闭环 —— 我们需要用 runtime 做闭环 - -| 类型 | 必经之路 | closure 信号 | -|---|---|---| -| 代码改动 | PR 合入 | merge event | -| 上线/部署 | `loopctl deploy` 成功 | `loopctl status xxx` 上看到 → 触发 close | -| 文档/调研 | ?(开放) | mtime 衰减 + AI 巡检? | -| 纯探索 | 没必经之路 | 不开 issue,靠 mtime | - -具体例子: - -> **上线一个 xxx,如果有 issue,那么 auto-close 的标准就是 `loopctl` 上看到 xxx。** - -也就是说,issue 不是手动关,而是 runtime 验证后自动关。 - -## 推论 - -1. **不是所有 thread 都该用 issue/timeline 跟踪**。runtime 上没钩子的,issue 必腐烂。让它在 workspace dir 里靠 mtime 自然衰减就好。强求开 issue = 制造 dead issue = 系统污染源。 - -2. **loopctl 不只是 deploy/operate**,更是 **closure signal 的发射源**。runtime 的设计要把 "acceptance" 作为 first-class 行为:每个动作完成后都能 emit "什么被验证了"的事件。 - -3. **这强化了"runtime 同等重要"的判断** —— 没有 runtime 闭环,整个 thread 系统会自我崩塌。 - -## 待挖 - -- runtime → issue 的 closure 事件协议长什么样?需要标准化吗? -- 没必经之路的 thread(探索、调研)怎么处理?接受不关单?还是搞个 AI 巡检员定期 review? -- 如何避免"为了 close 而 close"的伪闭环(比如随便 deploy 一下凑数)? diff --git a/thoughts/scenarios.md b/thoughts/scenarios.md deleted file mode 100644 index 0ccc59cd..00000000 --- a/thoughts/scenarios.md +++ /dev/null @@ -1,55 +0,0 @@ -# 典型 Loop 场景 - -整理用户场景,用来检验 Loop 模型对真实工作的覆盖度。 - -## 通用场景(按生命周期/驱动方式) - -| # | 场景 | 描述 | -|---|---|---| -| 1 | 想到一件事要做 | 自己冒出 idea 立刻开干 | -| 2 | 管理多 todo + 年度目标 | 个人长期目标 + 短期事项 | -| 3 | 跨团队长项目推进 | 多团队、多季度、多状态 | -| 4 | 做到一半交接 | handoff | -| 5 | 被中断 / 切换 | 当前事 → 临时事 → 回当前 | -| 6 | 复盘 / postmortem | 事后聚合、提炼 knowledge | -| 7 | 找回旧事 | 半年前那个结论是啥 | -| 8 | 周会汇报 | "本周已做 / 下周要做" 视图 | -| 9 | AI 后台自跑 | 长任务 AI 推进 | -| 10 | 分叉探索 | fork 试另一条路 | -| 11 | 零散琐事日 | 30 个小事,不污染 loop 系统 | -| 12 | 学习 / 读论文 | 输入型,无明确产物 | - -## 个人场景(基于 ~/workspace 现状) - -| # | 场景 | 具体抓手 | -|---|---|---| -| A | 推一个具体上线 | gateway + 模型 + 机型 + 特性(如 llama-3 a100 gateway + pd 适配)| -| B | 跑对比实验 | mirror-/shadow-llama-3-70b 这种并行;AI 后台跑 benchmark 矩阵 | -| C | fork 跟社区同步 | github_vllm@kvcache_trace —— 上游持续变,自己分支要 rebase | -| D | 跨季度战役 | 推理优化战役;多团队、多子战役、协调者非执行者 | -| E | 招聘 funnel | 7-8 个候选人各自不同状态,长期推进 | -| F | 老板临时插一件事 | "今晚要 Y 结论";切 Y → 次日回原 X | -| G | 客户报线上 bug | reasoning_content 截断;临时插队、跨 customer/SRE/模型团队 | -| H | 自驱内部基建 | loopctl / ccx / chimp_cli / 1001;可放下可回来、用着改改着用 | -| I | 写 blog 系列 | 1001 / blog / how-I-use-AI;中途可能 spawn 别的 loop | -| J | 跟兄弟团队对齐一点 | 和泰兰德对 turbo quant;IM + 1:1 推,不开 issue | - -## 优先讨论 - -| | 为什么先讨论 | -|---|---| -| **H 自驱内部基建** | 最近、最具体、meta —— 1001 自己就是这种 loop | -| **A gateway 上线** | 嵌套了 #3 + #5 + #6 多轴,覆盖广 | -| **D 跨季度战役** | 检验"协调者 loop"这种特殊形态 | -| **E 招聘 funnel** | 检验"状态机长期推进 + 流程一环"形态 | -| **B 对比实验** | 检验 #9 AI 自跑 + #10 分叉 | - -## 检验维度(每个场景都从这几个角度拷问 Loop 模型) - -- workspace dir 自然吗? -- chat history 在哪?谁产生? -- 参与者怎么进出? -- artifact 是什么?怎么验证 closure? -- 与外部 issue 的关系? -- 多 loop 怎么并行 / 切换 / 引用? -- 这个 loop 何时结束?怎么 archive? diff --git a/thoughts/share-loop-not-progress.md b/thoughts/share-loop-not-progress.md deleted file mode 100644 index 169f2e75..00000000 --- a/thoughts/share-loop-not-progress.md +++ /dev/null @@ -1,55 +0,0 @@ -# 协作的本质:共享 loop,不共享进展 - -## 核心原则(2026-05-04) - -> **协作的目的是协作 loop,不是协作进展。** -> -> **进展是给人看的,context 才是给 AI 看的。** - -## AI 时代的协作转变 - -| | Pre-AI | Post-AI | -|---|---|---| -| 协作单位 | 进展 / 状态 / 报告 | **完整的 loop(context)** | -| 为什么 | 人受不了原始 context 的量 | AI 能吃完整 context | -| 后果 | 大量人力做摘要、汇报、同步 | 直接共享 SoT,view 由 AI 按需生成 | - -tracker / IM doc / 周报 都是协作 **view**,不是协作 **loop**。它们衔接的是给人看的进展,**丢了 AI 能用的 context**。 - -## 这把"view 不是 SoT"原则推到协作维度 - -之前在 thread 模型里说 view 不是 SoT。今天这条把它推到协作: - -- **协作的 SoT** = loop(dir + chat + artifacts) -- **协作的 view** = tracker / IM / 周报(给特定受众的进展投影) -- **AI 让 view 可以按需生成**,不必预先做 - -人 + tracker 的协作,等于在做"AI 不需要、人需要"的工作。低杠杆。 - -## 对两个痛点的重新设计 - -### 痛点 a:个人 loop 投到团队可见 - -- ❌ 老:把 loop 状态投影成 tracker / IM doc 给团队看(投进展,丢 context) -- ✅ 新:让 team 直接 read 我的 loop(dir + chat),需要时 AI 给 team 按需生成进展 view - -### 痛点 b:两人协作一件事 - -- ❌ 老:IM 太轻 / tracker 太重,找一个"中间形态" -- ✅ 新:开一个**两人共享的 loop** —— 共享 dir + 共享 chat channel + 各自 AI - -形态上就是把 IRC bot 那套(per-channel workspace + channel log + bot 进 channel)**普遍化到任何两人 / 多人协作**。 - -## 待解的具体形态问题 - -1. **Loop 共享的物理形式**:git remote?共享 fs?同机 share dir? -2. **Chat 共享**:全部走 IRC?还是有的 loop 走 git commit log?混合? -3. **AI 进共享 loop**:每人自带 AI(cd 共享 dir)/ channel 共享 AI / 混合? -4. **Read-only vs writable** 边界:团队 read = git fetch 我的 dir? -5. **跟 tracker 的衔接**:tracker 作 closure 信号 + view,**不再复制 context** - -## 推论 - -- tracker 不是协作工具,是**外部任务跟踪工具 + 团队 view 渲染面** -- 真正的协作工具应该是**让 loop 可分享 / 可加入 / 可观察**的 -- 这种工具几乎不存在 —— 是 1001 要建的 diff --git a/thoughts/superseded/framework.md b/thoughts/superseded/framework.md deleted file mode 100644 index 30498262..00000000 --- a/thoughts/superseded/framework.md +++ /dev/null @@ -1,128 +0,0 @@ -# 1001 框架(v3,2026-05-04) - -## 一句话 - -**Loop is everything. Runtime is the membrane. Knowledge is the flow.** - -## 核心模型:Loop 中心论 - -``` - ┌─────────────────────┐ - │ │ -context ──→ LOOP ──→ context (new) + artifacts - │ (人 ↔ AI) │ - │ │ - └─────────────────────┘ - ↑ - Runtime - (让"现实"能进入 loop: - deploy / terminal / logs / 数据 / ...) -``` - -**Loop 是唯一的目的,Runtime 是手段,Knowledge 是流。** - -## 三个概念 - -### Loop = 工作的基本单元 - -``` -Loop = Workspace dir + Chat history + Participants + Artifacts -``` - -- **Workspace dir**:filesystem-native,代码 / md / ppt / 数据都在这 -- **Chat history**:人 ↔ AI 的对话流,自带 timeline -- **Participants**:单人或多人,AI 是一阶身份 -- **Artifacts**:loop 产出的可验证产物 - -生命周期:Open → Active → Closed / Forked → Archived - -### Runtime = loop 与现实之间的薄膜 - -- 没 runtime → deploy 只在脑子里、logs 只在某终端、状态只在生产环境 -- 有 runtime(loopctl)→ 这些都能进入 loop -- **Runtime 决定什么能进入 loop** - -### Knowledge = 流经 loop 的流体 - -- 上一个 loop 的输出 = 下一个 loop 的输入 -- 同一物在不同 loop 间换位置 -- 不再是支柱,是流 - -## 关键洞察(按提出顺序) - -1. **好的 runtime 压缩 knowledge** —— loopctl 把分散文档收敛进 CLI 自描述接口(`-h` 即学) -2. **工具能否存活看是否在"必经之路"上** —— GitHub issue 不死靠 PR merge 自动 close -3. **Thread closure = 做事的副产品**,不是单独动作 -4. **View 不应该是 SoT** —— IM doc / tracker / todo doc 都是 view -5. **"汇报"也是 view**,不是 log -6. **AI 时代,工作即 chat** —— log / issue / chat 折叠为 Loop -7. **Issue 是外部的,Loop 是内部的** —— map 但不嵌套 -8. **TODO 在 Loop 模型里是 view**,不是一阶概念 - -## 设计原则(自建系统的约束) - -- 目录是一阶对象(loop = dir) -- AI 是一阶用户(无需 token / GUI) -- 代码 / markdown / 产物平等 -- 无 GUI 必需,终端能跑就够 -- Runtime 闭环第一性 -- 零 lock-in - -## Issue / Loop / TODO 的位置 - -| | 是什么 | 在哪 | 谁能见 | -|---|---|---|---| -| **Loop** | 内部工作单元 | 你本地 dir + chat | loop 参与者 | -| **Issue** | 外部任务 / 协调 | tracker / GitHub / 老板嘴里 | 团队 / 公开 | -| **TODO** | 跨 loop 或 loop 内的 view | 一个 query 渲染出来 | 看 view 的人 | - -## 实施状态 - -| 元素 | 状态 | -|---|---| -| Knowledge | ✅ git docs(ccx/docs) | -| Runtime | 🚧 loopctl 扩展中(Heroku 风格闭环) | -| Loop | ❓ 模型已成型,实操开放 | -| IRC bot 雏形 | ✅ ergo + thelounge + coo(`~/workspace/im` 早期 Loop 实现)| - -## 排除现有工具 - -| 工具 | 为什么不直接用 | 借鉴什么 | -|---|---|---| -| GitHub Issues | code-centric,非代码任务别扭 | PR merge 闭环模式 | -| Linear | SaaS、不 filesystem-native、不 AI-friendly | lifecycle 概念(多态状态、Triage、Cycle、Cancelled vs Done)| -| Obsidian | note-centric、GUI 流、缺 runtime 闭环 | Tasks / Dataview / backlinks 模式 | -| Notion / Jira | 太重、SaaS、AI 不友好 | — | -| IRC | 通信协议而非完整方案 | channel = 多人 loop 的早期形态 | - -## 开放问题(Loop 实操层) - -1. **Chat 协议**:IRC?claude-code conversation log?markdown? -2. **持久化**:append-only file?SQLite? -3. **Handoff**:怎么 onboard 新参与者? -4. **Fork**:git branch?复制目录? -5. **INDEX / view 怎么实现**:当前最关键问题(todo 问题退化到这里) -6. **AI session 与 loop chat 的对应**:1:1?多对多? - -## 不再开放(已收敛) - -- ~~Knowledge 是支柱还是派生~~ → **流** -- ~~Thread / Issue / Log / Chat 怎么区分~~ → **都是 Loop 的不同面 / 参与者多寡之别** -- ~~多人 thread 时 Log SoT 在哪~~ → **多人 loop = 多人 channel,本就在共享处** -- ~~汇报 / 周报 / tracker 工单怎么定位~~ → **都是 view** -- ~~是不是直接用 Obsidian / Linear / GitHub~~ → **不**,自建 -- ~~TODO 系统怎么搞~~ → **不搞,是 view** - ---- - -整套思想的演化路径在 `thoughts/` 里: - -| 思考 | 主题 | -|---|---| -| `runtime-as-context-shrinker.md` | runtime 压缩 knowledge | -| `entity-log-view.md` | thread 数据模型(已折叠进 Loop)| -| `runtime-closes-the-loop.md` | 必经之路闭环原则 | -| `obsidian-deep-dive.md` | Obsidian 解读 + 借鉴 | -| `system-shape.md` | 自建决定 + 设计原则 | -| `loop.md` | Loop 概念定义 + Loop 中心论 | -| `todo-as-view.md` | TODO 是 view 不是一阶 | diff --git a/thoughts/superseded/loopd.md b/thoughts/superseded/loopd.md deleted file mode 100644 index ec0fdfd3..00000000 --- a/thoughts/superseded/loopd.md +++ /dev/null @@ -1,395 +0,0 @@ -# Loopd — Context Handoff to Claude Code - -> This document captures the design conversation between simpx and Claude (in Claude.ai) that led to the current Loopd MVP. It includes both **what we decided** and **what we explicitly rejected, and why**. Read this before suggesting designs — many "obvious" alternatives have already been ruled out for specific reasons. - ---- - -## 1. One-line product description - -**Loopd is a CLI tool that lets a person save the full state of a working directory as a "loop" snapshot, share it with a teammate, and have the teammate restore it locally to continue the work.** - -That's it. Everything else (agent integration, IM, dashboard, real-time sync) is post-MVP. - ---- - -## 2. The deeper philosophy (don't lose this) - -The product was conceived around a specific observation: - -> **AI has no autonomous desire. Desire can only be injected by a human.** - -This means: in any human + AI workflow, the human is the source of intent ("driver"), and AI is an extension that executes injected intent. The hardest problem in human-AI-collaboration is not capability — it is **expressing, preserving, and transferring human intent reliably across people, agents, and time.** - -Loopd's deepest purpose is to be **the container and conduit for intent**. Save/restore is the minimal mechanism to validate this — does the snapshot carry enough intent for someone else to continue the work? - -If you forget everything else in this doc, remember this paragraph. All design choices below serve this philosophy. - ---- - -## 3. The user (target persona) - -- **Engineers**, comfortable with terminal, git, gh CLI -- Already use Claude Code or similar agent CLI tools -- Work in small teams (3–10 people) -- simpx himself is the first user -- simpx already built **ccx** — a bash-based Claude Code session manager. Loopd should NOT replace ccx, NOT integrate with ccx in MVP, just **coexist** alongside ccx - ---- - -## 4. Current MVP scope - -### Commands (THIS IS ALL) - -``` -loopd save --new <name> # First time: snapshot current dir, create new loop -loopd save <id> # Update an existing loop with new snapshot -loopd restore <id> # Download latest snapshot, extract locally -loopd ls # List loops (optional, low priority for day 1) -``` - -**That's the entire MVP. 3 commands.** No handoff command, no driver command, no say/log command, no agent integration. - -### What "save" does - -1. Read current working directory -2. Apply default ignore list (target/, node_modules/, __pycache__/, .venv/, etc.) -3. tar + gzip into a snapshot tarball -4. Upload tarball to S3 (object storage) -5. Open `$EDITOR` for the user to write/edit a `meta.json` (or markdown that gets parsed) — fields: name, goal, status, current_state, next_steps, notes_for_next_driver -6. Upload meta.json to S3 -7. Update a global index file (all-loops.json) so `ls` works - -### What "restore" does - -1. GET meta.json for the given loop id -2. Display a summary (goal, current state, who saved it last, when) -3. Download the latest snapshot tarball -4. Extract to `~/loopd/loops/<id>/` -5. Generate a `bootstrap.md` in the extracted dir that tells the next person how to get started (what to read, what external repos to set up, etc.) -6. Print the path so user can `cd` there - -### What "save" does NOT do - -- Does NOT touch ccx -- Does NOT manage cc sessions -- Does NOT do git commits -- Does NOT create PRs -- Does NOT update PR descriptions -- Does NOT do any handoff bookkeeping -- Does NOT track drivers -- Does NOT log "turns" - -### Implicit handoff via save+restore - -There's no handoff command. Handoff happens organically: -- Person A saves a loop -- Person A pings Person B in IM ("hey check loop 173") -- Person B restores -- Person B works on it, saves -- Person A restores when they want to see progress - -This is sufficient for MVP. We are explicitly NOT building a handoff command yet. - ---- - -## 5. Tech stack decisions (locked in) - -| Component | Choice | Why | -|---|---|---| -| Language | **Rust** | simpx is fluent, single binary distribution, fits ccx ecosystem | -| Storage | **Object storage (S3-compatible)** | Simple GET/PUT, no database needed | -| Specific S3 | **TBD — see open questions** | Candidates: self-hosted nginx + SFTP, Cloudflare R2, AWS S3 | -| Tarball format | **tar.gz** | Standard, ubiquitous | -| Metadata format | **JSON** (meta.json) | Simple, parseable, debuggable | -| Index format | **JSON** (all-loops.json) | Same | -| Concurrency control | **Optimistic with etag/if-match** | S3 native, sufficient for low concurrency | -| ID assignment | **Sequential, allocated by reading max from index +1** | With etag retry on collision | - -### Rust crate suggestions (not locked, just starting points) - -- `clap` — CLI parsing -- `tar` + `flate2` — tarball -- `ignore` — gitignore-style file filtering -- `serde` + `serde_json` — JSON -- `rust-s3` or `aws-sdk-s3` — S3 client (S3 API) -- `reqwest` — if doing direct HTTP -- `dirs` — for `~/.loopd/` paths -- `anyhow` — error handling - ---- - -## 6. THE EVOLUTION (read this so you don't suggest a rejected design) - -The MVP went through ~7 progressive simplifications. Each step was a deliberate "step back" by simpx. Here is the trail, with rejection reasons: - -### Step 1 (REJECTED): Full SaaS platform - -Initial framing: a Slack-like multi-person + multi-agent collaboration platform, with channels, agents-as-first-class-citizens, presence indicators, etc. Too big for one person in 2-3 months. - -### Step 2 (REJECTED): Loop OS / collaboration platform with web UI - -Pivoted to "loop" as the core primitive. Designed loop / turn / handoff / driver / participant data models, web UI mockups, multi-tenant database, agent connection protocol. Still too big. - -### Step 3 (REJECTED): Owner + Driver split - -Originally I (Claude) proposed having both `owner` (intent source, must be human) and `driver` (current pusher, can be human or agent). simpx cut this — said only `driver` matters, and driver must always be human, agents are tools the driver uses. **Don't reintroduce owner.** - -### Step 4 (REJECTED): Web-first MVP with Next.js + Supabase - -I proposed Next.js + Supabase + Clerk. simpx rejected because: -- Web frontend for a non-designer engineer would be "工程师味儿" (engineer-flavored), low quality -- CLI is the right form factor for the target user -- Frontend should be forked from an existing open-source product later, not built from scratch - -### Step 5 (REJECTED): Fork OpenCode / Claudia / etc - -Considered forking an existing open-source Claude Code UI (OpenCode 95k stars, Opcode/Claudia 21k, claudecodeui). Decided NOT to fork because: -- Forking 95k LOC project means most time spent reverse-engineering someone else's code -- Loopd's core is the data model, not the chat UI -- Build CLI from scratch first, fork only later if doing web - -### Step 6 (REJECTED): Git-native with branches as loops - -Designed: each loop = a branch in a shared loops repo, LOOP.md is the source of truth, all coordination via git. simpx loved the idea initially, but then questioned why we even need git when each "save" is essentially a snapshot. - -### Step 7 (REJECTED variant): GitHub PR as loop - -Considered using GitHub PR description + comments as the loop's "shell" — driver = PR assignee, log = comments, handoff = changing assignee. Pretty good but: -- PR lifecycle (opens → merges) doesn't match all loop lifecycles -- GitHub-locked -- LOOP.md as actual file vs PR description had sync issues -- Needed gh CLI, layered on top of git CLI - -### Step 8 (CURRENT): Pure tarball + S3 - -simpx's own insight: "If everything is a tarball, why do I even need git?" -Result: just upload/download tarballs to S3, with a json metadata index. No git, no GitHub, no PRs. - -This is the MVP. **Don't suggest reintroducing git or GitHub PRs unless simpx asks.** - ---- - -## 7. Things that were CONSIDERED but consciously REJECTED - -When in doubt, do NOT add these to MVP: - -- ❌ **Database** — meta.json + index file is the database -- ❌ **Backend service** — CLI talks directly to S3, no server in between -- ❌ **Web UI** — CLI-only for MVP. If web is ever added, it's a thin viewer for the same S3 data -- ❌ **Authentication system** — use S3 credentials, no user accounts -- ❌ **Git integration** — no git operations in save/restore -- ❌ **GitHub PR integration** — none -- ❌ **Agent SDK / agent connection protocol** — agents don't connect to Loopd in MVP -- ❌ **ccx integration** — Loopd and ccx coexist but don't talk to each other -- ❌ **Real-time sync / WebSocket / SSE** — polling-style is fine, even manual refresh is fine -- ❌ **Notifications** — out-of-band (IM) is fine -- ❌ **Driver/owner split** — only driver, must be human -- ❌ **Handoff command** — implicit via save+restore -- ❌ **Turn / log granular events** — just snapshots and meta.json updates -- ❌ **Knowledge management features** — out of MVP entirely -- ❌ **Workspace/Room hierarchy** — flat list of loops for now -- ❌ **Persona / agent identity registration** — not needed -- ❌ **Permissions / RBAC** — everyone in the team can access everything -- ❌ **Search** — `ls` is enough -- ❌ **Mobile app** — never -- ❌ **Cloud-hosted agent runtime** — out of scope - ---- - -## 8. Things that ARE in MVP scope but underdesigned (open questions) - -These were intentionally left for implementation time: - -### Q1: Where to host S3? - -Three candidates considered: - -a. **Self-hosted nginx + SFTP** on a server simpx has access to. -- Pro: zero external dependency, full control -- Con: requires running a server, dealing with SSH keys for team - -b. **Cloudflare R2** -- Pro: S3-compatible, no egress fees, fast globally -- Con: external service, need account setup - -c. **AWS S3** -- Pro: fast in China (simpx is in China), simpx might have existing access -- Con: vendor-specific - -**Recommendation when starting**: ask simpx which one he wants to use. If he doesn't care, default to whichever has the lowest setup friction in his current environment. - -### Q2: Tarball includes external repo code or not? - -When working on a feature, the user's working dir is often a checkout of an existing repo (e.g., `~/work/anyserve/`). Two options: - -a. **Include**: tar everything including the full `.git/` and source tree. Self-contained snapshot. Easy to restore. But tarball is large (hundreds of MB). - -b. **Exclude**: ignore the external repo, only save loopd-specific files (notes, artifacts, ccx session data). meta.json records "External: anyserve @ feat/x @ commit-abc". Smaller tarball but next driver needs to set up external repo themselves. - -**Default for MVP**: include. Optimize later if tarballs are too big. - -### Q3: meta.json format - -Minimal proposed schema: - -```json -{ - "id": 173, - "name": "Add per-loop token budget", - "status": "running", // running / paused / done / abandoned - "driver": "simpx", // who saved it last - "goal": "...", // markdown allowed - "current_state": "...", // markdown - "next_steps": "...", // markdown - "notes_for_next_driver": "...", // optional, markdown - "created_at": "ISO8601", - "created_by": "simpx", - "snapshots": [ - { - "id": "snap-001", - "uri": "loops/173/snapshot-001.tar.gz", - "size_bytes": 145000000, - "created_at": "ISO8601", - "created_by": "simpx", - "saver_notes": "initial save" - } - ], - "external_refs": { // optional - "anyserve": "feat/token-budget @ a3f9c1" - } -} -``` - -This schema is a starting point — refine as needed during implementation. - -### Q4: Default ignore list - -Suggested defaults to put in `.loopdignore` (built into binary): - -``` -target/ -node_modules/ -__pycache__/ -*.pyc -.venv/ -venv/ -build/ -dist/ -.DS_Store -*.log -.cache/ -``` - -Users can add their own `.loopdignore` in working dir to extend this. - -### Q5: bootstrap.md template - -When restore extracts a tarball, drop a `bootstrap.md` in the dir like: - -```markdown -# Welcome to Loop #{id}: {name} - -You restored this loop on {date}. - -## Quick read -- See LOOP.md / meta.json for full context -- Goal: {goal} -- Current state: {current_state} -- Next steps: {next_steps} -- Note from previous driver: {notes_for_next_driver} - -## To continue -1. Read the files in this directory -2. (If external repo) cd to your local checkout of {external_repo}, fetch and checkout {branch} -3. Start your own ccx session: `ccx new` (your session, not theirs) -4. When you've made progress: `loopd save {id}` -``` - -### Q6: Editor integration for save - -When `loopd save --new` is called, it should open `$EDITOR` (vim/nano/etc.) with a pre-filled markdown template. User edits, saves, exits. Loopd parses the markdown back into meta.json fields. - -Or: skip editor, just take `--name` as flag and prompt for other fields. Probably simpler for MVP. - -### Q7: Concurrency on the index - -If two people save at the same time, both try to update `index/all-loops.json`. Simple solution: use S3 conditional PUT (if-match etag). On conflict, retry: re-fetch index, re-apply local change, retry PUT. Acceptable for low-concurrency teams. - -If etag isn't supported (rare for S3-compatible S3), fallback: per-user index files (`index/by-user/simpx.json`), aggregate on read. - ---- - -## 9. First-week plan - -### Day 1 — Save command - -- Set up Rust project, `cargo new loopd` -- Add `clap`, `tar`, `flate2`, `serde_json`, `ignore`, S3 client -- Implement `loopd save --new <name>`: - - Parse args - - Walk current dir, apply ignores, build tarball in memory or temp file - - Open editor for meta.json fields (or skip, take from CLI flags for v0) - - PUT tarball to S3 - - PUT meta.json to S3 - - Update index -- Test: save simpx's actual current working dir - -### Day 2 — Restore command - -- Implement `loopd restore <id>`: - - GET meta.json - - Display summary - - GET tarball - - Extract to `~/loopd/loops/<id>/` - - Write bootstrap.md -- Implement `loopd ls`: - - GET index, format as table -- End-to-end test: simpx saves, teammate restores - -### Days 3–7 — Real use - -simpx and at least one teammate use loopd for real work. Save when stopping work, restore when continuing or when teammate hands over. **Do not add features in week 1**. Just collect pain points. - -### After week 1 - -If real usage felt valuable, prioritize the most painful gap (e.g., "I want to see my list of loops faster" → polish `ls`, "save asks too many questions" → simplify prompts). If it didn't feel valuable, kill the project — simpx explicitly said "if it sucks, I'll abandon it." - ---- - -## 10. Notes on style and working with simpx - -Things to know about how simpx works (drawn from the design conversation): - -- simpx cuts aggressively. When in doubt, simplify, don't add. Almost every "elaborate design" Claude proposed got cut down. -- simpx has strong intuitions but verbalizes them gradually. When he says "我感觉..." or "我有点纠结..." he's usually onto something important — explore the intuition before pushing back. -- simpx pushes back hard on overengineering. If you find yourself proposing "let's add X to handle case Y", first ask: does the current MVP scope require this? -- simpx reads carefully. Don't write unnecessarily long responses. He explicitly said at one point "你说的东西太多了我跟不上" (you're saying too much, I can't keep up). -- simpx likes Chinese for casual conversation, mixes English for technical terms. Either is fine. -- simpx is a strong systems engineer (Rust, LLM inference, KV cache, etc.). Don't over-explain Rust idioms or basic engineering concepts. Do explain when veering into product/UX territory. -- simpx runs ccx, a bash + tmux + Eternal Terminal Claude Code session manager. He thinks in terms of git worktrees, shell scripts, and CLI ergonomics. Designs that don't fit this aesthetic are likely wrong for him. -- simpx has a project called **mockup.md** (Markdown for slides), uses Obsidian, has explored MAORPG game design, organizational theory ("1001人公司"). Broad interests, but for this MVP stay focused. - ---- - -## 11. What "done" looks like for week 1 - -- [ ] simpx can run `loopd save --new "test"` in any directory and it produces a tarball + meta.json on S3 -- [ ] simpx can run `loopd restore <id>` on another machine (or a fresh dir) and recover the directory -- [ ] At least one teammate has used `loopd restore` to pick up work from simpx -- [ ] simpx has tried it for real work for at least 3 days -- [ ] simpx has a list of pain points (whether to fix them is week 2's question) - ---- - -## 12. If something here contradicts what simpx says now - -**simpx wins.** This document is a snapshot. He is the source of intent (literally — see philosophy section). If he changes his mind, update the design — don't argue from this doc. - -But also: **don't proactively suggest reintroducing things this doc says were rejected, unless he asks.** Many of the "obvious next steps" (add a database! add git! add a web UI! add agent integration!) were already considered and consciously deferred. Re-suggesting them wastes everyone's time. - ---- - -## End of handoff - -Good luck. Build the smallest thing that works. Then have simpx actually use it for a week. Decide what to do next based on real use, not on what looked good in design. - diff --git a/thoughts/superseded/pillars.md b/thoughts/superseded/pillars.md deleted file mode 100644 index 852541b5..00000000 --- a/thoughts/superseded/pillars.md +++ /dev/null @@ -1,26 +0,0 @@ -# 三支柱当前状态 - -## Knowledge - -**方案**:git + docs(类似 `ccx/docs`) -**状态**:✅ 已定型,沿用现有做法 - -## Runtime - -**方案**:把 `~/workspace/loopctl` 做完整,基于它可以完全做开发工作。 - -**目标**:Heroku 风格的开发反馈闭环 —— 让 AI 非常快地拿到 feedback: - -- `loopctl deploy` → 代码里直接拿到一个 URL -- `loopctl terminal` → 直接拿到一个终端 -- `loopctl logs` → 直接看 log -- …… - -**状态**:🚧 进行中 - -参考思考:[Runtime as Context Shrinker](thoughts/runtime-as-context-shrinker.md) - -## Thread - -**方案**:暂无好的 all-in-one 方案 -**状态**:❓ 开放问题 —— 三支柱里最大的缺口 diff --git a/thoughts/system-shape.md b/thoughts/system-shape.md deleted file mode 100644 index 910db713..00000000 --- a/thoughts/system-shape.md +++ /dev/null @@ -1,50 +0,0 @@ -# 系统形态:自建 filesystem-base + AI-base 的 Thread 系统 - -## 决定(2026-05-03) - -不采用现有工具(GitHub Issues / Linear / Obsidian),**自建一个本地的、filesystem-native、AI-native 的 thread 系统**。 - -## 排除现有工具的原因 - -讨论 Obsidian 时浮现的 6 点几乎全部命中: - -1. **note-centric vs workspace-centric**:Obsidian 一切是 note,我们要 thread = 目录 -2. **markdown 世界 vs 混合世界**:vault 假设 90% 是 `.md`,我们 workspace 大量是代码、产物、二进制 -3. **知识管理基因 vs 工作管理基因**:evergreen notes / digital garden 默认沉淀,我们要推进 -4. **GUI 插件流 vs 终端流**:我们活在 vim + terminal -5. **缺 runtime 闭环**:Obsidian 不响应 `loopctl deploy` -6. **"打开工具创建 thread" vs "工作中自然长出 thread"**:我们要后者 - -Linear / GitHub 各自的问题前面已讨论。 - -## 设计原则(约束) - -- **目录是一阶对象**:thread = `~/workspace/<name>/`,不需要 workaround -- **AI 是一阶用户**:AI 直接读写文件、调 CLI,不需要 GUI、不需要 token -- **代码、markdown、产物平等**:没有"二等公民" -- **无 GUI 必需**:终端能跑就够,GUI 是可选 -- **Runtime 闭环是第一性**:loopctl 等工具能 emit closure 事件 -- **零 lock-in**:所有数据都是 plain file,换工具不丢 - -## 借鉴 - -| 来源 | 借鉴 | -|---|---| -| Obsidian Tasks | `- [ ]` 跨文件查询的"一处定义、多处渲染" | -| Obsidian Dataview | YAML frontmatter 当数据库字段 | -| Obsidian backlinks | 文件间引用 → 自动反向索引 | -| GitHub PR | runtime 闭环(PR merge 自动 close) | -| Linear lifecycle | 多态状态流、Triage、Cycle | -| ccx 旧 todo.md | 周轮换、`> workspace: foo` metadata 行 | - -## 待解 - -- `THREAD.md` 的具体 schema(frontmatter 字段、log 约定) -- CLI 操作动词(list / new / log / close / archive?) -- AI 接入形式(MCP?skill?loopctl 子命令?独立工具?) -- INDEX 的角色(todo doc 视图怎么生成?) -- 与外部 thread(tracker / GitHub)的连接协议 - -## 下一步 - -最低成本验证:挑一个活跃 thread(如 `~/workspace/loopctl/`),写一个 `THREAD.md`,用最朴素的 markdown + frontmatter,看真实形态。比再讨论 1 小时清楚。 diff --git a/thoughts/todo-as-view.md b/thoughts/todo-as-view.md deleted file mode 100644 index 9d40ce1e..00000000 --- a/thoughts/todo-as-view.md +++ /dev/null @@ -1,55 +0,0 @@ -# TODO 在 Loop 模型里:是 view,不是一阶概念 - -## 旧问题 - -`ccx/notes/todo.md` 弃用,深层原因不只是"必经之路上没它"(runtime 闭环角度),还有: - -**它想同时当 SoT 和 view**,结果两者都做不好: -- 任务条目(SoT)和扫一眼视图(view)混在一个文件 -- 多人加进来就 desync -- 无法多视图 - -## Loop 模型自动消解 - -| 旧 | 新(Loop 模型)| -|---|---| -| todo.md 一文承载所有 | SoT / view 分开 | -| SoT 在 view 里 | SoT 在 loop 的 chat / notes | -| view 也在 view 里 | view 是跨 loop 的 query | - -类似 **Obsidian Tasks** 的工程化范式:`- [ ]` 散落在所有 `.md`,跨文件 query 拉出列表。这套机制在 Loop 模型上完全成立。 - -## 两个尺度 - -| 尺度 | 位置 | 例 | -|---|---|---| -| 单 loop 内 | loop 自己的 chat / notes / THREAD.md | "等无厚回消息后改 RDMA 注册" | -| 跨 loop(meta)| 一个 view(query 渲染) | "今天 6 个 active loop,先推哪 2 个" | - -## 太小、不值一个 loop 的事 - -有些事太小不值整个 loop("找时间和无厚聊"、"明天提醒自己看下 X")。 - -它们也活在某个 loop 的 chat 里,只是不专门为它开 loop: - -- **默认 / inbox loop**:personal daily 类,零散事都丢这 -- **self chat**:长期"自己跟自己聊"的 loop - -形式上跟其他 loop 一样是个 dir,只是产物是清单 / 反思 / 计划而不是代码。 - -## todo doc 的角色 - -不消失,但永远是 **view**: - -- 每天扫一眼 → 跨 loop query 当天该做的 -- 周报 / 汇报 → 跨 loop query 本周变更 -- 老板看的进度 → 跨 loop query 状态 / 优先级 - -view 是临时、可重建、可定制的。**SoT 永远在 loop 内**。 - -## "todo 问题"实际是什么 - -- ❌ 不是"再造一个 todo 系统" -- ✅ 是 **loop 的 INDEX / view 怎么实现**(之前留的 6 个开放问题里的第 5 个) - -退化到 query 协议 + view 渲染机制 —— 是 Loop 实操层的问题,不是模型层的开放问题。 diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..c70db733 --- /dev/null +++ b/todo.md @@ -0,0 +1,45 @@ +# loopat — todo / 已知问题 + +> 记录用,先不动手修这些。当前无人值守任务:「mac 版本完整跑通 + behavior cases 全跑」。 +> 最后更新:2026-06-02(session: A2A / per-user repos / onboarding / mcp setup) + +## 已知问题 + +### 凭证 / onboarding +- [ ] **per-user vault key 要能访问 team git 平台**:simpx 的 vault key 没注册到 gitlab → 新建 loop `clone dashscope/knowledge` Permission denied,knowledge 空。已优雅处理(loop 顶部 contextWarnings banner),但缺"把 vault 公钥注册到 git 平台"的引导。并入 onboarding。 +- [ ] **UI 两把 key 易混**:deploy key(personal repo 用,comment `loopat:<user>`)vs vault key(team repos 用,comment `loopat:<provider-login>`)。personal-repo 页要同时显示 vault key 并验证能 `git ls-remote`。 +- [ ] **onboarding 后续 check**:code.ts 的 `onboarding()` 现有两步(personal repo + AI key)。可加:vault key 注册验证、mcp 认证、git/api 权限探测(都在 code.ts 里加,平台不动)。 + +### A2A +- [ ] **走的是方案 A(loopback)**:a2a adapter loopback 调自己的 `/api/v1`。已确认对外/对用户一致。**计划方案 C**:把 turn 引擎抽成进程内函数,`/api/v1` 和 `/a2a` 各自绑定,去掉 loopback + "对外 API 调对外 API"的怪味。 +- [ ] **A2A `input-required` 未实现**:现在 turn 走 `bypassPermissions` 跳过了"中途要用户输入"。协议两边都支持(A2A input-required ↔ v1 requires_choice),以后做交互式多轮。 +- [ ] **contextId→loop 映射在内存**:重启后新会话重开 loop(v1 可接受)。 +- [ ] **a2a tool_call/thinking 未透出**:现在只透 assistant 文本到 artifact;以后可把工具调用塞进 artifact。 + +### per-user 重构遗留 +- [ ] **admin profile 管理仍 workspace 级**:`tiers.ts` 的 `listProfilesRich()` / `/api/admin/profiles` 还读 `workspaceProfilesDir`。new-loop 选择器 + 统计已改 per-user(`listProfiles(user)`/`computeLoopStats(user,...)`),admin 那块要统一。 +- [ ] **`/api/sync/repos`(单 repo pull/push)对 bare mirror 是空操作**:code repos 现在是 host-only bare mirror(`repo-cache/<name>`),旧的单 repo sync 端点指向旧 working-tree 路径,找不到 → 空。bare mirror 每次建 loop 自动 fetch,基本不需要手动 sync。要的话补 bare 版 fetch。 +- [ ] **knowledge/notes 还是 working-tree 缓存,不是 bare mirror**:code repos 已改 bare mirror(`--bare --depth=1` 单分支 + worktree)。knowledge/notes 有 git-crypt、是入口指针,改 bare 风险高,暂缓。用户说过"每个 repo 都这样"。 +- [ ] **旧 loops 与 per-user 不兼容**:旧 loops 的 context worktree 派生自 workspace 共享 main repo,沙箱挂载已改 per-user → 旧 loops 打开可能异常。MVP 无迁移,新建即可。 + +### 其它 +- [ ] **serve-rs binary 没编译进 npx 包** → Share Artifact 不可用。CI 编译进包,或 UI 标注不可用。 +- [ ] **fatal: not a git repository 噪音**:已修(0.1.27 把 `/api/version` 的 git 改成 `stdio:["ignore","pipe","ignore"]`)。mac mini 0.1.39+ 实测 0 次。若再现 = 旧版本,升级即可。其余 git 调用都走 `execFileP`(捕获 stderr,不泄露)。 +- [ ] **npmmirror 同步滞后**:每次发版要手动 `curl -X PUT .../syncs?sync_upstream=true`。mac mini 用 npmjs(加 `npm_config_prefer_online=true` 破缓存),siqian mac 用 npmmirror。 +- [ ] **behavior 02 脚本过时**:stage3 假设 personal repo 用 vault key,实际走 deploy key(Model B / per-user);notes 已移进 knowledge config。脚本+断言要重写。 + +## 准备做的事 +- [ ] A2A 方案 C(共享 turn 引擎,去 loopback) +- [ ] onboarding 补 vault-key 注册引导 + 更多 check +- [ ] admin profile 管理统一到 per-user knowledge +- [ ] behavior 02 脚本更新到 Model B / per-user +- [ ] (可选)knowledge/notes 也改 bare mirror + +## 本 session 已发布(0.1.32 → 0.1.40) +- 0.1.32 onboarding 完全扩展自管(check→remediation,route/form 两原语) +- 0.1.33/34 profile 列表+统计改 per-user knowledge +- 0.1.35/36/37 loop 建仓提速:depth=1 → bare mirror cache + worktree +- 0.1.38 mcp:通用 authed + 内联 resource + 粘贴 url 解析填值 +- 0.1.39 标准 A2A + per-user agent(`/a2a/<user>/...`)+ 可编辑 card/key +- 0.1.40 personal repo 导入已加密 repo 时显示 crypt-key 输入框 +- (扩展)code.ts onboarding 判断 AI key 改用 resolved providers(兼容已有 repo) diff --git a/web/components.json b/web/components.json new file mode 100644 index 00000000..9bcd7aca --- /dev/null +++ b/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/phase1-prototype/index.html b/web/index.html similarity index 66% rename from phase1-prototype/index.html rename to web/index.html index 8c944e06..0b64e796 100644 --- a/phase1-prototype/index.html +++ b/web/index.html @@ -3,11 +3,11 @@ <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>1001 · loopat</title> + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> + <title>loopat</title> </head> <body> <div id="root"></div> - <script type="module" src="/src/index.tsx"></script> + <script type="module" src="/src/main.tsx"></script> </body> </html> diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..34eb50bb --- /dev/null +++ b/web/package.json @@ -0,0 +1,67 @@ +{ + "name": "@loopat/web", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "(cd ../server/src/serve-rs && cargo build --release) && (cd ../server/src/port-proxy-rs && cargo build --release) && tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@assistant-ui/react": "^0.14.5", + "@assistant-ui/react-markdown": "^0.14.0", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/language": "^6.12.3", + "@codemirror/legacy-modes": "^6.5.3", + "@codemirror/lint": "^6.9.6", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.42.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@lezer/highlight": "^1.2.3", + "@milkdown/core": "^7.21.1", + "@milkdown/plugin-listener": "^7.21.1", + "@milkdown/preset-commonmark": "^7.21.1", + "@milkdown/preset-gfm": "^7.21.1", + "@milkdown/react": "^7.21.1", + "@xterm/addon-clipboard": "^0.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-ligatures": "^0.10.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "codemirror": "^6.0.2", + "highlight.js": "^11.11.1", + "lucide-react": "^1.14.0", + "radix-ui": "^1.4.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.11.1", + "react-router-dom": "^7.15.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1", + "smol-toml": "^1.6.1", + "tailwind-merge": "^3.5.0", + "tw-shimmer": "^0.4.11", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.0", + "@types/mdast": "^4.0.4", + "@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", + "unified": "^11.0.5", + "vite": "^8.0.11" + } +} diff --git a/phase1-prototype/public/favicon.svg b/web/public/favicon.svg similarity index 100% rename from phase1-prototype/public/favicon.svg rename to web/public/favicon.svg diff --git a/web/public/logo.png b/web/public/logo.png new file mode 100644 index 00000000..d15f3491 Binary files /dev/null and b/web/public/logo.png differ diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 00000000..fc3266f2 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,462 @@ +/** + * Top-level shell. Single workspace per loopat instance — no workspace + * prefix in URL, no workspace switcher. The header just shows the + * workspace name fetched once from /api/health (which is basename of + * LOOPAT_HOME, server-side). + */ +import { useEffect, useState } from "react" +import { MessageCircle, RefreshCw, X, Sun, Moon } from "lucide-react" +import { BrowserRouter, Routes, Route, Navigate, NavLink, Outlet, useNavigate, useMatch, useLocation } from "react-router-dom" +import { TooltipProvider } from "@/components/ui/tooltip" +import { useWorkspaceState, type WorkspaceState } from "./state" +import { WorkspaceCtx, useWorkspace } from "./ctx" +import { NewLoopDialog } from "./components/dialog/NewLoopDialog" +import { AboutDialog } from "./components/dialog/AboutDialog" +import { LoopPage } from "./pages/LoopPage" + +import { TopicView } from "./pages/TopicView" +import { ContextPage } from "./pages/ContextPage" +import { KanbanPage } from "./pages/KanbanPage" +import { ChatPage } from "./pages/ChatPage" +import { SettingsPage } from "./pages/SettingsPage" +import { AdminSystemPage } from "./pages/AdminSystemPage" +import { AuthPage } from "./pages/AuthPage" +import { FloatingDm } from "./components/FloatingDm" +import { SetupPersonalRepoCard, isSetupPersonalRepoDismissed } from "./components/SetupPersonalRepoCard" +import { getServerWorkspace, getVersion, getBuildInfo, linkKanbanLoop, getPersonalStatus, getOnboarding, type PersonalStatus, type OnboardingStatus } from "./api" +import { OnboardingForm } from "./components/OnboardingForm" +import { OnboardingInfo } from "./components/OnboardingInfo" +import { useChatUnreadTitle } from "./useChatUnreadTitle" +import { ThemeProvider, useTheme } from "./theme" + +const TABS = [ + { id: "loop", label: "Loop", icon: "⑂" }, + + { id: "kanban", label: "Focus", icon: "◎" }, + { id: "context", label: "Context", icon: "⌘" }, + { id: "chat", label: "Chat", icon: <MessageCircle size={14} /> }, +] as const + +function Layout() { + const ws = useWorkspaceState() + return ( + <WorkspaceCtx.Provider value={ws}> + {ws.authLoading ? null : <Shell ws={ws} />} + </WorkspaceCtx.Provider> + ) +} + +function Shell({ ws }: { ws: WorkspaceState }) { + const navigate = useNavigate() + const location = useLocation() + const [workspaceName, setWorkspaceName] = useState("loopat") + const [menuOpen, setMenuOpen] = useState(false) + const [aboutOpen, setAboutOpen] = useState(false) + const [showUpdateBanner, setShowUpdateBanner] = useState(false) + const [newVersionCommit, setNewVersionCommit] = useState("") + const [newVersionTime, setNewVersionTime] = useState("") + const { theme, toggle: toggleTheme } = useTheme() + const me = ws.currentUser?.id ?? "" + const isAdmin = ws.currentUser?.role === "admin" + const loggedIn = !!ws.currentUser + const onLoopRoute = !!useMatch("/loop/:id") + const onChatRoute = location.pathname.startsWith("/chat") + // Anonymous viewers on a loop URL get the "shared" experience: no header, + // no tabs, no login affordance, no dialogs. The page itself enforces + // meta.public via server gating; if it's not public the page renders its + // own "unavailable" state. + const shareMode = !loggedIn && onLoopRoute + + useEffect(() => { + if (shareMode) return + getServerWorkspace().then((name) => { + if (name) { + setWorkspaceName(name) + // Base title. When logged in, useChatUnreadTitle below re-fires on + // workspaceName change and either re-sets the same string (no unread) + // or prefixes "(N) ". The two effects don't fight — last write + // wins per state change, and the hook always runs after. + document.title = `${name} · loopat` + } + }) + }, [shareMode]) + + useChatUnreadTitle(workspaceName, loggedIn && !shareMode, me) + + const DISMISSED_KEY = "loopat:version-dismissed" + + useEffect(() => { + const handler = (e: Event) => { + const commit = (e as CustomEvent).detail?.commit as string | undefined + if (!commit) return + // Don't show if this version was already dismissed or clicked + try { + const dismissed = JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]") as string[] + if (dismissed.includes(commit)) return + } catch {} + setNewVersionCommit(commit) + setNewVersionTime(new Date(getBuildInfo().time).toLocaleString()) + setShowUpdateBanner(true) + } + window.addEventListener("loopat:version-mismatch", handler) + return () => window.removeEventListener("loopat:version-mismatch", handler) + }, []) + + useEffect(() => { + if (shareMode) return + const build = getBuildInfo() + getVersion().then((v) => { + console.log( + `%cloopat %cserver:%c ${v.branch}@${v.commit.slice(0, 7)} %cbuild:%c ${build.commit.slice(0, 7)} @ ${build.time}`, + "font-weight:bold", + "color:#666", + "color:#61afef", + "color:#666", + "color:#98c379", + ) + }) + }, [shareMode]) + + // Hard onboarding gate — the active provider fully owns the flow (see + // GitHostProvider.onboarding). loopat just shows what it returns. + const [onboarding, setOnboarding] = useState<OnboardingStatus | null>(null) + useEffect(() => { + if (!loggedIn || shareMode) { setOnboarding(null); return } + getOnboarding().then(setOnboarding) + }, [loggedIn, shareMode]) + // While the provider is sending the user to an existing page (a "route" + // remediation, e.g. the personal-repo setup), poll so the gate advances on its + // own once the user finishes there (no in-page wiring needed). + useEffect(() => { + if (!loggedIn || shareMode || !onboarding || onboarding.done || !onboarding.gated) return + if (onboarding.show.kind !== "route") return + const t = setInterval(() => { getOnboarding().then(setOnboarding) }, 3000) + return () => clearInterval(t) + }, [loggedIn, shareMode, onboarding]) + + if (shareMode) { + return ( + <div className="h-full w-full bg-white text-gray-900"> + <Outlet /> + </div> + ) + } + + // Anyone not on a (shared) loop URL must be logged in. Show the login page + // as the entire screen — no header, no tabs, no nav. Successful login + // re-renders the Shell with the normal chrome. + if (!loggedIn) { + return <AuthPage /> + } + + const handleCreate = async (opts: { title: string; repo?: string; profiles?: string[]; vault?: string }) => { + const m = await ws.createLoop(opts) + if (ws.kanbanCreateCtx) { + await linkKanbanLoop(ws.kanbanCreateCtx.board, ws.kanbanCreateCtx.filename, ws.kanbanCreateCtx.cid, m.id) + } + ws.setNewLoopDialogOpen(false) + navigate(`/loop/${m.id}`) + return m.id + } + + return ( + <div className="h-full w-full flex flex-col bg-gray-50 text-gray-900"> + <header className="h-12 shrink-0 border-b border-gray-200 bg-white flex items-center px-2 md:px-4 gap-2 md:gap-4"> + <div className="flex items-center gap-2 px-1 md:px-2 h-8 shrink-0 cursor-pointer" title={`workspace: ${workspaceName}`} onClick={() => { window.location.href = "/" }}> + <span className="text-lg leading-none">🧶</span> + <span className="hidden md:inline text-sm text-gray-900 font-medium">{workspaceName}</span> + </div> + <nav className="flex items-center gap-0.5 md:gap-1"> + {TABS.map((t) => ( + <NavLink + key={t.id} + to={`/${t.id}`} + className={({ isActive }) => + isActive + ? "px-2 md:px-3 h-8 rounded text-sm bg-gray-900 text-white flex items-center gap-1.5" + : "px-2 md:px-3 h-8 rounded text-sm text-gray-600 hover:bg-gray-100 flex items-center gap-1.5" + } + > + <span className="opacity-70">{t.icon}</span> + <span className="hidden md:inline">{t.label}</span> + </NavLink> + ))} + </nav> + <div className="flex-1" /> + <button + type="button" + onClick={() => ws.setNewLoopDialogOpen(true)} + className="flex items-center gap-1 md:gap-1.5 px-2 md:px-3 h-8 rounded text-sm bg-gray-900 text-white hover:bg-gray-700" + title="create new loop" + > + <span className="text-base leading-none">+</span> + <span className="hidden md:inline">New Loop</span> + </button> + <button + type="button" + onClick={toggleTheme} + className="flex items-center justify-center w-8 h-8 rounded text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors" + title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"} + > + {theme === "dark" ? <Sun size={16} /> : <Moon size={16} />} + </button> + <div className="relative"> + <button + type="button" + onClick={() => setMenuOpen((v) => !v)} + className="flex items-center gap-1 md:gap-2 px-1 md:px-2 h-8 rounded hover:bg-gray-100" + title="account" + > + <span className="w-6 h-6 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center"> + {me[0]?.toUpperCase() ?? "?"} + </span> + <span className="hidden md:inline text-sm text-gray-700">{me}</span> + <span className="text-gray-400 text-xs">▾</span> + </button> + {menuOpen && ( + <> + <div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} /> + <div className="absolute right-0 top-full mt-1 z-20 w-40 bg-white border border-gray-200 rounded shadow-md py-1"> + <button + type="button" + onClick={() => { + setMenuOpen(false) + navigate("/settings") + }} + className="w-full text-left px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100" + > + Settings + </button> + {isAdmin && ( + <button + type="button" + onClick={() => { + setMenuOpen(false) + navigate("/admin/system") + }} + className="w-full text-left px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100" + title="server version + activity + pull latest code" + > + Platform + </button> + )} + {isAdmin && ( + <button + type="button" + onClick={async () => { + setMenuOpen(false) + try { + const m = await ws.createLoop({ + title: "cross-loop distill", + mountAllLoops: true, + knowledgeRw: true, + }) + navigate(`/loop/${m.id}`) + } catch (e: any) { + alert(e?.message ?? "create failed") + } + }} + className="w-full text-left px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100" + title="New loop with /loopat/loops/ mounted read-only — read every loop's chat/workdir for distill" + > + Cross-loop distill + </button> + )} + <button + type="button" + onClick={() => { + setMenuOpen(false) + setAboutOpen(true) + }} + className="w-full text-left px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100" + > + About + </button> + <button + type="button" + onClick={async () => { + setMenuOpen(false) + await ws.logout() + navigate("/loop") + }} + className="w-full text-left px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100" + > + Logout + </button> + </div> + </> + )} + </div> + </header> + {showUpdateBanner && ( + <div className="shrink-0 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-blue-200 flex items-center px-4 py-2.5 gap-2.5"> + <RefreshCw className="w-4 h-4 text-blue-500 shrink-0" /> + <span className="text-sm text-blue-700"> + New version available + <span className="text-blue-400 ml-1 text-xs"> + ({newVersionCommit.slice(0, 7)} {newVersionTime}) + </span> + </span> + <button + type="button" + className="text-xs font-medium px-2.5 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer shrink-0" + onClick={() => { + try { + const dismissed = JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]") as string[] + dismissed.push(newVersionCommit) + localStorage.setItem(DISMISSED_KEY, JSON.stringify(dismissed)) + } catch {} + window.location.reload() + }} + > + Update + </button> + <button + type="button" + className="ml-auto p-0.5 rounded hover:bg-blue-100 text-blue-400 hover:text-blue-600 cursor-pointer shrink-0" + onClick={() => { + try { + const dismissed = JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]") as string[] + dismissed.push(newVersionCommit) + localStorage.setItem(DISMISSED_KEY, JSON.stringify(dismissed)) + } catch {} + setShowUpdateBanner(false) + }} + aria-label="关闭" + > + <X className="w-4 h-4" /> + </button> + </div> + )} + <main className="flex-1 min-h-0 min-w-0 overflow-hidden"> + {(() => { + const ob = onboarding + // No gate (or done / not logged in) → the normal app. + if (!(loggedIn && ob && ob.gated && !ob.done)) return <Outlet /> + // Provider wants the user on an existing page → send them there and show + // the real page; while there we poll so the gate advances when they're done. + if (ob.show.kind === "route") { + return location.pathname === ob.show.path ? <Outlet /> : <Navigate to={ob.show.path} replace /> + } + // Provider wants to show instructions → render them with a re-check button. + if (ob.show.kind === "info") { + return <OnboardingInfo show={ob.show} onAdvance={setOnboarding} /> + } + // Provider wants a form → render it; on submit we get the next view. + return <OnboardingForm form={ob.show} onAdvance={setOnboarding} /> + })()} + </main> + {ws.newLoopDialogOpen && ( + <NewLoopDialog onClose={() => ws.setNewLoopDialogOpen(false)} onCreate={handleCreate} initialTitle={ws.newLoopDialogTitle} /> + )} + <AboutDialog open={aboutOpen} onClose={() => setAboutOpen(false)} /> + {/* Floating DM bubble — hidden on /chat where the full surface is already up. */} + {!onChatRoute && <FloatingDm me={me} />} + </div> + ) +} + +function LoopRedirect() { + // Use the SHARED workspace context, not a fresh useWorkspaceState() — the + // latter creates an independent state instance that double-bootstraps auth + // and races against Layout's, sometimes returning currentUser=null on the + // first render. With the context, authLoading is already false (Layout + // gates Shell on it) so currentUser is final when this mounts. + const ws = useWorkspace() + const [personal, setPersonal] = useState<PersonalStatus | null>(null) + const [reloadKey, setReloadKey] = useState(0) + + useEffect(() => { + if (!ws.currentUser) return + getPersonalStatus().then((p) => setPersonal(p)) + }, [ws.currentUser, reloadKey]) + + // For logged-in users, wait until the personal fetch resolves before deciding + // the route, so the first render doesn't fall through to a loop redirect. + if (ws.currentUser && personal === null) { + return null + } + + // Wait for loops to load before checking length or redirecting + if (ws.loopsLoading) return null + + // Pre-onboarding (non-gated providers): no personal repo yet. Skip is a + // localStorage flag — the user can fall through to operate loopat with + // workspace-shared keys. (The HARD onboarding gate for providers that require + // it lives in Shell, blocking the whole UI.) + if ( + ws.currentUser && + personal && + !personal.imported && + !isSetupPersonalRepoDismissed() + ) { + return <SetupPersonalRepoCard onDismiss={() => setReloadKey((k) => k + 1)} /> + } + + if (ws.loops.length === 0) { + return <LoopEmpty loggedIn={!!ws.currentUser} onNew={() => ws.setNewLoopDialogOpen(true)} /> + } + // Prefer the current user's last-opened loop, then their newest loop, + // then fall back to the first loop in the list. + const lastLoopId = ws.currentUser?.id + ? localStorage.getItem(`loopat:lastLoop:${ws.currentUser.id}`) + : null + const myLoops = ws.currentUser?.id + ? ws.loops.filter((l) => l.createdBy === ws.currentUser!.id) + : [] + const preferredId = (lastLoopId && ws.loops.some((l) => l.id === lastLoopId)) + ? lastLoopId + : myLoops.length > 0 + ? myLoops[0].id + : ws.loops[0].id + return <Navigate to={`/loop/${preferredId}`} replace /> +} + +function LoopEmpty({ loggedIn, onNew }: { loggedIn: boolean; onNew: () => void }) { + return ( + <div className="h-full w-full flex flex-col items-center justify-center text-gray-500 gap-3"> + <div>no loops yet</div> + {loggedIn ? ( + <button + onClick={onNew} + className="px-3 h-8 rounded text-sm bg-gray-900 text-white hover:bg-gray-700" + > + + New Loop + </button> + ) : ( + <div className="text-xs text-gray-400">log in to create one</div> + )} + </div> + ) +} + +export function App() { + return ( + <ThemeProvider> + <TooltipProvider> + <BrowserRouter> + <Routes> + <Route element={<Layout />}> + <Route path="/" element={<Navigate to="/loop" replace />} /> + <Route path="/loop" element={<LoopRedirect />} /> + {/* Dual-mode: logged-in → full LoopPage with chrome; anonymous → + read-only share view (server gates by meta.public). Shell + drops the chrome when anonymous. */} + <Route path="/loop/:id" element={<LoopPage />} /> + <Route path="/topic/:name" element={<TopicView />} /> + <Route path="/kanban" element={<Navigate to="/kanban/default" replace />} /> + <Route path="/kanban/:board" element={<KanbanPage />} /> + <Route path="/context" element={<Navigate to="/context/knowledge" replace />} /> + <Route path="/context/:sub" element={<ContextPage />} /> + <Route path="/chat" element={<ChatPage />} /> + <Route path="/chat/:convId" element={<ChatPage />} /> + <Route path="/settings" element={<Navigate to="/settings/personal-repo" replace />} /> + <Route path="/settings/:tab" element={<SettingsPage />} /> + <Route path="/admin/system" element={<AdminSystemPage />} /> + </Route> + </Routes> + </BrowserRouter> + </TooltipProvider> + </ThemeProvider> + ) +} diff --git a/web/src/Editor.tsx b/web/src/Editor.tsx new file mode 100644 index 00000000..01bba8e6 --- /dev/null +++ b/web/src/Editor.tsx @@ -0,0 +1,104 @@ +/** + * Right-panel editor mode. + * <body: CodeMirror> + * <footer: path · unsaved · word-wrap · utf-8·LF> + */ +import { useEffect, useState } from "react" +import { readFile, writeFile } from "./api" +import { CodeEditor } from "./components/markdown/CodeEditor" +import { WrapText } from "lucide-react" + +function getStoredWordWrap(): boolean { + try { + const v = localStorage.getItem("loopat:editor:wordWrap") + if (v === "0") return false + } catch {} + return true +} + +export function Editor({ loopId, path, onSelectionChange }: { loopId: string; path: string | null; onSelectionChange?: (sel: { from: number; to: number } | null) => void }) { + const [original, setOriginal] = useState("") + const [draft, setDraft] = useState("") + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [wordWrap, setWordWrap] = useState(getStoredWordWrap) + + 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 ( + <div className="flex-1 min-h-0 flex items-center justify-center text-[13px] text-gray-500 px-8 text-center"> + 没打开文件 · 在 ▤ workdir 里点一个 + </div> + ) + } + + return ( + <> + <div + className="flex-1 min-h-0 relative" + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault() + save() + } + }} + > + {loading ? ( + <div className="h-full w-full flex items-center justify-center text-[12px] text-gray-400"> + loading… + </div> + ) : ( + <CodeEditor path={path} value={draft} onChange={setDraft} wordWrap={wordWrap} onSelectionChange={onSelectionChange} /> + )} + </div> + <div className="border-t border-gray-200 px-3 py-1.5 text-[11px] text-gray-500 flex items-center gap-3"> + <span className="truncate">{path}</span> + {dirty && ( + <button onClick={save} className="text-orange-600 hover:underline" title="ctrl/⌘+S"> + {saving ? "saving…" : "unsaved · save"} + </button> + )} + <span className="flex-1" /> + <button + onClick={() => { + const next = !wordWrap + setWordWrap(next) + try { localStorage.setItem("loopat:editor:wordWrap", next ? "1" : "0") } catch {} + }} + className={`flex items-center gap-1 hover:text-gray-700 transition-colors ${wordWrap ? "text-gray-500" : "text-gray-300"}`} + title={wordWrap ? "word wrap: on" : "word wrap: off"} + > + <WrapText size={13} /> + </button> + <span>utf-8 · LF</span> + </div> + </> + ) +} diff --git a/web/src/ErrorBoundary.tsx b/web/src/ErrorBoundary.tsx new file mode 100644 index 00000000..66861f7f --- /dev/null +++ b/web/src/ErrorBoundary.tsx @@ -0,0 +1,57 @@ +import React from "react" + +interface State { + error: Error | null +} + +/** + * Top-level error boundary. White-screens come from uncaught render errors + * in the React tree (a renderer hitting `null.foo`, a malformed assistant + * message, etc). This catches them and shows a recoverable fallback so the + * whole UI doesn't disappear. + */ +export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, State> { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + console.error("[ErrorBoundary]", error, info.componentStack) + } + + reset = (): void => { + this.setState({ error: null }) + } + + render(): React.ReactNode { + if (!this.state.error) return this.props.children + const e = this.state.error + return ( + <div className="m-4 flex flex-col gap-3 rounded-md border border-red-300 bg-red-50 p-4 text-sm text-red-900"> + <div className="font-medium">UI crashed — caught by error boundary</div> + <pre className="overflow-auto whitespace-pre-wrap break-words rounded bg-white/70 p-2 text-xs leading-relaxed"> + {e.name}: {e.message} + {e.stack ? `\n\n${e.stack}` : ""} + </pre> + <div className="flex gap-2"> + <button + type="button" + className="rounded bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700" + onClick={this.reset} + > + Try again + </button> + <button + type="button" + className="rounded border border-red-300 bg-white px-3 py-1 text-xs font-medium text-red-700 hover:bg-red-100" + onClick={() => window.location.reload()} + > + Reload page + </button> + </div> + </div> + ) + } +} diff --git a/web/src/FileTree.tsx b/web/src/FileTree.tsx new file mode 100644 index 00000000..58f78eb8 --- /dev/null +++ b/web/src/FileTree.tsx @@ -0,0 +1,247 @@ +/** + * File tree for loop workdir, using the generic Tree component. + */ +import { useEffect, useState, useRef, useCallback } from "react" +import { listFiles, uploadFile, writeFile, deleteWorkdirFile, createWorkdirFolder, type FileEntry } from "./api" +import { Tree, type TreeNodeData, type TreeContextAction, type TreeProps } from "./components/Tree" +import { Upload, Trash2, Eye, FilePlus, FolderPlus } from "lucide-react" + +const ROOTS: { name: string; section: "context" | "workdir"; emoji: string; hint?: string }[] = [ + { name: "context", section: "context", emoji: "🧷", hint: "knowledge / notes / personal" }, + { name: "workdir", section: "workdir", emoji: "▣" }, +] + +export interface FileTreeHandle { + triggerUpload: () => void +} + +export function FileTree({ + loopId, + onPick, + picked, +}: { + loopId: string + onPick: (path: string) => void + picked: string | null +}) { + const [reloadKey, setReloadKey] = useState(0) + const fileInputRef = useRef<HTMLInputElement>(null) + const [uploadTarget, setUploadTarget] = useState<string>("") + const [creating, setCreating] = useState<{ type: "file" | "folder"; path: string } | null>(null) + const [newName, setNewName] = useState("") + + const triggerUpload = useCallback((targetPath?: string) => { + setUploadTarget(targetPath ?? "workdir") + fileInputRef.current?.click() + }, []) + + const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0] + if (!file) return + const r = await uploadFile(loopId, file) + if (!r.ok) { alert(`upload failed: ${r.error}`) } + setReloadKey((k) => k + 1) + if (fileInputRef.current) fileInputRef.current.value = "" + setUploadTarget("") + } + + const handleCreate = async () => { + if (!creating || !newName.trim()) { setCreating(null); return } + const targetPath = creating.path + "/" + newName.trim() + let ok = false + if (creating.type === "file") { + ok = await writeFile(loopId, targetPath, "") + } else { + ok = await createWorkdirFolder(loopId, targetPath) + } + if (!ok) { alert("create failed"); setCreating(null); return } + setCreating(null) + setNewName("") + setReloadKey((k) => k + 1) + } + + const handleAction = useCallback((action: string, node: TreeNodeData) => { + if (action === "upload") { + triggerUpload(node.path) + } else if (action === "new-file" || action === "new-folder") { + setCreating({ type: action === "new-file" ? "file" : "folder", path: node.path }) + setNewName("") + } else if (action === "delete") { + if (!confirm(`Delete "${node.name}"?`)) return + deleteWorkdirFile(loopId, node.path).then((ok) => { + if (!ok) { alert("delete failed"); return } + setReloadKey((k) => k + 1) + }) + } + }, [triggerUpload, loopId]) + + const getContextActions = useCallback((node: TreeNodeData): TreeContextAction[] => { + if (node.type === "dir") { + return [ + { label: "Upload here", icon: <Upload size={12} />, action: "upload" }, + { label: "New file", icon: <FilePlus size={12} />, action: "new-file" }, + { label: "New folder", icon: <FolderPlus size={12} />, action: "new-folder" }, + { label: "Delete", icon: <Trash2 size={12} />, action: "delete", danger: true }, + ] + } + return [ + { label: "View", icon: <Eye size={12} />, action: "view" }, + { label: "Delete", icon: <Trash2 size={12} />, action: "delete", danger: true }, + ] + }, []) + + const handleLoadChildren = useCallback((path: string) => { + return listFiles(loopId, path).then((entries) => entries as TreeNodeData[]) + }, [loopId]) + + return ( + <aside className="flex-1 min-h-0 overflow-auto py-2 text-[13px]"> + <input + ref={fileInputRef} + type="file" + className="hidden" + onChange={handleUpload} + /> + {ROOTS.map((r) => ( + <SectionFolder + key={r.name + reloadKey} + loopId={loopId} + name={r.name} + section={r.section} + emoji={r.emoji} + hint={r.hint} + onPick={onPick} + picked={picked} + onUpload={() => triggerUpload(r.name)} + onReload={() => setReloadKey((k) => k + 1)} + reloadKey={reloadKey} + getContextActions={getContextActions} + onAction={handleAction} + treeId={`loop-${loopId}`} + /> + ))} + {creating && ( + <CreateInline + depth={1} + type={creating.type} + value={newName} + onChange={setNewName} + onSubmit={handleCreate} + onCancel={() => setCreating(null)} + /> + )} + </aside> + ) +} + +function SectionFolder({ + loopId, + name, + section, + emoji, + hint, + onPick, + picked, + onUpload, + onReload, + reloadKey, + getContextActions, + onAction, + treeId, +}: { + loopId: string + name: string + section: "context" | "workdir" + emoji: string + hint?: string + onPick: (path: string) => void + picked: string | null + onUpload: () => void + onReload: () => void + reloadKey: number + getContextActions: (node: TreeNodeData) => TreeContextAction[] + onAction: (action: string, node: TreeNodeData) => void + treeId: string +}) { + const [open, setOpen] = useState(true) + const sectionClass = + section === "context" + ? "w-full py-1.5 flex items-center gap-1.5 bg-cyan-50/50 hover:bg-cyan-50 text-left border-y border-cyan-100/70" + : "w-full py-1.5 flex items-center gap-1.5 bg-emerald-50/40 hover:bg-emerald-50 text-left border-y border-emerald-100/70" + + const handleLoadChildren = useCallback((path: string) => { + return listFiles(loopId, path).then((entries) => entries as TreeNodeData[]) + }, [loopId]) + + return ( + <> + <button + type="button" + onClick={() => setOpen((o) => !o)} + className={sectionClass} + style={{ paddingLeft: "0.5rem", paddingRight: "0.5rem" }} + > + <span className="text-gray-500">{open ? "▾" : "▸"}</span> + <span className="text-[12px]">{emoji}</span> + <span className="text-[11px] uppercase tracking-wider font-semibold text-gray-700">{name}</span> + {hint && <span className="text-[10px] text-gray-500 italic ml-1">{hint}</span>} + {section === "workdir" && ( + <> + <div className="flex-1" /> + <span + onClick={(e) => { e.stopPropagation(); onUpload() }} + className="text-gray-400 hover:text-gray-700 px-1 rounded hover:bg-emerald-100/50 flex items-center gap-0.5" + title="upload file" + > + <Upload size={12} /> + </span> + </> + )} + </button> + {open && ( + <Tree + treeId={`${treeId}-${name}`} + entries={[{ name, path: name, type: "dir" }]} + onPick={onPick} + picked={picked} + onLoadChildren={handleLoadChildren} + getContextActions={getContextActions} + onAction={onAction} + depthOffset={1} + reloadKey={reloadKey} + /> + )} + </> + ) +} + +function CreateInline({ depth, type, value, onChange, onSubmit, onCancel }: { + depth: number; + type: "file" | "folder"; + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + onCancel: () => void; +}) { + const ref = useRef<HTMLInputElement>(null) + useEffect(() => { ref.current?.focus() }, []) + + return ( + <div className="flex items-center gap-1 py-0.5" style={{ paddingLeft: 8 + depth * 12 }}> + <span className="text-gray-400">{type === "file" ? "📄" : "📁"}</span> + <input + ref={ref} + value={value} + onChange={(e) => onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) onSubmit() + if (e.key === "Escape") onCancel() + }} + placeholder={type === "file" ? "filename.txt" : "folder-name"} + className="flex-1 px-1.5 py-0.5 text-[12px] border border-gray-300 rounded outline-none focus:border-gray-900" + /> + <button onClick={onSubmit} className="text-[10px] text-emerald-600 hover:text-emerald-800 px-1">✓</button> + <button onClick={onCancel} className="text-[10px] text-gray-400 hover:text-gray-600 px-1">✕</button> + </div> + ) +} diff --git a/web/src/Terminal.tsx b/web/src/Terminal.tsx new file mode 100644 index 00000000..e7dba8cd --- /dev/null +++ b/web/src/Terminal.tsx @@ -0,0 +1,240 @@ +import { useEffect, useRef, useState } from "react" +import { Terminal as XTerm } from "@xterm/xterm" +import { FitAddon } from "@xterm/addon-fit" +import { WebglAddon } from "@xterm/addon-webgl" +import { SearchAddon } from "@xterm/addon-search" +import { ClipboardAddon } from "@xterm/addon-clipboard" +import { Unicode11Addon } from "@xterm/addon-unicode11" +import { LigaturesAddon } from "@xterm/addon-ligatures" +import "@xterm/xterm/css/xterm.css" + +type ConnStatus = "connected" | "reconnecting" | "disconnected" + +// One-Dark-ish theme. Calmer than xterm default; better contrast on bg. +const THEME = { + background: "#1a1c20", + foreground: "#dadbdc", + cursor: "#e1e4e8", + cursorAccent: "#1a1c20", + selectionBackground: "#3e4451", + black: "#3a3e44", + red: "#e06c75", + green: "#98c379", + yellow: "#e5c07b", + blue: "#61afef", + magenta: "#c678dd", + cyan: "#56b6c2", + white: "#dadbdc", + brightBlack: "#5c6370", + brightRed: "#e06c75", + brightGreen: "#98c379", + brightYellow: "#e5c07b", + brightBlue: "#61afef", + brightMagenta: "#c678dd", + brightCyan: "#56b6c2", + brightWhite: "#ffffff", +} + +const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000] + +function buildWsUrl(loopId: string, cols: number, rows: number) { + return `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws/loop/${loopId}/term?cols=${cols}&rows=${rows}` +} + +export function Terminal({ + loopId, + currentUserId, + onStatusChange, +}: { + loopId: string + currentUserId: string + onStatusChange?: (status: ConnStatus) => void +}) { + const containerRef = useRef<HTMLDivElement>(null) + const xtermRef = useRef<XTerm | null>(null) + const wsRef = useRef<WebSocket | null>(null) + const fitRef = useRef<FitAddon | null>(null) + const searchRef = useRef<SearchAddon | null>(null) + const reconnectRef = useRef({ attempt: 0, timer: null as ReturnType<typeof setTimeout> | null }) + const [status, setStatus] = useState<ConnStatus>("connected") + + function setStatusBoth(s: ConnStatus) { + setStatus(s) + onStatusChange?.(s) + } + + useEffect(() => { + if (!containerRef.current) return + + const term = new XTerm({ + cursorBlink: true, + cursorStyle: "block", + fontFamily: + '"JetBrains Mono", "Fira Code", "SF Mono", "Cascadia Code", Menlo, Consolas, "DejaVu Sans Mono", ui-monospace, monospace', + fontSize: 12.5, + lineHeight: 1.3, + letterSpacing: 0, + theme: THEME, + convertEol: true, + scrollback: 5000, + smoothScrollDuration: 80, + allowProposedApi: true, + drawBoldTextInBrightColors: true, + minimumContrastRatio: 4, + rightClickSelectsWord: true, + ignoreBracketedPasteMode: true, + }) + + const fit = new FitAddon() + const search = new SearchAddon() + + term.loadAddon(fit) + term.loadAddon(search) + + // GPU-accelerated renderer; falls back to DOM silently on failure + try { term.loadAddon(new WebglAddon()) } catch {} + try { term.loadAddon(new ClipboardAddon()) } catch {} + try { term.loadAddon(new Unicode11Addon()) } catch {} + try { term.loadAddon(new LigaturesAddon()) } catch {} + + term.open(containerRef.current) + // xterm.js creates an internal textarea for accessibility; give it a name + // for the browser's autofill/lighthouse audit. + const helper = containerRef.current.querySelector(".xterm-helper-textarea") as HTMLTextAreaElement | null + if (helper) helper.setAttribute("name", "terminal-input") + fit.fit() + term.focus() + + xtermRef.current = term + fitRef.current = fit + searchRef.current = search + + function connect() { + const ws = new WebSocket(buildWsUrl(loopId, term.cols, term.rows)) + wsRef.current = ws + + ws.onopen = () => { + reconnectRef.current.attempt = 0 + setStatusBoth("connected") + sendResize() + } + + ws.onmessage = (e) => { + try { + const m = JSON.parse(e.data) + if (m.type === "data") term.write(m.data) + else if (m.type === "exit") term.write(`\r\n\x1b[2m[process exited ${m.code}]\x1b[0m\r\n`) + else if (m.type === "error") term.write(`\r\n\x1b[31m[error] ${m.message}\x1b[0m\r\n`) + } catch {} + } + + ws.onclose = () => { + const { attempt } = reconnectRef.current + if (attempt < RECONNECT_DELAYS.length) { + const delay = RECONNECT_DELAYS[attempt] + reconnectRef.current.attempt = attempt + 1 + setStatusBoth("reconnecting") + term.write(`\r\n\x1b[33m[Disconnected, reconnecting in ${delay / 1000}s (${attempt + 1}/${RECONNECT_DELAYS.length})...]\x1b[0m\r\n`) + reconnectRef.current.timer = setTimeout(connect, delay) + } else { + setStatusBoth("disconnected") + term.write("\r\n\x1b[2m[Disconnected]\x1b[0m\r\n") + } + } + + ws.onerror = () => { + // onclose fires after onerror, reconnect logic lives in onclose + } + } + + const disposeData = term.onData((data) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "data", data })) + } + }) + + const sendResize = () => { + const ws = wsRef.current + if (!ws || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })) + } + + let connected = false + const onResize = () => { + try { + fit.fit() + if (!connected) { + if (term.cols < 10) return + connected = true + connect() + } else { + sendResize() + } + } catch {} + } + // One immediate check in case the container is already laid out. + onResize() + window.addEventListener("resize", onResize) + const ro = new ResizeObserver(onResize) + if (containerRef.current) ro.observe(containerRef.current) + + // Ctrl+Shift+F triggers search via prompt (SearchAddon has no built-in UI bar) + term.attachCustomKeyEventHandler((e) => { + if (e.ctrlKey && e.shiftKey && e.key === "F") { + const query = prompt("Terminal search:") + if (query && searchRef.current) { + searchRef.current.findNext(query) + } + return false + } + return true + }) + + return () => { + window.removeEventListener("resize", onResize) + ro.disconnect() + disposeData.dispose() + if (reconnectRef.current.timer) clearTimeout(reconnectRef.current.timer) + try { wsRef.current?.close() } catch {} + term.dispose() + xtermRef.current = null + fitRef.current = null + searchRef.current = null + wsRef.current = null + } + }, [loopId, currentUserId]) + + const dotColor = status === "connected" ? "#98c379" : status === "reconnecting" ? "#e5c07b" : "#e06c75" + + return ( + <div style={{ width: "100%", height: "100%", position: "relative" }}> + <div + ref={containerRef} + style={{ + width: "100%", + height: "100%", + background: THEME.background, + padding: "10px 12px 4px", + boxSizing: "border-box", + }} + /> + {/* connection indicator dot — overlaid top-right */} + <div + title={status === "connected" ? "Connected" : status === "reconnecting" ? "Reconnecting..." : "Disconnected"} + style={{ + position: "absolute", + top: 6, + right: 8, + width: 8, + height: 8, + borderRadius: "50%", + background: dotColor, + boxShadow: `0 0 4px ${dotColor}`, + transition: "background 0.3s, box-shadow 0.3s", + zIndex: 10, + pointerEvents: "none", + }} + /> + </div> + ) +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 00000000..6a2a79f4 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,2037 @@ +export type LoopMeta = { + id: string + title: string + createdAt: string + createdBy: string + /** Active driver. Use `loop.driver ?? loop.createdBy` everywhere "who is + * currently in charge" matters (filters, headers, button visibility). + * Non-drivers are read-only — server enforces the same way as `archived`. */ + driver?: string + /** Chronological log of driver assignments. First entry = creation + * (driver = createdBy). Used by ChatInterface to splice "driving by X + * since <ts>" markers into the agent chat timeline. */ + driverHistory?: Array<{ driver: string; since: string }> + /** RFD ("Request For Drive") state. When set, current driver released + * control: sandbox is torn down, and any authed user can claim via + * POST /api/loops/:id/drive. */ + rfdRequestedAt?: string + rfdRequestedBy?: string + repo?: string + branch?: string + /** Context-setup problems captured at loop creation (e.g. the per-user + * knowledge/notes clone failed). Shown as a warning banner in the loop view + * so a silently-empty context doesn't go unnoticed. */ + contextWarnings?: string[] + archived?: boolean + archivedAt?: string + /** If true, /share/:id is publicly viewable. Toggle via setLoopPublic. */ + public?: boolean + publicAt?: string + shareEnabled?: boolean + shareMode?: "static" | "port" | "ephemeral" + shareAlias?: string + sharePort?: number + shareExternalPort?: number + shareProtocol?: "tcp" | "udp" | "static" + config?: { + profiles?: string[] + vault?: string + [k: string]: unknown + } +} + +export type UserRole = "admin" | "member" +export type UserStatus = "active" | "pending" + +export type User = { + id: string + role: UserRole + status: UserStatus +} + +export type AdminUser = { + id: string + role: UserRole + status: UserStatus + personalRepo?: string + createdAt: string + activatedAt?: string +} + +const apiFetch: typeof fetch = (input, init) => + fetch(input, { credentials: "include", ...init }) + +let _workspaceCache: Promise<string> | null = null +/** Server's current workspace name, fetched once and memoized. */ +export function getServerWorkspace(): Promise<string> { + if (!_workspaceCache) { + _workspaceCache = apiFetch("/api/health") + .then((r) => r.json()) + .then((d) => (typeof d?.workspace === "string" ? d.workspace : "loopat")) + .catch(() => "loopat") + } + return _workspaceCache +} + +// ── auth ── + +export async function getMe(): Promise<User | null> { + const r = await apiFetch("/api/auth/me") + if (!r.ok) return null + const j = await r.json() + return (j.user as User) ?? null +} + +export async function login(username: string, password: string): Promise<{ user?: User; error?: string }> { + const r = await apiFetch("/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username, password }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { error: j.error ?? `login failed (${r.status})` } + return { user: j.user as User } +} + +export type RegisterResult = { + user?: User + /** ed25519 public key for the loopat-managed deploy keypair (server-generated). + * Null if ssh-keygen was missing on the host — register still succeeds, but + * the deploy-key import flow is unavailable until the host installs it. */ + publicKey?: string | null + /** Repo URL the user wants imported into personal/. Null if not provided. */ + personalRepo?: string | null + /** True iff user supplied a personalRepo AND publicKey was generated. The + * UI should walk them through the deploy-key + import step. */ + needsImport?: boolean + error?: string +} + +export async function register(input: { + username: string + password: string + personalRepo?: string +}): Promise<RegisterResult> { + const r = await apiFetch("/api/auth/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { error: j.error ?? `register failed (${r.status})` } + return { + user: j.user as User, + publicKey: j.publicKey, + personalRepo: j.personalRepo ?? null, + needsImport: !!j.needsImport, + } +} + +// ── personal repo bootstrap ── + +export type PersonalStatus = { + userId: string + personalRepo: string | null + /** Deploy key (host-secrets) — for the personal repo only. */ + publicKey: string | null + /** Per-vault SSH public keys — the keys a loop uses for TEAM repos + * (knowledge / notes / repos). One per vault; this is what to register on + * the team git host. */ + vaultKeys?: { vault: string; publicKey: string }[] + imported: boolean + gitHost?: { provider: string; baseUrl: string | null; defaultRepo?: string; tokenHelp?: string | null } +} + +// List the user's repos for the onboarding picker ("personal"-named first). +// `ok: false` means the token was rejected (or the request failed) — surface +// `error` instead of treating it as an empty list. +export async function listPersonalRepos( + token: string, +): Promise<{ ok: boolean; repos: { name: string; path: string }[]; login?: string; error?: string }> { + const r = await apiFetch("/api/personal/repos", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), + }) + const j = await r.json().catch(() => ({}) as any) + if (!r.ok) return { ok: false, repos: [], error: j?.error ?? "request failed" } + return { ok: j.ok !== false, repos: Array.isArray(j.repos) ? j.repos : [], login: j.login, error: j.error } +} + +export async function getPersonalStatus(): Promise<PersonalStatus | null> { + const r = await apiFetch("/api/personal/status") + if (!r.ok) return null + return (await r.json()) as PersonalStatus +} + +// Provider-driven onboarding. `gated:false` → no gate. Otherwise the app blocks +// on the onboarding screen until `done`. The provider fully owns the flow: when +// not done it returns the next FORM to render; the UI submits the values and +// re-renders whatever comes back. +export type OnboardingField = { + name: string + label: string + type?: "password" | "text" + help?: string + placeholder?: string + action: "vault-env" | "personal-repo-token" +} +export type OnboardingForm = { + title: string + description?: string + submitLabel?: string + require?: "all" | "any" + fields: OnboardingField[] +} +// The provider either reports done, or tells the UI what to show next: a form, +// or an existing loopat page (route) to send the user to. +export type OnboardingShow = + | ({ kind: "form" } & OnboardingForm) + | { kind: "route"; path: string; title?: string; description?: string } + | { + kind: "info" + title: string + description?: string + values?: { label: string; value: string }[] + help?: { label: string; url: string }[] + } +export type OnboardingStatus = + | { gated: boolean; done: true } + | { gated: boolean; done: false; show: OnboardingShow } + +export async function getOnboarding(): Promise<OnboardingStatus | null> { + const r = await apiFetch("/api/onboarding") + if (!r.ok) return null + return (await r.json()) as OnboardingStatus +} + +// Submit one onboarding form; returns the provider's next view (or an error). +export async function submitOnboarding( + values: Record<string, string>, +): Promise<OnboardingStatus | { error: string }> { + const r = await apiFetch("/api/onboarding/submit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ values }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { error: (j as any).error ?? `submit failed (${r.status})` } + return j as OnboardingStatus +} + +export async function exportPersonalCryptKey( + password: string, +): Promise< + { ok: true; cryptKey: string } | { ok: false; error: string; wrongPassword?: boolean } +> { + const r = await apiFetch("/api/personal/crypt-key", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) { + return { + ok: false, + error: j.error ?? `request failed (${r.status})`, + wrongPassword: r.status === 403, + } + } + if (typeof j.cryptKey !== "string") return { ok: false, error: "missing cryptKey in response" } + return { ok: true, cryptKey: j.cryptKey } +} + +export async function deletePersonalVault( + password: string, + force: boolean = false, +): Promise<{ + ok: boolean + error?: string + wrongPassword?: boolean + syncFailed?: boolean + synced?: boolean + dataLost?: boolean + uncommitted?: number + unpushed?: number + hasRemote?: boolean +}> { + const r = await apiFetch("/api/personal/delete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ password, force }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) { + return { + ok: false, + error: j.error ?? `delete failed (${r.status})`, + wrongPassword: r.status === 403, + syncFailed: j.syncFailed, + uncommitted: j.uncommitted, + unpushed: j.unpushed, + hasRemote: j.hasRemote, + } + } + return { ok: true, synced: j.synced, dataLost: j.dataLost } +} + +export async function pullPersonalVault(opts?: { force?: boolean }): Promise<{ + ok: boolean + error?: string + conflict?: boolean + files?: string[] + needsStash?: boolean + message?: string +}> { + const r = await apiFetch("/api/personal/pull", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ force: opts?.force ?? false }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) { + return { + ok: false, + error: j.error ?? `pull failed (${r.status})`, + conflict: j.conflict, + files: j.files, + needsStash: j.needsStash, + } + } + return { ok: true, message: j.message } +} + +export async function pushPersonalVault(): Promise<{ + ok: boolean + error?: string + conflict?: boolean + files?: string[] + needsPull?: boolean + message?: string +}> { + const r = await apiFetch("/api/personal/push", { method: "POST" }) + const j = await r.json().catch(() => ({})) + if (!r.ok) { + return { + ok: false, + error: j.error ?? `push failed (${r.status})`, + conflict: j.conflict, + files: j.files, + needsPull: j.needsPull, + } + } + return { ok: true, message: j.message } +} + +export async function importPersonal( + repoUrl?: string, + cryptKey?: string, +): Promise<{ + ok: boolean + error?: string + needsCryptKey?: boolean + notClean?: boolean + secretsExposed?: boolean + exposedFiles?: string[] + autoInitialized?: boolean + cryptKey?: string | null +}> { + const payload: Record<string, string> = {} + if (repoUrl) payload.repoUrl = repoUrl + if (cryptKey) payload.cryptKey = cryptKey + const r = await apiFetch("/api/personal/import", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) { + return { + ok: false, + error: j.error ?? `import failed (${r.status})`, + needsCryptKey: !!j.needsCryptKey, + notClean: !!j.notClean, + secretsExposed: !!j.secretsExposed, + exposedFiles: Array.isArray(j.exposedFiles) ? j.exposedFiles : [], + } + } + return { + ok: true, + autoInitialized: !!j.autoInitialized, + cryptKey: typeof j.cryptKey === "string" ? j.cryptKey : null, + } +} + +// Onboard personal via a GitHub PAT: loopat creates the repo, registers the +// deploy key, clones + handles git-crypt. The token is used host-side only. +export async function setupPersonalGithub( + token: string, + repoName?: string, + cryptKey?: string, + baseUrl?: string, +): Promise<{ + ok: boolean + error?: string + needsCryptKey?: boolean + repo?: string + created?: boolean + autoInitialized?: boolean + cryptKey?: string | null +}> { + const payload: Record<string, string> = { token } + if (repoName) payload.repoName = repoName + if (cryptKey) payload.cryptKey = cryptKey + if (baseUrl) payload.baseUrl = baseUrl + const r = await apiFetch("/api/personal/github", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) { + return { ok: false, error: j.error ?? `github setup failed (${r.status})`, needsCryptKey: !!j.needsCryptKey } + } + return { + ok: true, + repo: typeof j.repo === "string" ? j.repo : undefined, + created: !!j.created, + autoInitialized: !!j.autoInitialized, + cryptKey: typeof j.cryptKey === "string" ? j.cryptKey : null, + } +} + +export async function logout(): Promise<void> { + await apiFetch("/api/auth/logout", { method: "POST" }).catch(() => {}) +} + +// ── loops ── + +/** filter: "active" (default, archived hidden), "all", "archived" */ +export async function listLoops(filter: "active" | "all" | "archived" = "active"): Promise<LoopMeta[]> { + const q = filter === "all" ? "?archived=all" : filter === "archived" ? "?archived=true" : "" + const r = await apiFetch("/api/loops" + q) + if (!r.ok) return [] + const j = await r.json() + return j.loops as LoopMeta[] +} + +export async function createLoop(opts: { + title: string + repo?: string + /** Active profiles for this loop. Base is auto-included server-side. + * Empty/undefined = base + personal CLAUDE.md only, no plugins. */ + profiles?: string[] + vault?: string + /** Admin-only flags — still go via the internal `/api/loops` endpoint; + * v1 doesn't surface them. */ + knowledgeRw?: boolean + mountAllLoops?: boolean +}): Promise<LoopMeta> { + // Admin-flag path keeps using the internal endpoint (those flags aren't in v1). + if (opts.knowledgeRw || opts.mountAllLoops) { + const body: Record<string, unknown> = { + title: opts.title, + repo: opts.repo, + profiles: opts.profiles, + vault: opts.vault, + } + if (opts.knowledgeRw) body.knowledge_rw = true + if (opts.mountAllLoops) body.mount_all_loops = true + const r = await apiFetch("/api/loops", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + return (await r.json()) as LoopMeta + } + // Normal create → v1 API. Translate snake_case + loop_ prefix back to the + // web's LoopMeta shape so existing callers don't change. + const r = await apiFetch("/api/v1/loops", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: opts.title, + profiles: opts.profiles, + vault: opts.vault, + repo: opts.repo, + }), + }) + if (!r.ok) throw new Error(`createLoop failed (${r.status})`) + const v1 = await r.json() as { + id: string + title: string + created_at: string + created_by: string + archived: boolean + profiles: string[] + vault: string + repo: string | null + } + const rawId = v1.id.startsWith("loop_") ? v1.id.slice("loop_".length) : v1.id + return { + id: rawId, + title: v1.title, + createdAt: v1.created_at, + createdBy: v1.created_by, + archived: v1.archived, + repo: v1.repo ?? undefined, + config: { + profiles: v1.profiles, + vault: v1.vault, + }, + } as LoopMeta +} + +export type ProfileEntry = { name: string; description?: string } +export async function listProfiles(): Promise<ProfileEntry[]> { + const r = await apiFetch(`/api/profiles`) + if (!r.ok) return [] + const j = await r.json() + return (j.profiles ?? []) as ProfileEntry[] +} + +/** Current user's default_profiles from personal config (the diff baseline + * NewLoopDialog pre-checks). Empty array if config missing or field absent. */ +export async function getDefaultProfiles(): Promise<string[]> { + const r = await apiFetch(`/api/personal/default-profiles`) + if (!r.ok) return [] + const j = await r.json() + return Array.isArray(j.default_profiles) ? j.default_profiles : [] +} + +export type LoopStats = { + plugins: number + skills: number + agents: number + hooks: number + mcpServers: number + /** Toolchain tools (mise.toml [tools] entries) deduped across all tiers. */ + toolchain: number +} +/** Preview of what a loop with the given profile selection will contain. + * Team layer is always implicit. */ +export async function getLoopStats(profiles: string[]): Promise<LoopStats | null> { + const r = await apiFetch(`/api/loop-stats?profiles=${encodeURIComponent(profiles.join(","))}`) + if (!r.ok) return null + return (await r.json()) as LoopStats +} + +export async function listVaults(): Promise<string[]> { + const r = await apiFetch("/api/vaults") + if (!r.ok) return [] + const j = await r.json() + return Array.isArray(j.vaults) ? j.vaults : [] +} + +export async function markLoopViewed(id: string): Promise<boolean> { + const r = await apiFetch(`/api/loops/${id}/viewed`, { method: "POST" }) + return r.ok +} + +export async function setLoopArchived(id: string, archived: boolean): Promise<LoopMeta | null> { + const r = await apiFetch(`/api/loops/${id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ archived }), + }) + if (!r.ok) return null + return (await r.json()) as LoopMeta +} + +export async function setLoopPublic(id: string, isPublic: boolean): Promise<LoopMeta | null> { + const r = await apiFetch(`/api/loops/${id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ public: isPublic }), + }) + if (!r.ok) return null + return (await r.json()) as LoopMeta +} + +/** Rename the loop. Server allows only meta.createdBy to patch. */ +export async function setLoopTitle(id: string, title: string): Promise<LoopMeta | null> { + const r = await apiFetch(`/api/loops/${id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title }), + }) + if (!r.ok) return null + return (await r.json()) as LoopMeta +} + +/** Current driver releases control. Sandbox + PTY are torn down server-side; + * any authed user can then claim via takeDrive(). */ +export async function requestDrive(id: string): Promise<LoopMeta | null> { + const r = await apiFetch(`/api/loops/${id}/request-drive`, { method: "POST" }) + if (!r.ok) return null + return (await r.json()) as LoopMeta +} + +/** Take over a loop in RFD state. Sandbox respawns lazily on next message + * using the new driver's personal config. */ +export async function takeDrive(id: string): Promise<LoopMeta | null> { + const r = await apiFetch(`/api/loops/${id}/drive`, { method: "POST" }) + if (!r.ok) return null + return (await r.json()) as LoopMeta +} + +/** Spawn a distill child loop from `id`. Server seeds the child's workdir + * with a snapshot of the source's conversation files plus a distill-kind + * CLAUDE.md. Returns the new loop meta. Any authed user may call. */ +export async function distillLoop(id: string): Promise<LoopMeta | null> { + const r = await apiFetch(`/api/loops/${id}/distill`, { method: "POST" }) + if (!r.ok) return null + return (await r.json()) as LoopMeta +} + +export async function getLoopMeta(id: string): Promise<LoopMeta | null> { + const r = await apiFetch(`/api/loops/${id}`) + if (!r.ok) return null + return (await r.json()) as LoopMeta +} + +/** Strip thinking/redacted_thinking blocks from SDK history. Used before + * swapping to a provider that can't validate existing thinking signatures. */ +export async function stripThinkingBlocks(id: string): Promise<{ stripped: number; sessionsTouched: number }> { + const r = await apiFetch(`/api/loops/${id}/strip-thinking`, { method: "POST" }) + if (!r.ok) return { stripped: 0, sessionsTouched: 0 } + return (await r.json()) as { stripped: number; sessionsTouched: number } +} + +export type FileEntry = { + name: string + path: string + type: "file" | "dir" + size?: number +} + +export async function listFiles(loopId: string, path = ""): Promise<FileEntry[]> { + const r = await apiFetch(`/api/loops/${loopId}/files?path=${encodeURIComponent(path)}`) + const j = await r.json() + return (j.entries ?? []) as FileEntry[] +} + +/** Recursively list all files under a path in one call. */ +export async function listFilesTree(loopId: string, path = ""): Promise<FileEntry[]> { + const r = await apiFetch(`/api/loops/${loopId}/files/tree?path=${encodeURIComponent(path)}`) + const j = await r.json() + return (j.entries ?? []) as FileEntry[] +} + +export async function readFile(loopId: string, path: string): Promise<{ content: string; truncated: boolean; size: number } | null> { + const r = await apiFetch(`/api/loops/${loopId}/file?path=${encodeURIComponent(path)}`) + if (!r.ok) return null + return (await r.json()) as { content: string; truncated: boolean; size: number } +} + +export async function writeFile(loopId: string, path: string, content: string): Promise<boolean> { + const r = await apiFetch(`/api/loops/${loopId}/file?path=${encodeURIComponent(path)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content }), + }) + return r.ok +} + +export async function deleteWorkdirFile(loopId: string, path: string): Promise<boolean> { + const r = await apiFetch(`/api/loops/${loopId}/file?path=${encodeURIComponent(path)}`, { + method: "DELETE", + }) + return r.ok +} + +export async function createWorkdirFolder(loopId: string, path: string): Promise<boolean> { + const r = await apiFetch(`/api/loops/${loopId}/folder`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path }), + }) + return r.ok +} + +export async function uploadFile(loopId: string, file: File): Promise<{ ok: boolean; path?: string; error?: string }> { + const formData = new FormData() + formData.append("file", file) + const r = await apiFetch(`/api/loops/${loopId}/upload`, { + method: "POST", + body: formData, + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? "upload failed" } + return { ok: true, path: j.path } +} + +export type ContextMount = { name: string; path: string } +export async function getContext(loopId: string): Promise<ContextMount[]> { + const r = await apiFetch(`/api/loops/${loopId}/context`) + if (!r.ok) return [] + const j = await r.json() + return (j.mounts ?? []) as ContextMount[] +} + +export type VaultId = "knowledge" | "notes" | "personal" | "repos" +export type VaultEntry = { name: string; path: string; type: "file" | "dir"; size?: number } + +export async function vaultList(vault: VaultId, path = ""): Promise<VaultEntry[]> { + const r = await apiFetch(`/api/workspace/files?vault=${vault}&path=${encodeURIComponent(path)}`) + if (!r.ok) return [] + const j = await r.json() + return (j.entries ?? []) as VaultEntry[] +} + +export async function vaultFlatList(vault: VaultId): Promise<VaultEntry[]> { + const r = await apiFetch(`/api/workspace/files?vault=${vault}&flat=1`) + if (!r.ok) return [] + const j = await r.json() + return (j.entries ?? []) as VaultEntry[] +} + +export async function vaultRead( + vault: VaultId, + path: string, +): Promise<{ content: string; size: number; truncated: boolean; secret?: boolean } | null> { + const r = await apiFetch(`/api/workspace/file?vault=${vault}&path=${encodeURIComponent(path)}`) + if (!r.ok) return null + return (await r.json()) as { content: string; size: number; truncated: boolean; secret?: boolean } +} + +export async function vaultWrite(vault: VaultId, path: string, content: string): Promise<{ ok: boolean; commit?: string; error?: string }> { + const r = await apiFetch(`/api/workspace/file?vault=${vault}&path=${encodeURIComponent(path)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content }), + }) + return (await r.json()) as { ok: boolean; commit?: string; error?: string } +} + +// Save notes to the shared remote (the no-AI UI loop): commit + rebase onto +// origin/main + ff-push. A real conflict is held back (local edit kept). +export async function saveNotes(): Promise<{ + ok: boolean + conflict?: boolean + files?: string[] + needsPull?: boolean + error?: string + message?: string +}> { + const r = await apiFetch("/api/notes/save", { method: "POST" }) + const j = await r.json().catch(() => ({}) as any) + if (!r.ok) return { ok: false, conflict: j.conflict, files: j.files, needsPull: j.needsPull, error: j.error ?? `save failed (${r.status})` } + return { ok: true, message: j.message } +} + +// How many commits notes is behind origin (drives the "remote updated" hint). +export async function notesBehind(): Promise<number> { + const r = await apiFetch("/api/notes/behind") + const j = await r.json().catch(() => ({}) as any) + return typeof j.behind === "number" ? j.behind : 0 +} + +// Refresh = ff-pull origin into the notes worktree. `diverged` means you have +// unsaved local edits — the client keeps its draft and just re-reads. +export async function refreshNotes(): Promise<{ ok: boolean; diverged?: boolean; error?: string }> { + const r = await apiFetch("/api/notes/refresh", { method: "POST" }) + const j = await r.json().catch(() => ({}) as any) + return { ok: !!j.ok, diverged: j.diverged, error: j.error } +} + +export async function vaultCreateFile(vault: VaultId, path: string): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/workspace/file?vault=${vault}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path }), + }) + return (await r.json()) as { ok: boolean; error?: string } +} + +export async function vaultCreateFolder(vault: VaultId, path: string): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/workspace/folder?vault=${vault}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path }), + }) + return (await r.json()) as { ok: boolean; error?: string } +} + +export async function vaultDeleteFile(vault: VaultId, path: string): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/workspace/file?vault=${vault}&path=${encodeURIComponent(path)}`, { + method: "DELETE", + }) + return (await r.json()) as { ok: boolean; error?: string } +} + +export type Backlink = { path: string; preview: string } +export async function vaultBacklinks(vault: VaultId, path: string): Promise<Backlink[]> { + const r = await apiFetch(`/api/workspace/backlinks?vault=${vault}&path=${encodeURIComponent(path)}`) + if (!r.ok) return [] + const j = await r.json() + return (j.backlinks ?? []) as Backlink[] +} + +// Context repos roster — PERSONAL: lives in the user's own +// personal/<user>/.loopat/config.json. Physical clones stay on-demand at loop +// creation. Edited via the /context/repos page. +export type ContextRepoSpec = { name: string; git: string } +export type ContextRepoRoster = { repos: ContextRepoSpec[] } + +export async function getContextRepos(): Promise<ContextRepoRoster> { + const r = await apiFetch(`/api/context/repos`) + if (!r.ok) return { repos: [] } + const j = await r.json() + return { repos: (j.repos ?? []) as ContextRepoSpec[] } +} + +export async function putContextRepos(roster: ContextRepoRoster): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/context/repos`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(roster), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `http ${r.status}` } + return { ok: true } +} + +// Sandbox API client removed — sandbox concept replaced by profiles +// (composition model). Profile editing now happens via filesystem edits to +// knowledge/.loopat/profiles/<n>/.claude/; the matching backend endpoints +// were deleted in the profile refactor. + +export async function getChatHistory(loopId: string): Promise<string[]> { + const r = await apiFetch(`/api/loops/${loopId}/chat-history`) + if (!r.ok) return [] + const entries = await r.json() + return entries.map((e: any) => e.text) +} + +export async function appendChatHistory(loopId: string, text: string): Promise<void> { + await apiFetch(`/api/loops/${loopId}/chat-history`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text }), + }) +} + +// ── Unified workspace-resource sync (knowledge / notes / repos) ── + +export type SyncResource = "knowledge" | "notes" | { repo: string } + +export type RepoSyncStatus = { + isGitRepo: boolean + hasRemote: boolean + branch: string + ahead: number + behind: number + uncommitted: number +} + +function syncBase(r: SyncResource): string { + if (typeof r === "string") return `/api/sync/${r}` + return `/api/sync/repos/${encodeURIComponent(r.repo)}` +} + +export async function syncStatus(r: SyncResource): Promise<RepoSyncStatus | null> { + const res = await apiFetch(`${syncBase(r)}/status`) + if (!res.ok) return null + return (await res.json()) as RepoSyncStatus +} + +export async function syncPull(r: SyncResource): Promise<{ ok: boolean; message?: string; error?: string }> { + const res = await apiFetch(`${syncBase(r)}/pull`, { method: "POST" }) + const j = await res.json().catch(() => ({})) + if (!res.ok) return { ok: false, error: j.error ?? `http ${res.status}` } + return { ok: true, message: j.message } +} + +export async function syncPush(r: SyncResource): Promise<{ ok: boolean; message?: string; error?: string }> { + const res = await apiFetch(`${syncBase(r)}/push`, { method: "POST" }) + const j = await res.json().catch(() => ({})) + if (!res.ok) return { ok: false, error: j.error ?? `http ${res.status}` } + return { ok: true, message: j.message } +} + +// ── kanban ── +// Storage in notes/todo/<filename>.md, one file per column. +// Cards are top-level - [ ] bullet items within each file. +// See server/src/kanban.ts for the parsing model. + +export type KanbanCard = { + cid: string + text: string + done: boolean + assignee?: string + priority?: string + due?: string + loopId?: string + topics: string[] + description: string + subtasks: { text: string; done: boolean }[] +} + +export type KanbanColumn = { + filename: string + title: string + cards: KanbanCard[] +} + +export async function listKanbanColumns(board = "default"): Promise<KanbanColumn[]> { + const r = await apiFetch(`/api/kanban/${encodeURIComponent(board)}`) + if (!r.ok) return [] + const j = await r.json() + return j.columns as KanbanColumn[] +} + +export async function addKanbanCard(board: string, filename: string, opts: { + text: string; assignee?: string; priority?: string; due?: string + topics?: string[]; description?: string +}): Promise<{ cid?: string; error?: string }> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(filename)}/cards`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(opts), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { error: j.error ?? "add failed" } + return { cid: j.cid } +} + +export async function toggleKanbanCard(board: string, filename: string, cid: string): Promise<boolean> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(filename)}/cards/${encodeURIComponent(cid)}/toggle`, { + method: "PATCH", + }) + return r.ok +} + +export async function updateKanbanCard(board: string, filename: string, cid: string, patch: { + text?: string; assignee?: string; priority?: string; due?: string +}): Promise<boolean> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(filename)}/cards/${encodeURIComponent(cid)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }) + return r.ok +} + +export async function updateKanbanCardBlock(board: string, filename: string, cid: string, block: string): Promise<boolean> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(filename)}/cards/${encodeURIComponent(cid)}/block`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ block }), + }) + return r.ok +} + +export async function deleteKanbanCard(board: string, filename: string, cid: string): Promise<boolean> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(filename)}/cards/${encodeURIComponent(cid)}`, { + method: "DELETE", + }) + return r.ok +} + +export async function moveKanbanCard(board: string, fromFile: string, cid: string, toFile: string, toIndex?: number): Promise<boolean> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(fromFile)}/cards/${encodeURIComponent(cid)}/move`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ toFile, toIndex }), + }) + return r.ok +} + +export async function createKanbanColumn(board: string, filename: string, title?: string): Promise<boolean> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ filename, title }), + }) + return r.ok +} + +// ── board management ── + +export async function listBoards(): Promise<string[]> { + const r = await apiFetch("/api/kanban/boards") + if (!r.ok) return ["default"] + const j = await r.json() + return j.boards as string[] +} + +export async function createBoard(name: string): Promise<boolean> { + const r = await apiFetch("/api/kanban/boards", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name }), + }) + return r.ok +} + +export async function renameBoard(oldName: string, newName: string): Promise<boolean> { + const r = await apiFetch(`/api/kanban/boards/${encodeURIComponent(oldName)}/rename`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: newName }), + }) + return r.ok +} + +// ── column config ── + +export type KanbanColumnConfig = { file: string; color?: string } + +export async function getKanbanConfig(board = "default"): Promise<KanbanColumnConfig[]> { + const r = await apiFetch(`/api/kanban/config/${encodeURIComponent(board)}`) + if (!r.ok) return [] + const j = await r.json() + return j.columns as KanbanColumnConfig[] +} + +export async function saveKanbanColumnOrder(board: string, orderedFiles: string[]): Promise<boolean> { + const r = await apiFetch(`/api/kanban/config/${encodeURIComponent(board)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ columns: orderedFiles }), + }) + return r.ok +} + +export async function renameKanbanColumn(board: string, fromFile: string, toFile: string): Promise<boolean> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(fromFile)}/rename`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ toFile }), + }) + return r.ok +} + +export async function deleteKanbanColumn(board: string, filename: string): Promise<boolean> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(filename)}`, { method: "DELETE" }) + return r.ok +} + +export async function setKanbanColumnColor(board: string, filename: string, color: string): Promise<boolean> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(filename)}/color`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ color }), + }) + return r.ok +} + +export async function reorderKanbanCards(board: string, filename: string, cids: string[]): Promise<boolean> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(filename)}/reorder`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ cids }), + }) + return r.ok +} + +export async function assignKanbanDriver(board: string, filename: string, cid: string): Promise<{ ok: boolean; loopId?: string }> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(filename)}/cards/${encodeURIComponent(cid)}/assign-driver`, { + method: "POST", + }) + return r.ok ? await r.json() : { ok: false } +} + +export async function createKanbanLoop(board: string, filename: string, cid: string): Promise<{ ok: boolean; loopId?: string }> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(filename)}/cards/${encodeURIComponent(cid)}/create-loop`, { + method: "POST", + }) + if (!r.ok) return { ok: false } + return await r.json() +} + +export async function linkKanbanLoop(board: string, filename: string, cid: string, loopId: string): Promise<boolean> { + const r = await apiFetch(`/api/kanban/columns/${encodeURIComponent(board)}/${encodeURIComponent(filename)}/cards/${encodeURIComponent(cid)}/link-loop`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ loopId }), + }) + return r.ok +} + +export type TopicAggregate = { + name: string + loops: { id: string; title: string }[] +} + +export async function listTopics(): Promise<TopicAggregate[]> { + const r = await apiFetch("/api/topics") + if (!r.ok) return [] + const j = await r.json() + return j.topics as TopicAggregate[] +} + +export type ModelEntry = { id: string; enabled?: boolean; maxContextTokens?: number } +export type ProviderInfo = { model?: string; models: ModelEntry[]; baseUrl: string; source: "personal" | "workspace"; enabled: boolean; hasKey: boolean } +export type ProvidersResponse = { providers: Record<string, ProviderInfo>; default: string } +export async function getProviders(): Promise<ProvidersResponse> { + const r = await apiFetch("/api/providers") + if (!r.ok) return { providers: {}, default: "" } + return (await r.json()) as ProvidersResponse +} + +export async function testProviderConnection( + baseUrl: string, + apiKey: string, + model: string, + provider?: string, + source?: "personal" | "workspace", +): Promise<{ ok: boolean; error?: string }> { + const body: Record<string, string> = { baseUrl, model } + if (apiKey) { + body.apiKey = apiKey + } else if (provider && source) { + body.provider = provider + body.source = source + } + const r = await apiFetch("/api/providers/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `test failed (${r.status})` } + return j as { ok: boolean; error?: string } +} + +export type VersionInfo = { branch: string; commit: string } +export async function getVersion(): Promise<VersionInfo> { + const r = await apiFetch("/api/version") + if (!r.ok) return { branch: "unknown", commit: "unknown" } + return (await r.json()) as VersionInfo +} + +// ── admin platform (/admin/system) ── + +export type AdminActiveLoop = { + id: string + title: string + driver: string + wsCount: number + generating: boolean + /** Seconds since last user message; -1 if no messages.jsonl. */ + lastMsgAgeSec: number +} + +export type AdminSystemInfo = { + version: { + branch: string + commit: string + behindBy: number + latestCommit: string | null + latestMessage: string | null + } + activity: { + activeLoops: number + activeUsers: number + totalWs: number + totalGenerating: number + loops: AdminActiveLoop[] + } +} + +export async function getAdminSystem(): Promise<AdminSystemInfo | null> { + const r = await apiFetch("/api/admin/system") + if (!r.ok) return null + return (await r.json()) as AdminSystemInfo +} + +export async function adminCheckForUpdates(): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch("/api/admin/system/check", { method: "POST" }) + return (await r.json()) as { ok: boolean; error?: string } +} + +export async function adminPull(): Promise<{ + ok: boolean + pulled?: boolean + oldHead?: string + newHead?: string + message?: string + error?: string +}> { + const r = await apiFetch("/api/admin/system/pull", { method: "POST" }) + return (await r.json()) as any +} + +declare const __BUILD_COMMIT__: string +declare const __BUILD_TIME__: string +export function getBuildInfo() { + return { commit: __BUILD_COMMIT__, time: __BUILD_TIME__ } +} + +// ── git (workdir) ── + +export type GitFileInfo = { + path: string + status: "A" | "M" | "D" | "R" | "?" + additions: number + deletions: number + isBinary: boolean +} + +export type GitStatus = { + unstaged: GitFileInfo[] + staged: GitFileInfo[] +} + +export async function getGitStatus(loopId: string): Promise<GitStatus> { + const r = await apiFetch(`/api/loops/${loopId}/git-status`) + if (!r.ok) return { unstaged: [], staged: [] } + return (await r.json()) as GitStatus +} + +export async function getGitDiff(loopId: string, path: string, staged: boolean): Promise<string | null> { + const r = await apiFetch(`/api/loops/${loopId}/git-diff?path=${encodeURIComponent(path)}&staged=${staged ? "1" : "0"}`) + if (!r.ok) return null + const j = await r.json() + return j.diff as string ?? null +} + +export async function gitStageFiles(loopId: string, files: string[], unstage: boolean = false): Promise<boolean> { + const r = await apiFetch(`/api/loops/${loopId}/git-stage`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ files, unstage }), + }) + return r.ok +} + +export async function gitDiscardFile(loopId: string, file: string): Promise<boolean> { + const r = await apiFetch(`/api/loops/${loopId}/git-discard`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ file }), + }) + return r.ok +} + +export async function gitCommit(loopId: string, message: string): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/loops/${loopId}/git-commit`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? "commit failed" } + return { ok: true } +} + +export type GitCommit = { + hash: string + shortHash: string + subject: string + author: string + date: string + parentHashes: string[] + branch: string | null + branches: string[] + tags: string[] +} + +export async function getGitLog(loopId: string, limit = 50): Promise<GitCommit[]> { + const r = await apiFetch(`/api/loops/${loopId}/git-log?limit=${limit}`) + if (!r.ok) return [] + const j = await r.json() + return (j.commits ?? []) as GitCommit[] +} + +// ── settings ── + +export type SettingsProvider = { + model?: string + models: ModelEntry[] + baseUrl: string + hasKey?: boolean + enabled: boolean + maxContextTokens?: number + apiKey?: string +} + +export type TokenUsage = Record<string, { inputTokens: number; outputTokens: number }> + +export type PersonalSettings = { + providers: Record<string, SettingsProvider> + default: string + tokenUsage: TokenUsage +} + +export type WorkspaceSettings = { + providers: Record<string, { models: ModelEntry[]; baseUrl: string; hasKey: boolean; enabled: boolean }> + default: string + tokenUsage: TokenUsage +} + +export async function getPersonalSettings(): Promise<PersonalSettings> { + const r = await apiFetch("/api/settings/personal") + return (await r.json()) as PersonalSettings +} + +export async function updatePersonalSettings(patch: { + providers?: Record<string, { model: string; baseUrl: string; apiKey?: string; maxContextTokens?: number }> + default?: string +}): Promise<boolean> { + const r = await apiFetch("/api/settings/personal", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }) + return r.ok +} + +// ── disk-shape settings (rich Settings page) ── + +export type ProviderDisk = { + model?: string + models?: ModelEntry[] + baseUrl: string + /** Plain string; may contain `${VAR}` references resolved against vault envs/ at load. */ + apiKey?: string + maxContextTokens?: number + enabled?: boolean +} + +export type PersonalConfigDisk = { + /** Mixed map: "default" key carries a string; other keys carry ProviderDisk. */ + providers: Record<string, ProviderDisk | string> + shell?: string +} + +/** + * For each `providers.<name>.apiKey` ref the backend reports: + * - kind: "literal" | "var" | "mixed" | "empty" + * - exists: whether the vault env file referenced by `${VAR}` exists + * - varName: the `${VAR}` name (only when kind === "var") + */ +export type RefExistsMap = Record<string, { kind: string; exists: boolean; varName?: string }> + +export async function getPersonalDisk(): Promise<{ disk: PersonalConfigDisk; refExists: RefExistsMap } | null> { + const r = await apiFetch("/api/settings/personal/disk") + if (!r.ok) return null + return (await r.json()) as { disk: PersonalConfigDisk; refExists: RefExistsMap } +} + +export async function savePersonalDisk(patch: Partial<PersonalConfigDisk>): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch("/api/settings/personal/disk", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `save failed (${r.status})` } + return { ok: true } +} + +/** + * Write a value to a vault env file. `name` is the env var name (e.g. + * "ANTHROPIC_API_KEY"); `vault` defaults to "default". The next personal + * config load picks up the new value via `${VAR}` substitution in apiKey. + */ +export async function writeVaultEnv(name: string, value: string, vault: string = "default"): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch("/api/settings/personal/value", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name, value, vault }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `write failed (${r.status})` } + return { ok: true } +} + +export async function getWorkspaceSettings(): Promise<WorkspaceSettings> { + const r = await apiFetch("/api/settings/workspace") + return (await r.json()) as WorkspaceSettings +} + +export async function updateWorkspaceSettings(patch: { + providers?: Record<string, { model?: string; models?: ModelEntry[]; baseUrl: string; apiKey?: string; enabled?: boolean }> + default?: string +}): Promise<boolean> { + const r = await apiFetch("/api/settings/workspace", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }) + return r.ok +} + +export type DailyUsage = Record<string, Record<string, { inputTokens: number; outputTokens: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number }>> + +export async function getDailyTokenUsage(): Promise<DailyUsage> { + const r = await apiFetch("/api/settings/token-usage/daily") + if (!r.ok) return {} + return (await r.json()) as DailyUsage +} + +export type LoopTokenUsage = { + loopId: string + title: string + model: string + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + lastActivity: string +} + +export async function getLoopTokenUsage(): Promise<LoopTokenUsage[]> { + const r = await apiFetch("/api/settings/token-usage/loops") + if (!r.ok) return [] + return (await r.json()) as LoopTokenUsage[] +} + +// ── admin presets ── + +export type ProviderPreset = { + name: string + baseUrl: string + models: string[] +} + +export type MiseToolPreset = { + name: string + suggestedVersion: string + description?: string + backend?: string +} + +export type PresetsData = { + providerPresets: ProviderPreset[] + miseToolPresets: MiseToolPreset[] +} + +export async function getAdminPresets(): Promise<PresetsData> { + const r = await apiFetch("/api/admin/presets") + if (!r.ok) return { providerPresets: [], miseToolPresets: [] } + return (await r.json()) as PresetsData +} + +export async function updateAdminPresets(presets: PresetsData): Promise<boolean> { + const r = await apiFetch("/api/admin/presets", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(presets), + }) + return r.ok +} + +// ── admin ── + +export async function listAdminUsers(): Promise<AdminUser[]> { + const r = await apiFetch("/api/admin/users") + if (!r.ok) return [] + const j = await r.json() + return (j.users as AdminUser[]) ?? [] +} + +export async function activateAdminUser(id: string): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/admin/users/${encodeURIComponent(id)}/activate`, { method: "POST" }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `activate failed (${r.status})` } + return { ok: true } +} + +export async function setAdminUserRole(id: string, role: UserRole): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/admin/users/${encodeURIComponent(id)}/role`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ role }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `role change failed (${r.status})` } + return { ok: true } +} + +export async function deleteAdminUser(id: string): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/admin/users/${encodeURIComponent(id)}`, { method: "DELETE" }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `delete failed (${r.status})` } + return { ok: true } +} + +// ── workspace serve ── + +export type ServeConfig = { + // Standard serve + serveEnabled: boolean + domain: string + ip: string + baseUrl: string + withPort: boolean + https: boolean + displayPort: number + // Dynamic port + serveDynamicEnabled: boolean + serveDynamicDomain: string + serveDynamicPortRange: string + serveDynamicUdpEnabled: boolean + serveDynamicStaticEnabled: boolean + // Ephemeral port + serveEphemeralEnabled: boolean + serveEphemeralDomain: string +} + +export async function getServeConfig(): Promise<ServeConfig> { + const r = await apiFetch("/api/serve/config") + if (!r.ok) return { + serveEnabled: true, domain: "nip.io", ip: "127.0.0.1", baseUrl: ".127.0.0.1.nip.io", + withPort: false, https: false, displayPort: 7788, + serveDynamicEnabled: false, serveDynamicDomain: "", serveDynamicPortRange: "10000-20000", + serveDynamicUdpEnabled: false, serveDynamicStaticEnabled: false, + serveEphemeralEnabled: false, serveEphemeralDomain: "", + } + return (await r.json()) as ServeConfig +} + +/** Read the live host port for a loop's ephemeral share. Returns null if + * the loop isn't in ephemeral mode, or the container isn't running yet. */ +export async function getCurrentSharePort(loopId: string): Promise<{ port: number | null; internalPort?: number; protocol?: "tcp" | "udp" }> { + const r = await apiFetch(`/api/loops/${loopId}/share/current-port`) + if (!r.ok) return { port: null } + return (await r.json()) as { port: number | null; internalPort?: number; protocol?: "tcp" | "udp" } +} + +export async function setServeConfig(data: Record<string, unknown>): Promise<boolean> { + const r = await apiFetch("/api/serve/config", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(data), + }) + return r.ok +} + +export async function getAvailablePort(): Promise<{ port: number | null; error?: string }> { + const r = await apiFetch("/api/serve/available-port") + if (!r.ok) return { port: null, error: "request failed" } + return (await r.json()) as { port: number | null; error?: string } +} + +export async function checkPortAvailable(port: number, loopId?: string): Promise<{ available: boolean; reason?: string }> { + const r = await apiFetch(`/api/serve/check-port?port=${port}&loopId=${loopId || ""}`) + if (!r.ok) return { available: false, reason: "request failed" } + return (await r.json()) as { available: boolean; reason?: string } +} + +export async function checkAliasAvailable(alias: string, loopId?: string): Promise<{ available: boolean; reason?: string }> { + const params = new URLSearchParams({ alias }) + if (loopId) params.set("loopId", loopId) + const r = await apiFetch(`/api/serve/alias-check?${params}`) + if (!r.ok) return { available: false, reason: "check failed" } + return (await r.json()) as { available: boolean; reason?: string } +} + +// ── chat ── + +export type ChatConvKind = "channel" | "dm" + +export type ChatConversation = { + id: string + kind: ChatConvKind + name: string | null + topic: string | null + createdBy: string + createdAt: number + dmUserA: string | null + dmUserB: string | null + unread: number + lastMessageTs: number | null + peerUserId: string | null +} + +export type ChatMessage = { + id: number + convId: string + author: string + text: string + ts: number + /** NULL = thread root; otherwise the root msg id this reply belongs to. */ + parentId: number | null +} + +/** Thread root with denormalized reply stats. Returned by listChatMessages + * (main feed). UI uses replyCount to render the "💬 N replies" affordance. */ +export type ChatThreadRoot = ChatMessage & { + replyCount: number + lastReplyTs: number | null +} + +export type ChatWorkspaceUser = { + id: string + role: "admin" | "member" + isMe: boolean +} + +export async function listChatConversations(): Promise<ChatConversation[]> { + const r = await apiFetch("/api/chat/conversations") + if (!r.ok) return [] + const j = await r.json() + return (j.conversations as ChatConversation[]) ?? [] +} + +export async function listChatUsers(): Promise<ChatWorkspaceUser[]> { + const r = await apiFetch("/api/chat/users") + if (!r.ok) return [] + const j = await r.json() + return (j.users as ChatWorkspaceUser[]) ?? [] +} + +export async function createChatChannel( + name: string, + topic?: string, +): Promise<{ conv?: ChatConversation; error?: string }> { + const r = await apiFetch("/api/chat/channels", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name, topic }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { error: j.error ?? `create failed (${r.status})` } + return { conv: j.conv as ChatConversation } +} + +export async function deleteChatChannel(id: string): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/chat/channels/${encodeURIComponent(id)}`, { method: "DELETE" }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `delete failed (${r.status})` } + return { ok: true } +} + +export async function openChatDm(username: string): Promise<{ conv?: ChatConversation; error?: string }> { + const r = await apiFetch(`/api/chat/dm/${encodeURIComponent(username)}`, { method: "POST" }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { error: j.error ?? `open DM failed (${r.status})` } + return { conv: j.conv as ChatConversation } +} + +export async function listChatMessages( + convId: string, + opts: { before?: number; limit?: number } = {}, +): Promise<ChatThreadRoot[]> { + const q = new URLSearchParams() + if (opts.before) q.set("before", String(opts.before)) + if (opts.limit) q.set("limit", String(opts.limit)) + const r = await apiFetch(`/api/chat/conversations/${encodeURIComponent(convId)}/messages?${q.toString()}`) + if (!r.ok) return [] + const j = await r.json() + return (j.messages as ChatThreadRoot[]) ?? [] +} + +/** Fetch a thread (root + replies). Used when opening the ThreadPanel. */ +export async function getChatThread( + rootId: number, +): Promise<{ root: ChatMessage; replies: ChatMessage[] } | null> { + const r = await apiFetch(`/api/chat/threads/${rootId}`) + if (!r.ok) return null + return (await r.json()) as { root: ChatMessage; replies: ChatMessage[] } +} + +export async function sendChatMessage( + convId: string, + text: string, + parentId: number | null = null, +): Promise<{ message?: ChatMessage; error?: string }> { + const r = await apiFetch(`/api/chat/conversations/${encodeURIComponent(convId)}/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(parentId != null ? { text, parentId } : { text }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { error: j.error ?? `send failed (${r.status})` } + return { message: j.message as ChatMessage } +} + +export async function markChatRead(convId: string, lastReadId: number): Promise<boolean> { + const r = await apiFetch(`/api/chat/conversations/${encodeURIComponent(convId)}/read`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ lastReadId }), + }) + return r.ok +} + +/** Spawn a loop seeded from a thread (root + replies). The thread is the + * natural semantic unit for AI seeding — works even for a brand-new top- + * level message with no replies (snapshot of 1 line). */ +export async function spawnLoopFromThread( + rootId: number, + title?: string, +): Promise<{ loopId?: string; seedPrompt?: string; messageCount?: number; error?: string }> { + const r = await apiFetch(`/api/chat/threads/${rootId}/spawn-loop`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(title ? { title } : {}), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { error: j.error ?? `spawn failed (${r.status})` } + return { loopId: j.loopId, seedPrompt: j.seedPrompt, messageCount: j.messageCount } +} + +// ── MCP servers ── + +/** + * MCP server, as returned by /api/mcp-servers. The list is derived from the + * loop's merged settings.json (team + profile + personal + plugin defaults). + * + * `authed` is a pure existence check on the env file named by `authTokenEnv` + * in the user's personal default vault — it does NOT validate the token + * (no expiry check, no probe). Click "Re-authorize" anytime to refresh it. + */ +export type McpServerEntry = { + name: string + type: "http" | "sse" | "stdio" + url?: string + /** Env var name parsed from `Authorization: Bearer ${VAR}` in headers. + * null when the server doesn't use a Bearer-template (stdio servers, + * static-keyed servers, non-Bearer auth schemes). */ + authTokenEnv: string | null + /** Every env var the server references via `${VAR}` (url + headers). */ + requiredEnvs?: string[] + /** True iff all of the server's referenced env vars are set in the vault + * (generalizes the Bearer-token case). */ + authed: boolean + /** Inline `x-loopat-resource`: a page where the user obtains their + * credentials (drives the "paste your MCP URL" setup flow). */ + setupResource?: string + /** OAuth capability probe result. dcr=loopat can auto-auth; manual=admin + * must register an app (loopat can't); none=no OAuth (server is public or + * uses non-OAuth auth); unreachable=probe failed. */ + oauthSupport?: "dcr" | "manual" | "none" | "unreachable" +} + +/** Parse a pasted concrete MCP URL against the server's ${VAR} template and + * store the extracted secrets in the vault. */ +export async function parseMcpSetup( + server: string, + pastedUrl: string, + loopId?: string, +): Promise<{ ok: true; set: string[] } | { ok: false; error: string }> { + const r = await apiFetch("/api/mcp-setup/parse", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ server, pastedUrl, loopId }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: (j as any).error ?? `failed (${r.status})` } + return { ok: true, set: (j as any).set ?? [] } +} + +// ── A2A (per-user agent) settings ───────────────────────────────────────── +export type A2ASettings = { + card: { name: string; description: string } + profiles: string[] + vault: string + cardUrl: string + endpoint: string + hasKey: boolean + availableProfiles: string[] + availableVaults: string[] +} +export async function getA2A(): Promise<A2ASettings | null> { + const r = await apiFetch("/api/a2a") + if (!r.ok) return null + return (await r.json()) as A2ASettings +} +export async function saveA2A(patch: { + card?: { name?: string; description?: string } + profiles?: string[] + vault?: string +}): Promise<boolean> { + const r = await apiFetch("/api/a2a", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }) + return r.ok +} +export async function regenA2AKey(): Promise<string | null> { + const r = await apiFetch("/api/a2a/key", { method: "POST" }) + if (!r.ok) return null + const j = await r.json().catch(() => ({})) + return (j as any).token ?? null +} + +export type McpServerInventory = { servers: McpServerEntry[] } + +export async function reprobeMcpServers(url?: string): Promise<void> { + await apiFetch("/api/mcp-servers/reprobe", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(url ? { url } : {}), + }) +} + +export async function listMcpServers(loopId?: string): Promise<McpServerInventory> { + const q = loopId ? `?loopId=${encodeURIComponent(loopId)}` : "" + const r = await apiFetch(`/api/mcp-servers${q}`) + if (!r.ok) return { servers: [] } + return (await r.json()) as McpServerInventory +} + +/** Begin OAuth flow for an MCP server visible in the loop's merged settings. + * Returns the authorizationUrl the browser should navigate to. */ +export async function startMcpAuth( + serverName: string, + loopId: string, +): Promise<{ authorizationUrl?: string; error?: string }> { + const r = await apiFetch("/api/mcp-auth/start", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ serverName, loopId }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { error: j.error ?? "start failed" } + return { authorizationUrl: j.authorizationUrl } +} + +/** Forget an MCP token — deletes the env file from the user's personal + * default vault. This is the inverse of OAuth: subsequent /api/mcp-servers + * responses will show `authed: false` for any server keyed on this env. */ +export async function deleteEnv(name: string): Promise<boolean> { + const r = await apiFetch(`/api/envs/${encodeURIComponent(name)}`, { method: "DELETE" }) + return r.ok +} + +/** Restart a loop's in-memory SDK session — interrupt the current query() + * so the next user message re-spawns CC and re-reads mcpServers + tokens. + * Used by the /mcp popover's "Reload" button after the user connects a new + * MCP server. Conversation history is preserved by the SDK. */ +export async function restartLoopSession(loopId: string): Promise<{ restarted: boolean; error?: string }> { + const r = await apiFetch(`/api/loops/${encodeURIComponent(loopId)}/restart-session`, { + method: "POST", + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { restarted: false, error: j.error ?? `restart failed (${r.status})` } + return { restarted: !!j.restarted } +} + +// ── composition model: tiers ── + +export type TierId = "team" | "personal" | "project" | "local" | (string & {}) + +export type TierInfo = { + id: TierId + label: string + path: string + exists: boolean + editable: boolean + managedBy: "admin" | "user" | "sdk" + settings: Record<string, any> | null + claudeMd: string | null + pluginCount: number + mcpServerCount: number + marketplaceCount: number + hookCount: number + skillCount: number + agentCount: number + /** Toolchain tools declared in this tier's mise.toml. */ + toolchainCount: number + overrides: Record<string, { overrides: string; value: any }> +} + +export type TiersResponse = { + tiers: TierInfo[] + mergedSettings: Record<string, any> + isAdmin: boolean +} + +export async function getTiers(): Promise<TiersResponse | null> { + const r = await apiFetch("/api/tiers") + if (!r.ok) return null + return (await r.json()) as TiersResponse +} + +export async function getTierSettings(tierId: string): Promise<Record<string, any> | null> { + const r = await apiFetch(`/api/tiers/${encodeURIComponent(tierId)}/settings`) + if (!r.ok) return null + return (await r.json()) as Record<string, any> +} + +export async function saveTierSettings(tierId: string, settings: Record<string, any>): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/tiers/${encodeURIComponent(tierId)}/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(settings), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `save failed (${r.status})` } + return { ok: true } +} + +// ── mise.toml config per tier ── + +export type MiseConfigResponse = { + content: string + exists: boolean + error?: string +} + +export async function getTierMiseConfig(tierId: string): Promise<MiseConfigResponse> { + const r = await apiFetch(`/api/tiers/${encodeURIComponent(tierId)}/mise-config`) + if (!r.ok) return { content: "", exists: false, error: `fetch failed (${r.status})` } + return (await r.json()) as MiseConfigResponse +} + +export async function saveTierMiseConfig(tierId: string, content: string): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/tiers/${encodeURIComponent(tierId)}/mise-config`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `save failed (${r.status})` } + return { ok: true } +} + +// ── plugin inventory ── + +export type PluginEntry = { + name: string + marketplace: string + displayName: string + description?: string +} + +export type PluginWithStatus = PluginEntry & { + installed: boolean + marketplaceName: string +} + +export type MarketplaceSource = { + name: string + source: any + installLocation?: string +} + +export async function listAvailablePlugins(): Promise<PluginEntry[]> { + const r = await apiFetch("/api/plugins/available") + if (!r.ok) return [] + const j = await r.json() + return (j.plugins as PluginEntry[]) ?? [] +} + +export async function browseMarketplacePlugins(): Promise<PluginWithStatus[]> { + const r = await apiFetch("/api/plugins/browse") + if (!r.ok) return [] + const j = await r.json() + return (j.plugins as PluginWithStatus[]) ?? [] +} + +export async function listMarketplaces(): Promise<MarketplaceSource[]> { + const r = await apiFetch("/api/marketplaces") + if (!r.ok) return [] + const j = await r.json() + return (j.marketplaces as MarketplaceSource[]) ?? [] +} + +export async function refreshMarketplaces(): Promise<{ ok: boolean; added?: string[]; error?: string }> { + const r = await apiFetch("/api/plugins/refresh", { method: "POST" }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `refresh failed (${r.status})` } + return { ok: true, added: j.added } +} + +// ── profile CRUD (admin) ── + +export type ProfileDetail = { + name: string + path: string + description: string | null + settings: Record<string, any> | null + claudeMd: string | null + pluginCount: number + mcpServerCount: number + marketplaceCount: number + hookCount: number + skillCount: number + agentCount: number + /** Toolchain tools declared in this profile's mise.toml. */ + toolchainCount: number +} + +export async function listProfilesRich(): Promise<ProfileDetail[]> { + const r = await apiFetch("/api/admin/profiles") + if (!r.ok) return [] + const j = await r.json() + return (j.profiles as ProfileDetail[]) ?? [] +} + +export async function createProfile(name: string): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch("/api/admin/profiles", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `create failed (${r.status})` } + return { ok: true } +} + +export async function getProfileDetail(name: string): Promise<ProfileDetail | null> { + const r = await apiFetch(`/api/admin/profiles/${encodeURIComponent(name)}`) + if (!r.ok) return null + return (await r.json()) as ProfileDetail +} + +export async function updateProfile(name: string, data: { settings?: Record<string, any>; claudeMd?: string }): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/admin/profiles/${encodeURIComponent(name)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(data), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `update failed (${r.status})` } + return { ok: true } +} + +export async function deleteProfile(name: string): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch(`/api/admin/profiles/${encodeURIComponent(name)}`, { method: "DELETE" }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `delete failed (${r.status})` } + return { ok: true } +} + +// ── personal default profiles ── + +export async function saveDefaultProfiles(profiles: string[]): Promise<{ ok: boolean; error?: string }> { + const r = await apiFetch("/api/personal/default-profiles", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ default_profiles: profiles }), + }) + const j = await r.json().catch(() => ({})) + if (!r.ok) return { ok: false, error: j.error ?? `save failed (${r.status})` } + return { ok: true } +} + +// ── API tokens (v1 /me/tokens) ── + +export type ApiTokenEntry = { + tokenId: string + label: string + createdAt: string + lastUsedAt?: string +} + +export async function listApiTokens(): Promise<ApiTokenEntry[]> { + const r = await apiFetch("/api/v1/me/tokens") + if (!r.ok) return [] + const j = await r.json().catch(() => ({ tokens: [] })) + return j.tokens ?? [] +} + +export async function createApiToken(label: string): Promise<{ tokenId: string; token: string; label: string; createdAt: string } | null> { + const r = await apiFetch("/api/v1/me/tokens", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ label }), + }) + if (!r.ok) return null + return await r.json().catch(() => null) +} + +export async function revokeApiToken(tokenId: string): Promise<boolean> { + const r = await apiFetch(`/api/v1/me/tokens/${encodeURIComponent(tokenId)}`, { + method: "DELETE", + }) + return r.ok +} diff --git a/web/src/components/DiffModal.tsx b/web/src/components/DiffModal.tsx new file mode 100644 index 00000000..0eeccc1d --- /dev/null +++ b/web/src/components/DiffModal.tsx @@ -0,0 +1,144 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { LoaderIcon } from "lucide-react" + +type DiffHunk = { + header: string + lines: DiffLine[] +} + +type DiffLine = { + type: "add" | "del" | "ctx" + oldNum: number | null + newNum: number | null + text: string +} + +function parseDiff(raw: string): { fileHeader: string; hunks: DiffHunk[] } { + const lines = raw.split("\n") + let fileHeader = "" + const hunks: DiffHunk[] = [] + let currentHunk: DiffHunk | null = null + let oldNum = 0 + let newNum = 0 + + for (const line of lines) { + if (line.startsWith("diff ")) { + fileHeader = line + continue + } + if (line.startsWith("+++ ") || line.startsWith("--- ")) continue + + const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/) + if (hunkMatch) { + if (currentHunk) hunks.push(currentHunk) + oldNum = parseInt(hunkMatch[1], 10) + newNum = parseInt(hunkMatch[3], 10) + currentHunk = { header: line, lines: [] } + continue + } + + if (!currentHunk) continue + + if (line.startsWith("+")) { + currentHunk.lines.push({ type: "add", oldNum: null, newNum: newNum, text: line.slice(1) }) + newNum++ + } else if (line.startsWith("-")) { + currentHunk.lines.push({ type: "del", oldNum: oldNum, newNum: null, text: line.slice(1) }) + oldNum++ + } else { + currentHunk.lines.push({ type: "ctx", oldNum: oldNum, newNum: newNum, text: line.startsWith(" ") ? line.slice(1) : line }) + oldNum++ + newNum++ + } + } + if (currentHunk) hunks.push(currentHunk) + + return { fileHeader, hunks } +} + +export function DiffModal({ + filePath, + diff, + loading, + onClose, +}: { + filePath: string + diff: string | null + loading: boolean + onClose: () => void +}) { + let parsed: ReturnType<typeof parseDiff> | null = null + if (diff) { + try { parsed = parseDiff(diff) } catch {} + } + + return ( + <Dialog open onOpenChange={(o) => { if (!o) onClose() }}> + <DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col bg-white p-0 gap-0" showCloseButton={false}> + <div className="px-4 py-2.5 border-b border-gray-200 shrink-0 flex items-center gap-2"> + <span className="text-sm font-mono text-gray-900 truncate flex-1">{filePath}</span> + <button onClick={onClose} className="text-gray-400 hover:text-gray-700 text-sm leading-none"> + ✕ + </button> + </div> + <div className="flex-1 min-h-0 overflow-auto"> + {loading ? ( + <div className="flex items-center justify-center py-16 text-gray-400 gap-2"> + <LoaderIcon className="animate-spin" size={16} /> + <span className="text-sm">loading diff...</span> + </div> + ) : !parsed ? ( + <div className="py-16 text-center text-sm text-gray-400">empty diff</div> + ) : ( + <div className="text-xs font-mono leading-5"> + {parsed.hunks.map((hunk, hi) => ( + <div key={hi} className="border-b border-gray-100 last:border-b-0"> + {/* Hunk header */} + <div className="px-4 py-1 bg-blue-50/60 text-blue-700 border-b border-blue-100 sticky top-0"> + {hunk.header} + </div> + {/* Hunk lines */} + {hunk.lines.map((ln, li) => { + const isAdd = ln.type === "add" + const isDel = ln.type === "del" + return ( + <div + key={li} + className={ + "flex " + + (isAdd ? "bg-emerald-50" : isDel ? "bg-red-50" : "") + } + > + {/* Old line number */} + <span className="w-14 shrink-0 text-right pr-3 py-px text-gray-300 select-none border-r border-gray-200 bg-gray-50"> + {ln.oldNum ?? ""} + </span> + {/* New line number */} + <span className={ + "w-14 shrink-0 text-right pr-3 py-px select-none border-r border-gray-200 " + + (isAdd ? "bg-emerald-100/70 text-emerald-700" : isDel ? "bg-red-100/70 text-red-500" : "text-gray-300 bg-gray-50") + }> + {ln.newNum ?? ""} + </span> + {/* Content */} + <span className={ + "pl-3 py-px whitespace-pre-wrap break-all flex-1 " + + (isAdd ? "text-emerald-800" : isDel ? "text-red-700" : "text-gray-700") + }> + <span className="select-none"> + {isAdd ? "+" : isDel ? "−" : " "} + </span> + {ln.text} + </span> + </div> + ) + })} + </div> + ))} + </div> + )} + </div> + </DialogContent> + </Dialog> + ) +} diff --git a/web/src/components/FloatingDm.tsx b/web/src/components/FloatingDm.tsx new file mode 100644 index 00000000..88ed1274 --- /dev/null +++ b/web/src/components/FloatingDm.tsx @@ -0,0 +1,455 @@ +/** + * Floating DM widget — bottom-right bubble that opens a small DM-only panel. + * Channels still go through the full /chat route; this is the + * lightweight always-on-top affordance for 1:1s while you're working in + * a loop / focus / kanban / context tab. + * + * Owns its own WS subscriptions (cheap; server fans out by membership). + * Mark-read uses the same `loopat:chat-read` window event ChatPage uses, + * so the unread badge stays in sync if a DM is read from either surface. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { MessageCircle, X, ArrowLeft, Plus, ExternalLink } from "lucide-react" +import { useNavigate } from "react-router-dom" +import { + listChatConversations, + listChatMessages, + listChatUsers, + sendChatMessage, + markChatRead, + openChatDm, + type ChatConversation, + type ChatMessage, + type ChatThreadRoot, + type ChatWorkspaceUser, +} from "../api" +import { useChatWebSocket, type ChatWsEvent } from "../useChatWebSocket" + +const LS_OPEN = "loopat:floating-dm-open" +const LS_CONV = "loopat:floating-dm-conv" + +function formatTime(ts: number): string { + const d = new Date(ts) + const today = new Date() + const sameDay = d.toDateString() === today.toDateString() + const hhmm = d.toTimeString().slice(0, 5) + if (sameDay) return hhmm + const yesterday = new Date(today.getTime() - 86_400_000).toDateString() === d.toDateString() + if (yesterday) return `yesterday ${hhmm}` + return `${d.getMonth() + 1}/${d.getDate()} ${hhmm}` +} + +export function FloatingDm({ me }: { me: string }) { + const navigate = useNavigate() + const [open, setOpen] = useState(() => localStorage.getItem(LS_OPEN) === "1") + const [activeConvId, setActiveConvId] = useState<string | null>(() => localStorage.getItem(LS_CONV)) + const [showPicker, setShowPicker] = useState(false) + + const [convs, setConvs] = useState<ChatConversation[]>([]) + const [users, setUsers] = useState<ChatWorkspaceUser[]>([]) + const [messages, setMessages] = useState<ChatThreadRoot[]>([]) + const [draft, setDraft] = useState("") + const [sending, setSending] = useState(false) + const messagesEndRef = useRef<HTMLDivElement | null>(null) + + const dms = useMemo( + () => + convs + .filter((c) => c.kind === "dm") + .sort((a, b) => (b.lastMessageTs ?? 0) - (a.lastMessageTs ?? 0)), + [convs], + ) + const totalUnread = useMemo(() => dms.reduce((s, c) => s + c.unread, 0), [dms]) + const active = useMemo(() => dms.find((c) => c.id === activeConvId) ?? null, [dms, activeConvId]) + + // persist + useEffect(() => { localStorage.setItem(LS_OPEN, open ? "1" : "0") }, [open]) + useEffect(() => { + if (activeConvId) localStorage.setItem(LS_CONV, activeConvId) + else localStorage.removeItem(LS_CONV) + }, [activeConvId]) + + const refreshConvs = useCallback(async () => { + const list = await listChatConversations() + setConvs(list) + }, []) + + useEffect(() => { + refreshConvs() + listChatUsers().then(setUsers) + }, [refreshConvs]) + + // Load messages whenever the active DM changes (also runs on initial open + // if we restored a convId from localStorage). + useEffect(() => { + if (!activeConvId) { + setMessages([]) + return + } + let cancelled = false + listChatMessages(activeConvId, { limit: 100 }).then((msgs) => { + if (cancelled) return + setMessages(msgs) + const last = msgs[msgs.length - 1] + if (last) { + markChatRead(activeConvId, last.id).catch(() => {}) + setConvs((prev) => prev.map((c) => (c.id === activeConvId ? { ...c, unread: 0 } : c))) + window.dispatchEvent(new CustomEvent("loopat:chat-read", { detail: { convId: activeConvId } })) + } + }) + return () => { cancelled = true } + }, [activeConvId]) + + useEffect(() => { + if (open && active) messagesEndRef.current?.scrollIntoView({ behavior: "auto" }) + }, [messages, open, active]) + + // ── ws ── + const activeRef = useRef<string | null>(activeConvId) + activeRef.current = activeConvId + + const onEvent = useCallback((e: ChatWsEvent) => { + if (e.type === "message") { + const m = e.message + const isActive = m.convId === activeRef.current + if (isActive && m.parentId == null) { + setMessages((prev) => ( + prev.some((x) => x.id === m.id) + ? prev + : [...prev, { ...m, replyCount: 0, lastReplyTs: null }] + )) + markChatRead(m.convId, m.id).catch(() => {}) + window.dispatchEvent(new CustomEvent("loopat:chat-read", { detail: { convId: m.convId } })) + } else if (isActive && m.parentId != null) { + // reply on a message we have — bump reply count so the "💬 N replies" + // affordance updates (we don't render the thread inline). + setMessages((prev) => prev.map((x) => + x.id === m.parentId + ? { ...x, replyCount: x.replyCount + 1, lastReplyTs: m.ts } + : x, + )) + } else if (m.author !== me) { + // not the active conv && not self — bump unread + lastMessageTs for sort order. + setConvs((prev) => prev.map((c) => + c.id === m.convId + ? { ...c, unread: c.unread + 1, lastMessageTs: m.ts } + : c, + )) + } + } else if (e.type === "conv_created") { + setConvs((prev) => (prev.some((c) => c.id === e.conv.id) ? prev : [...prev, e.conv])) + } else if (e.type === "conv_deleted") { + setConvs((prev) => prev.filter((c) => c.id !== e.convId)) + if (activeRef.current === e.convId) setActiveConvId(null) + } + }, [me]) + + const { subscribe, unsubscribe } = useChatWebSocket(onEvent) + useEffect(() => { + for (const c of dms) subscribe(c.id) + return () => { for (const c of dms) unsubscribe(c.id) } + }, [dms, subscribe, unsubscribe]) + + // Cross-surface sync: when ChatPage marks a conv read, mirror it here. + useEffect(() => { + const onRead = (e: Event) => { + const detail = (e as CustomEvent).detail as { convId?: string } | undefined + if (!detail?.convId) return + setConvs((prev) => prev.map((c) => (c.id === detail.convId ? { ...c, unread: 0 } : c))) + } + window.addEventListener("loopat:chat-read", onRead) + return () => window.removeEventListener("loopat:chat-read", onRead) + }, []) + + const handleSend = async () => { + if (!active || !draft.trim() || sending) return + const text = draft.trim() + setDraft("") + setSending(true) + const r = await sendChatMessage(active.id, text) + setSending(false) + if (r.message) { + const m = r.message + setMessages((prev) => ( + prev.some((x) => x.id === m.id) + ? prev + : [...prev, { ...m, replyCount: 0, lastReplyTs: null }] + )) + } else { + // restore draft on failure so the user can retry / edit + setDraft(text) + } + } + + const handleOpenDm = async (userId: string) => { + const r = await openChatDm(userId) + if (r.conv) { + setShowPicker(false) + setActiveConvId(r.conv.id) + refreshConvs() + } else if (r.error) { + alert(r.error) + } + } + + const openFullChat = () => { + if (active) navigate(`/chat/${active.id}`) + else navigate("/chat") + setOpen(false) + } + + if (!me) return null + + return ( + <> + {!open && ( + <button + type="button" + onClick={() => setOpen(true)} + className={ + "fixed bottom-20 right-5 z-30 w-12 h-12 rounded-full flex items-center justify-center shadow-lg transition-colors " + + (totalUnread > 0 + ? "bg-gray-700 text-white animate-pulse" + : "bg-gray-700 text-white hover:bg-gray-500") + } + title="direct messages" + > + <MessageCircle size={20} /> + {totalUnread > 0 && ( + <span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-medium flex items-center justify-center"> + {totalUnread > 99 ? "99+" : totalUnread} + </span> + )} + </button> + )} + + {open && ( + <div className="fixed bottom-20 right-5 z-30 w-[22rem] max-w-[calc(100vw-2.5rem)] h-[32rem] max-h-[calc(100dvh-2.5rem)] bg-white rounded-lg shadow-2xl border border-gray-200 flex flex-col overflow-hidden"> + <header className="h-10 shrink-0 border-b border-gray-200 px-2 flex items-center gap-1"> + {active ? ( + <button + type="button" + onClick={() => setActiveConvId(null)} + className="w-7 h-7 flex items-center justify-center rounded text-gray-500 hover:text-gray-900 hover:bg-gray-100" + title="back to DMs" + > + <ArrowLeft size={14} /> + </button> + ) : ( + <span className="px-2 text-[13px] font-medium text-gray-900">Direct messages</span> + )} + {active && ( + <div className="flex-1 min-w-0 flex items-center gap-1.5"> + <span className="text-gray-400 text-[13px]">@</span> + <span className="text-[13px] font-medium text-gray-900 truncate"> + {active.peerUserId ?? "(unknown)"} + </span> + </div> + )} + {!active && <div className="flex-1" />} + {!active && ( + <button + type="button" + onClick={() => setShowPicker(true)} + className="w-7 h-7 flex items-center justify-center rounded text-gray-500 hover:text-gray-900 hover:bg-gray-100" + title="new DM" + > + <Plus size={14} /> + </button> + )} + {active && ( + <button + type="button" + onClick={openFullChat} + className="w-7 h-7 flex items-center justify-center rounded text-gray-500 hover:text-gray-900 hover:bg-gray-100" + title="open in chat tab" + > + <ExternalLink size={13} /> + </button> + )} + <button + type="button" + onClick={() => setOpen(false)} + className="w-7 h-7 flex items-center justify-center rounded text-gray-500 hover:text-gray-900 hover:bg-gray-100" + title="close" + > + <X size={14} /> + </button> + </header> + + {active ? ( + <> + <div className="flex-1 min-h-0 overflow-auto px-3 py-2 flex flex-col gap-2"> + {messages.length === 0 && ( + <div className="text-[12px] text-gray-400 italic py-4 text-center"> + no messages yet — say hi + </div> + )} + {messages.map((m) => ( + <MiniMessage key={m.id} message={m} isMe={m.author === me} /> + ))} + <div ref={messagesEndRef} /> + </div> + <div className="px-2 pb-2 pt-1 shrink-0"> + <div className="rounded-xl border border-gray-200 bg-white p-1.5 flex items-end gap-1"> + <textarea + value={draft} + onChange={(e) => setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.nativeEvent.isComposing) return + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSend() + } + }} + rows={1} + placeholder={`message @${active.peerUserId ?? ""}…`} + className="field-sizing-content flex-1 max-h-32 min-h-7 resize-none bg-transparent px-1.5 py-1 text-sm text-gray-900 outline-none placeholder:text-gray-400" + /> + <button + type="button" + onClick={handleSend} + disabled={sending || !draft.trim()} + className="px-2.5 h-7 rounded bg-gray-900 text-white text-xs hover:bg-gray-700 disabled:opacity-40 shrink-0" + > + send + </button> + </div> + </div> + </> + ) : ( + <div className="flex-1 min-h-0 overflow-auto"> + {dms.length === 0 ? ( + <div className="px-4 py-6 text-center text-[12px] text-gray-400"> + no DMs yet + <div className="mt-2"> + <button + type="button" + onClick={() => setShowPicker(true)} + className="px-2 py-1 rounded border border-gray-200 text-[12px] text-gray-700 hover:bg-gray-50 inline-flex items-center gap-1" + > + <Plus size={12} /> + <span>new DM</span> + </button> + </div> + </div> + ) : ( + <ul className="py-1"> + {dms.map((c) => ( + <li key={c.id}> + <button + type="button" + onClick={() => setActiveConvId(c.id)} + className="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-gray-50" + > + <span className="w-7 h-7 rounded bg-gray-200 text-gray-900 text-[11px] font-medium flex items-center justify-center shrink-0"> + {(c.peerUserId ?? "?").slice(0, 1).toUpperCase()} + </span> + <span className="flex-1 min-w-0 text-[13px] text-gray-900 truncate"> + @{c.peerUserId ?? "(unknown)"} + </span> + {c.unread > 0 && ( + <span className="text-[10px] font-medium px-1.5 rounded-full bg-red-500 text-white"> + {c.unread} + </span> + )} + {c.lastMessageTs && c.unread === 0 && ( + <span className="text-[10px] text-gray-400">{formatTime(c.lastMessageTs)}</span> + )} + </button> + </li> + ))} + </ul> + )} + </div> + )} + </div> + )} + + {showPicker && ( + <DmPicker + users={users} + onClose={() => setShowPicker(false)} + onPick={handleOpenDm} + /> + )} + </> + ) +} + +function MiniMessage({ message, isMe }: { message: ChatMessage; isMe: boolean }) { + return ( + <div className={"flex gap-1.5 " + (isMe ? "flex-row-reverse" : "")}> + <div + className={ + isMe + ? "w-6 h-6 rounded shrink-0 flex items-center justify-center text-[10px] font-medium bg-gray-900 text-white" + : "w-6 h-6 rounded shrink-0 flex items-center justify-center text-[10px] font-medium bg-gray-200 text-gray-900" + } + title={message.author} + > + {message.author.slice(0, 1).toUpperCase()} + </div> + <div className="min-w-0 max-w-[80%]"> + <div className={"text-[10px] text-gray-400 " + (isMe ? "text-right" : "")}> + {formatTime(message.ts)} + </div> + <div + className={ + "text-[12.5px] whitespace-pre-wrap break-words leading-relaxed px-2 py-1 rounded-lg " + + (isMe ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-900") + } + > + {message.text} + </div> + </div> + </div> + ) +} + +function DmPicker({ + users, + onClose, + onPick, +}: { + users: ChatWorkspaceUser[] + onClose: () => void + onPick: (userId: string) => void +}) { + const [q, setQ] = useState("") + const filtered = users + .filter((u) => !u.isMe) + .filter((u) => u.id.toLowerCase().includes(q.toLowerCase())) + return ( + <div className="fixed inset-0 z-40 bg-black/30 flex items-center justify-center" onClick={onClose}> + <div className="bg-white rounded-md shadow-lg w-80 p-3 flex flex-col gap-2" onClick={(e) => e.stopPropagation()}> + <div className="text-sm font-medium text-gray-900">New DM</div> + <input + autoFocus + value={q} + onChange={(e) => setQ(e.target.value)} + placeholder="search users…" + className="px-2 py-1.5 text-sm rounded border border-gray-200 outline-none focus:border-gray-400" + /> + <div className="max-h-64 overflow-auto flex flex-col gap-0.5"> + {filtered.map((u) => ( + <button + key={u.id} + type="button" + onClick={() => onPick(u.id)} + className="text-left px-2 py-1.5 text-sm rounded hover:bg-gray-100 flex items-center gap-2" + > + <span className="w-6 h-6 rounded bg-gray-200 text-gray-900 text-[11px] flex items-center justify-center"> + {u.id.slice(0, 1).toUpperCase()} + </span> + <span>{u.id}</span> + </button> + ))} + {filtered.length === 0 && ( + <div className="text-xs text-gray-400 px-2 py-2">no users match</div> + )} + </div> + </div> + </div> + ) +} diff --git a/web/src/components/GitDiffSidebar.tsx b/web/src/components/GitDiffSidebar.tsx new file mode 100644 index 00000000..adc2b761 --- /dev/null +++ b/web/src/components/GitDiffSidebar.tsx @@ -0,0 +1,613 @@ +/** + * Git diff sidebar — lists unstaged / staged changes with inline actions. + * Opens a modal for full diff on text files. + * Includes commit box, git graph, and right-click context menu. + */ +import { useEffect, useState, useCallback, useRef } from "react" +import { RotateCw, Plus, Minus, Undo2, Search, Pencil, GitCommit, GitBranch } from "lucide-react" +import { + getGitStatus, getGitDiff, gitStageFiles, gitDiscardFile, gitCommit, getGitLog, + type GitStatus, type GitFileInfo, type GitCommit as GitCommitType, +} from "../api" +import { DiffModal } from "./DiffModal" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" + +const STATUS_LABEL: Record<string, { label: string; color: string }> = { + A: { label: "A", color: "text-emerald-600" }, + M: { label: "M", color: "text-amber-600" }, + D: { label: "D", color: "text-red-600" }, + R: { label: "R", color: "text-purple-600" }, + "?": { label: "?", color: "text-gray-400" }, +} + +export function GitDiffSidebar({ loopId, onClose, onPickFile }: { loopId: string; onClose: () => void; onPickFile: (path: string) => void }) { + const [status, setStatus] = useState<GitStatus | null>(null) + const [loading, setLoading] = useState(false) + const [unstagedOpen, setUnstagedOpen] = useState(true) + const [stagedOpen, setStagedOpen] = useState(true) + const [error, setError] = useState<string | null>(null) + const [filter, setFilter] = useState("") + const [commitMsg, setCommitMsg] = useState("") + const [committing, setCommitting] = useState(false) + const [commitError, setCommitError] = useState<string | null>(null) + const [commits, setCommits] = useState<GitCommitType[]>([]) + const [loadingLog, setLoadingLog] = useState(false) + const [graphOpen, setGraphOpen] = useState(true) + + const refresh = useCallback(async () => { + setLoading(true) + setError(null) + try { + const s = await getGitStatus(loopId) + setStatus(s) + } catch { + setError("Failed to load git status") + } finally { + setLoading(false) + } + }, [loopId]) + + const refreshLog = useCallback(async () => { + setLoadingLog(true) + try { + const c = await getGitLog(loopId, 50) + setCommits(c) + } catch { + // silently fail + } finally { + setLoadingLog(false) + } + }, [loopId]) + + useEffect(() => { refresh(); refreshLog() }, [refresh, refreshLog]) + + const stageAll = async () => { + if (!status?.unstaged.length) return + const files = status.unstaged.map((f) => f.path) + const ok = await gitStageFiles(loopId, files) + if (ok) refresh() + } + + const unstageAll = async () => { + if (!status?.staged.length) return + const files = status.staged.map((f) => f.path) + const ok = await gitStageFiles(loopId, files, true) + if (ok) refresh() + } + + const stageOne = async (file: string) => { + const ok = await gitStageFiles(loopId, [file]) + if (ok) refresh() + } + + const unstageOne = async (file: string) => { + const ok = await gitStageFiles(loopId, [file], true) + if (ok) refresh() + } + + const discardOne = async (file: string) => { + const ok = await gitDiscardFile(loopId, file) + if (ok) refresh() + } + + const handleCommit = async () => { + if (!commitMsg.trim()) return + setCommitting(true) + setCommitError(null) + const res = await gitCommit(loopId, commitMsg.trim()) + setCommitting(false) + if (res.ok) { + setCommitMsg("") + refresh() + refreshLog() + } else { + setCommitError(res.error ?? "Commit failed") + } + } + + const filterText = filter.trim().toLowerCase() + const filteredUnstaged = status ? status.unstaged.filter((f) => !filterText || f.path.toLowerCase().includes(filterText)) : [] + const filteredStaged = status ? status.staged.filter((f) => !filterText || f.path.toLowerCase().includes(filterText)) : [] + const empty = !status || (status.unstaged.length === 0 && status.staged.length === 0) + + return ( + <aside className="w-full h-full min-w-0 bg-white flex flex-col"> + {/* Header */} + <header className="px-3 h-8 shrink-0 border-b border-gray-200 flex items-center gap-1 text-[11px] text-gray-500"> + <span className="tracking-wide">Git Changes</span> + <div className="flex-1" /> + <button + onClick={() => { refresh(); refreshLog() }} + className="text-gray-500 hover:text-gray-900 px-1 rounded hover:bg-gray-100" + title="refresh" + > + <RotateCw size={13} className={loading || loadingLog ? "animate-spin" : ""} /> + </button> + <button + className="text-gray-500 hover:text-gray-900 px-1 rounded hover:bg-gray-100" + onClick={onClose} + title="close" + > + ✕ + </button> + </header> + + {/* Commit box */} + <div className="px-3 py-2 border-b border-gray-100"> + <textarea + placeholder="Commit message..." + value={commitMsg} + onChange={(e) => { setCommitMsg(e.target.value); setCommitError(null) }} + rows={2} + className="w-full px-2 py-1.5 text-[12px] border border-gray-200 rounded outline-none focus:border-gray-300 placeholder:text-gray-300 resize-none mb-1.5" + /> + <div className="flex items-center gap-2"> + <button + onClick={handleCommit} + disabled={committing || !commitMsg.trim()} + className="flex-1 flex items-center justify-center gap-1 px-2 py-1 text-[11px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" + > + <GitCommit size={12} /> + {committing ? "Committing..." : "Commit"} + </button> + {status && status.staged.length > 0 && ( + <span className="text-[10px] text-gray-400">{status.staged.length} staged</span> + )} + </div> + {commitError && ( + <div className="mt-1 text-[10px] text-red-500">{commitError}</div> + )} + </div> + + {/* Filter input */} + <div className="px-3 py-2 border-b border-gray-100"> + <input + type="text" + placeholder="Filter files..." + value={filter} + onChange={(e) => setFilter(e.target.value)} + className="w-full px-2 py-1 text-[12px] border border-gray-200 rounded outline-none focus:border-gray-300 placeholder:text-gray-300" + /> + </div> + + {/* Content */} + <div className="flex-1 min-h-0 overflow-auto text-[13px]"> + <div className="overflow-auto py-2"> + {loading && !status && ( + <div className="px-5 py-4 text-[12px] text-gray-400 italic">loading git status...</div> + )} + {error && ( + <div className="px-5 py-4 text-[12px] text-red-500">{error}</div> + )} + {empty && !loading && ( + <div className="px-5 py-4 text-[12px] text-gray-400 italic">no changes</div> + )} + + {/* Unstaged section */} + {filteredUnstaged.length > 0 && ( + <Section + title="Unstaged Changes" + count={filteredUnstaged.length} + open={unstagedOpen} + onToggle={() => setUnstagedOpen((o) => !o)} + bulkAction={{ label: "Stage all", icon: <Plus size={12} />, onClick: stageAll }} + > + {filteredUnstaged.map((f) => ( + <FileRow + key={f.path} + file={f} + loopId={loopId} + staged={false} + onStage={() => stageOne(f.path)} + onUnstage={() => unstageOne(f.path)} + onDiscard={() => discardOne(f.path)} + onEdit={(p) => onPickFile(p)} + onRefresh={refresh} + /> + ))} + </Section> + )} + + {/* Staged section */} + {filteredStaged.length > 0 && ( + <Section + title="Staged Changes" + count={filteredStaged.length} + open={stagedOpen} + onToggle={() => setStagedOpen((o) => !o)} + bulkAction={{ label: "Unstage all", icon: <Minus size={12} />, onClick: unstageAll }} + > + {filteredStaged.map((f) => ( + <FileRow + key={f.path} + file={f} + loopId={loopId} + staged + onStage={() => stageOne(f.path)} + onUnstage={() => unstageOne(f.path)} + onDiscard={() => discardOne(f.path)} + onEdit={(p) => onPickFile(p)} + onRefresh={refresh} + /> + ))} + </Section> + )} + </div> + + {/* Git Graph */} + <div className="border-t border-gray-200"> + <div + role="button" + tabIndex={0} + onClick={() => setGraphOpen((o) => !o)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") setGraphOpen((o) => !o) }} + className="w-full px-3 py-1.5 flex items-center gap-1.5 hover:bg-gray-50 text-left cursor-pointer select-none" + > + <span className="text-gray-500">{graphOpen ? "▾" : "▸"}</span> + <GitBranch size={12} className="text-gray-500" /> + <span className="text-[11px] uppercase tracking-wide text-gray-500">History</span> + <span className="text-[11px] text-gray-400">({commits.length})</span> + </div> + {graphOpen && ( + <div className="px-2 pb-2 max-h-64 overflow-auto"> + {loadingLog ? ( + <div className="px-3 py-3 text-[11px] text-gray-400 italic">loading history...</div> + ) : commits.length === 0 ? ( + <div className="px-3 py-3 text-[11px] text-gray-400 italic">no commits</div> + ) : ( + <GitGraph commits={commits} /> + )} + </div> + )} + </div> + </div> + </aside> + ) +} + +function GitGraph({ commits }: { commits: GitCommitType[] }) { + const colors = ["bg-blue-500", "bg-emerald-500", "bg-purple-500", "bg-amber-500", "bg-pink-500", "bg-cyan-500"] + const branchColorMap = new Map<string, string>() + let colorIdx = 0 + + const getColor = (branch: string) => { + if (!branchColorMap.has(branch)) { + branchColorMap.set(branch, colors[colorIdx % colors.length]) + colorIdx++ + } + return branchColorMap.get(branch)! + } + + return ( + <div className="space-y-0"> + {commits.map((commit, idx) => { + const primaryRef = commit.branch ?? commit.branches[0] ?? null + const color = primaryRef ? getColor(primaryRef) : "bg-gray-400" + const isHead = idx === 0 + const dateStr = new Date(commit.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }) + const timeStr = new Date(commit.date).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) + + return ( + <div + key={commit.hash} + className="group/graph-row flex items-start gap-2 py-0.5 px-1.5 rounded hover:bg-gray-100 cursor-default text-[11px]" + title={`${commit.subject}\n${commit.author} · ${dateStr} ${timeStr}`} + > + <div className="flex flex-col items-center shrink-0"> + <div className={`w-2.5 h-2.5 rounded-full ${color} ring-2 ring-white ${isHead ? "ring-blue-300" : ""}`} /> + {idx < commits.length - 1 && ( + <div className={`w-0.5 h-10 ${color} opacity-10`} /> + )} + </div> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-1.5"> + <span className="font-mono text-[10px] text-gray-400 truncate">{commit.shortHash}</span> + {isHead && ( + <span className="text-[9px] px-1 py-0 rounded bg-blue-100 text-blue-700 font-medium">HEAD</span> + )} + {commit.branches.slice(0, 2).map((b) => ( + <span key={b} className="text-[9px] px-1 py-0 rounded bg-emerald-100 text-emerald-700 font-medium truncate max-w-[60px]">{b}</span> + ))} + {commit.tags.map((t) => ( + <span key={t} className="text-[9px] px-1 py-0 rounded bg-purple-100 text-purple-700 font-medium">{t}</span> + ))} + </div> + <div className="text-gray-700 truncate">{commit.subject}</div> + <div className="text-gray-400 text-[10px]">{commit.author} · {dateStr}</div> + </div> + </div> + ) + })} + </div> + ) +} + +function Section({ + title, + count, + open, + onToggle, + bulkAction, + children, +}: { + title: string + count: number + open: boolean + onToggle: () => void + bulkAction: { label: string; icon: React.ReactNode; onClick: () => void } + children: React.ReactNode +}) { + return ( + <div className="mb-2"> + <div + role="button" + tabIndex={0} + onClick={onToggle} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onToggle() }} + className="w-full px-3 py-1.5 flex items-center gap-1.5 hover:bg-gray-50 text-left group/section cursor-pointer select-none" + > + <span className="text-gray-500">{open ? "▾" : "▸"}</span> + <span className="text-[11px] uppercase tracking-wide text-gray-500">{title}</span> + <span className="text-[11px] text-gray-400">({count})</span> + <div className="flex-1" /> + <button + type="button" + onClick={(e) => { e.stopPropagation(); bulkAction.onClick() }} + className="opacity-100 sm:opacity-0 sm:group-hover/section:opacity-100 transition-opacity px-1 py-0.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-900 flex items-center gap-0.5" + title={bulkAction.label} + > + {bulkAction.icon} + </button> + </div> + {open && <div>{children}</div>} + </div> + ) +} + +type ContextMenuAction = { + label: string + icon?: React.ReactNode + onClick: () => void + danger?: boolean + separator?: boolean +} + +function FileRow({ + file, + loopId, + staged, + onStage, + onUnstage, + onDiscard, + onEdit, + onRefresh, +}: { + file: GitFileInfo + loopId: string + staged: boolean + onStage: () => void + onUnstage: () => void + onDiscard: () => void + onEdit: (editorPath: string) => void + onRefresh: () => void +}) { + const [showDiff, setShowDiff] = useState(false) + const [diff, setDiff] = useState<string | null>(null) + const [loadingDiff, setLoadingDiff] = useState(false) + const [confirmDiscard, setConfirmDiscard] = useState(false) + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) + const menuRef = useRef<HTMLDivElement>(null) + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setContextMenu(null) + } + } + if (contextMenu) { + document.addEventListener("mousedown", handleClick) + return () => document.removeEventListener("mousedown", handleClick) + } + }, [contextMenu]) + + const openDiff = async () => { + if (file.isBinary) return + setShowDiff(true) + if (diff === null) { + setLoadingDiff(true) + const d = await getGitDiff(loopId, file.path, staged) + setDiff(d ?? "") + setLoadingDiff(false) + } + } + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault() + setContextMenu({ x: e.clientX, y: e.clientY }) + } + + const editorPath = `workdir/${file.path}` + + const actions: ContextMenuAction[] = [ + { + label: "Open in Editor", + icon: <Pencil size={12} />, + onClick: () => { onEdit(editorPath); setContextMenu(null) }, + }, + { + label: "View Diff", + icon: <Search size={12} />, + onClick: () => { openDiff(); setContextMenu(null) }, + }, + { separator: true, label: "", onClick: () => {} }, + staged + ? { + label: "Unstage", + icon: <Minus size={12} />, + onClick: () => { onUnstage(); setContextMenu(null) }, + } + : { + label: "Stage", + icon: <Plus size={12} />, + onClick: () => { onStage(); setContextMenu(null) }, + }, + !staged && { + label: "Discard Changes", + icon: <Undo2 size={12} />, + onClick: () => { setConfirmDiscard(true); setContextMenu(null) }, + danger: true, + }, + ].filter(Boolean) as ContextMenuAction[] + + const si = STATUS_LABEL[file.status] ?? { label: file.status, color: "text-gray-500" } + const lastSlash = file.path.lastIndexOf("/") + const fileName = lastSlash >= 0 ? file.path.slice(lastSlash + 1) : file.path + const dirName = lastSlash >= 0 ? file.path.slice(0, lastSlash + 1) : null + + return ( + <> + <div + className="group/row relative flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50" + onContextMenu={handleContextMenu} + > + {/* Status badge */} + <span className={`w-5 text-center text-[11px] font-mono font-semibold ${si.color}`}> + {si.label} + </span> + + {/* Filename + directory */} + <span className="flex-1 min-w-0 truncate text-[13px] font-mono"> + <span className="text-gray-900">{fileName}</span> + {dirName && <span className="text-gray-400 ml-0.5">{dirName}</span>} + </span> + + {/* Diff stats */} + {!file.isBinary && (file.additions > 0 || file.deletions > 0) && ( + <span className="text-[11px] font-mono flex items-center gap-0.5 shrink-0"> + <span className="text-emerald-600">+{file.additions}</span> + <span className="text-red-500">-{file.deletions}</span> + </span> + )} + + {/* Hover actions */} + <span className="inline-flex items-center gap-0.5 opacity-100 sm:opacity-0 sm:group-hover/row:opacity-100 transition-opacity shrink-0"> + <button + type="button" + onClick={() => onEdit(editorPath)} + className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-blue-600 rounded" + title="edit file" + > + <Pencil size={13} /> + </button> + + {staged && ( + <button + type="button" + onClick={openDiff} + className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-700 rounded" + title="view diff" + disabled={file.isBinary} + > + <Search size={13} /> + </button> + )} + + {staged ? ( + <button + type="button" + onClick={onUnstage} + className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-amber-600 rounded" + title="unstage" + > + <Minus size={13} /> + </button> + ) : ( + <> + <button + type="button" + onClick={() => setConfirmDiscard(true)} + className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-red-500 rounded" + title="discard changes" + > + <Undo2 size={13} /> + </button> + <button + type="button" + onClick={onStage} + className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-emerald-600 rounded" + title="stage" + > + <Plus size={13} /> + </button> + </> + )} + </span> + </div> + + {/* Context Menu */} + {contextMenu && ( + <div + ref={menuRef} + className="fixed z-50 min-w-[160px] py-1 bg-white border border-gray-200 rounded-md shadow-lg text-[12px]" + style={{ left: contextMenu.x, top: contextMenu.y }} + > + {actions.map((action, idx) => + action.separator ? ( + <div key={idx} className="my-1 border-t border-gray-100" /> + ) : ( + <button + key={idx} + onClick={action.onClick} + className={`w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-gray-100 ${action.danger ? "text-red-600" : "text-gray-700"}`} + > + {action.icon} + <span>{action.label}</span> + </button> + ) + )} + </div> + )} + + {/* Diff modal */} + {showDiff && ( + <DiffModal + filePath={file.path} + diff={diff} + loading={loadingDiff} + onClose={() => setShowDiff(false)} + /> + )} + + {/* Discard confirmation */} + {confirmDiscard && ( + <Dialog open onOpenChange={(o) => { if (!o) setConfirmDiscard(false) }}> + <DialogContent className="sm:max-w-sm bg-white p-5 gap-4" showCloseButton={false}> + <DialogHeader> + <DialogTitle className="text-sm">Discard changes</DialogTitle> + </DialogHeader> + <p className="text-[13px] text-gray-600 break-all font-mono">{file.path}</p> + <p className="text-[12px] text-gray-500"> + {file.status === "?" + ? "This file is untracked and will be deleted." + : "This will permanently discard all changes to this file. This action cannot be undone."} + </p> + <div className="flex justify-end gap-2 pt-2"> + <button + onClick={() => setConfirmDiscard(false)} + className="px-3 py-1.5 text-xs rounded bg-gray-100 text-gray-700 hover:bg-gray-200" + > + Cancel + </button> + <button + onClick={() => { setConfirmDiscard(false); onDiscard() }} + className="px-3 py-1.5 text-xs rounded bg-red-600 text-white hover:bg-red-700" + > + Discard + </button> + </div> + </DialogContent> + </Dialog> + )} + </> + ) +} diff --git a/web/src/components/McpStatusPanel.tsx b/web/src/components/McpStatusPanel.tsx new file mode 100644 index 00000000..911981fb --- /dev/null +++ b/web/src/components/McpStatusPanel.tsx @@ -0,0 +1,318 @@ +/** + * MCP servers popover, opened from the /mcp slash command. + * + * Single source: the loop's merged settings.json (team + profile + personal + * + plugin defaults). Each row shows the `authed` badge (env file exists + * for this server's Bearer template) and a "Re-authorize" / "Forget" pair + * for OAuth-eligible HTTP/SSE servers. `authed` is existence-only, no + * validity check — click Re-authorize if the token is rejected at runtime. + * + * Settings page no longer surfaces MCP tokens separately: tokens are + * indistinguishable from other vault envs (per design). + */ +import { useCallback, useEffect, useState } from "react" +import { Check, AlertTriangle, RefreshCw, Link2, Unlink, X, RotateCw, ExternalLink, KeyRound } from "lucide-react" +import { + startMcpAuth, + listMcpServers, + deleteEnv, + restartLoopSession, + parseMcpSetup, + type McpServerEntry, +} from "@/api" + +export function McpStatusPanel({ + onClose, + loopId, +}: { + onClose?: () => void + /** Loop the popover is opened from. /mcp is loop-scoped — both the server + * list and any OAuth flow originate here. */ + loopId: string +}) { + const [servers, setServers] = useState<McpServerEntry[]>([]) + const [loading, setLoading] = useState(true) + const [busyFor, setBusyFor] = useState<string | null>(null) + const [error, setError] = useState<string | null>(null) + const [reloadFlash, setReloadFlash] = useState<string | null>(null) + + const refresh = useCallback(async () => { + setLoading(true) + const inv = await listMcpServers(loopId) + setServers(inv.servers) + setLoading(false) + }, [loopId]) + + useEffect(() => { refresh() }, [refresh]) + + const connect = async (serverName: string) => { + if (busyFor) return + setBusyFor(serverName) + setError(null) + const r = await startMcpAuth(serverName, loopId) + setBusyFor(null) + if (r.error || !r.authorizationUrl) { + setError(r.error ?? "start failed") + return + } + window.location.href = r.authorizationUrl + } + + const forget = async (envName: string) => { + if (busyFor) return + setBusyFor(envName) + await deleteEnv(envName) + setBusyFor(null) + refresh() + } + + // Parse a pasted MCP URL into the server's vault secrets. Returns an error + // string on failure, null on success (and refreshes so authed flips). + const parseSetup = async (serverName: string, pastedUrl: string): Promise<string | null> => { + const r = await parseMcpSetup(serverName, pastedUrl, loopId) + if (!r.ok) return r.error + await refresh() + return null + } + + const reloadSession = async () => { + setReloadFlash(null) + const r = await restartLoopSession(loopId) + if (r.error) { + setError(r.error) + } else { + setReloadFlash( + r.restarted + ? "Loop SDK session restarted. Send a message to re-spawn with new MCP tokens." + : "No active SDK session — next message will spawn fresh.", + ) + } + } + + return ( + <div className="text-sm"> + <div className="flex items-center justify-between px-3 py-2 border-b border-gray-200"> + <div className="text-[13px] font-medium text-gray-700">MCP servers</div> + <div className="flex items-center gap-1"> + <button onClick={refresh} className="p-1 text-gray-400 hover:text-gray-700 rounded" title="reload"> + <RefreshCw size={12} className={loading ? "animate-spin" : ""} /> + </button> + <button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-700 rounded" title="close"> + <X size={14} /> + </button> + </div> + </div> + + {error && ( + <div className="mx-3 mt-3 rounded px-3 py-2 text-[12px] bg-red-50 text-red-800 border border-red-200"> + {error} + </div> + )} + + {reloadFlash && ( + <div className="mx-3 mt-3 rounded px-3 py-2 text-[12px] bg-blue-50 text-blue-800 border border-blue-200"> + {reloadFlash} + </div> + )} + + <div className="max-h-[60vh] overflow-y-auto"> + {loading && servers.length === 0 ? ( + <div className="px-3 py-4 text-[12px] text-gray-400">loading…</div> + ) : servers.length === 0 ? ( + <div className="px-3 py-4 text-[12px] text-gray-400 italic"> + No MCP servers in this loop's merged settings.json. Add servers to + team/personal/profile <code className="bg-gray-100 px-1 rounded font-mono text-[11px]">settings.json</code>'s + <code className="bg-gray-100 px-1 rounded font-mono text-[11px]">mcpServers</code>. + </div> + ) : ( + servers.map((s) => ( + <ServerRow + key={s.name} + server={s} + busy={busyFor === s.name || (s.authTokenEnv !== null && busyFor === s.authTokenEnv)} + onConnect={() => connect(s.name)} + onForget={s.authTokenEnv ? () => forget(s.authTokenEnv!) : undefined} + onParseSetup={(pasted) => parseSetup(s.name, pasted)} + /> + )) + )} + </div> + + <div className="border-t border-gray-200 px-3 py-2 flex items-center justify-end text-[11px]"> + <button + onClick={reloadSession} + className="text-gray-600 hover:text-gray-900 flex items-center gap-1" + title="Restart the SDK session so newly-connected MCPs take effect. Conversation history is preserved." + > + <RotateCw size={10} /> Reload session + </button> + </div> + </div> + ) +} + +function ServerRow({ + server, + busy, + onConnect, + onForget, + onParseSetup, +}: { + server: McpServerEntry + busy: boolean + onConnect: () => void + onForget?: () => void + onParseSetup: (pastedUrl: string) => Promise<string | null> +}) { + const isHttp = server.type === "http" || server.type === "sse" + // Connect is offered only when OAuth via DCR is feasible AND we know which + // env to write. Servers without a Bearer template don't get a button. + const connectable = isHttp && server.oauthSupport === "dcr" && !!server.authTokenEnv + // Paste-the-URL setup is offered when the server declares a setup resource + // (its secrets live in the url/headers as ${VAR}s, parsed from a pasted URL). + const hasSetup = !!server.setupResource + const [setupOpen, setSetupOpen] = useState(false) + const [paste, setPaste] = useState("") + const [saving, setSaving] = useState(false) + const [setupErr, setSetupErr] = useState<string | null>(null) + const saveSetup = async () => { + setSaving(true) + setSetupErr(null) + const e = await onParseSetup(paste.trim()) + setSaving(false) + if (e) setSetupErr(e) + else { setSetupOpen(false); setPaste("") } + } + + return ( + <div className="border-b border-gray-50 last:border-0"> + <div className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50"> + <span className="text-[14px] text-gray-300 leading-none"> + {server.authed ? "●" : connectable ? "○" : "·"} + </span> + <div className="min-w-0 flex-1"> + <div className="flex items-baseline gap-2"> + <span className="font-medium text-gray-700 text-[13px]">{server.name}</span> + <span className="text-[10px] text-gray-400 uppercase">{server.type}</span> + </div> + {server.url && ( + <div className="text-[11px] text-gray-400 font-mono truncate">{server.url}</div> + )} + </div> + <div className="shrink-0 flex items-center gap-1"> + {server.authed && ( + <span className="text-[11px] text-green-700 inline-flex items-center gap-1"> + <Check size={11} /> authed + </span> + )} + {connectable && ( + <button + onClick={onConnect} + disabled={busy} + className={`inline-flex items-center gap-1 text-[11px] px-2 h-6 rounded ${ + server.authed + ? "text-gray-600 hover:bg-gray-100 border border-gray-200" + : "text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200" + } disabled:opacity-50`} + title={server.authed ? "Re-run OAuth and overwrite the existing token" : "Run OAuth to obtain a token"} + > + {server.authed ? <Link2 size={10} /> : <AlertTriangle size={10} />} + {busy ? "…" : server.authed ? "re-auth" : "auth"} + </button> + )} + {server.authed && onForget && ( + <button + onClick={onForget} + disabled={busy} + className="inline-flex items-center gap-1 text-[11px] px-1.5 h-6 rounded text-gray-500 hover:text-gray-900 hover:bg-gray-100 disabled:opacity-50" + title="Delete the env file backing this server's token" + > + <Unlink size={10} /> forget + </button> + )} + {hasSetup && ( + <button + onClick={() => setSetupOpen((o) => !o)} + className={`inline-flex items-center gap-1 text-[11px] px-2 h-6 rounded ${ + server.authed + ? "text-gray-600 hover:bg-gray-100 border border-gray-200" + : "text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200" + }`} + title="Paste your MCP URL from the provider's page to set this server's secrets" + > + <KeyRound size={10} /> + {server.authed ? "re-setup" : "setup"} + </button> + )} + {!hasSetup && !connectable && !server.authed && isHttp && ( + server.oauthSupport === "manual" ? ( + <span + className="text-[11px] text-gray-500 italic" + title="This provider requires admin to register an OAuth app (no DCR). Loopat doesn't support manual client_id setup." + > + manual setup + </span> + ) : server.oauthSupport === "none" ? ( + <span className="text-[11px] text-gray-400" title="No OAuth metadata — server is public or uses non-OAuth auth (e.g. API key)."> + no oauth + </span> + ) : server.oauthSupport === "unreachable" ? ( + <span className="text-[11px] text-red-500" title="Probe failed — server unreachable or returned malformed metadata."> + unreachable + </span> + ) : server.authTokenEnv === null ? ( + <span + className="text-[11px] text-gray-400 italic" + title="No Bearer ${VAR} template in Authorization header — loopat can't determine which env to write." + > + no bearer template + </span> + ) : null + )} + {!isHttp && <span className="text-[11px] text-gray-400">stdio</span>} + </div> + </div> + {setupOpen && hasSetup && ( + <div className="px-3 pb-2.5 pt-0.5 bg-gray-50/60"> + <div className="text-[11px] text-gray-500 mb-1.5"> + 到{" "} + <a + href={server.setupResource} + target="_blank" + rel="noreferrer" + className="text-blue-600 hover:text-blue-800 inline-flex items-center gap-0.5" + > + 这个页面 <ExternalLink size={9} /> + </a>{" "} + 复制你的 MCP URL,粘贴到下面 —— 会自动解析出{" "} + {(server.requiredEnvs ?? []).join("、") || "所需密钥"} 并存进你的 vault。 + </div> + <textarea + value={paste} + onChange={(e) => setPaste(e.target.value)} + placeholder="https://…?key=…" + rows={2} + spellCheck={false} + className="w-full px-2 py-1 rounded border border-gray-300 text-[11px] font-mono focus:outline-none focus:border-gray-500 resize-none" + /> + {setupErr && <div className="text-[11px] text-red-600 mt-1">{setupErr}</div>} + <div className="mt-1.5 flex items-center gap-2"> + <button + onClick={saveSetup} + disabled={!paste.trim() || saving} + className="text-[11px] px-2.5 h-6 rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-40" + > + {saving ? "解析中…" : "解析并保存"} + </button> + <button + onClick={() => { setSetupOpen(false); setPaste(""); setSetupErr(null) }} + className="text-[11px] text-gray-500 hover:text-gray-800" + > + 取消 + </button> + </div> + </div> + )} + </div> + ) +} diff --git a/web/src/components/OnboardingForm.tsx b/web/src/components/OnboardingForm.tsx new file mode 100644 index 00000000..93cdf6ef --- /dev/null +++ b/web/src/components/OnboardingForm.tsx @@ -0,0 +1,99 @@ +/** + * Generic onboarding form renderer. The active git-host provider fully owns the + * onboarding flow (see GitHostProvider.onboarding): it returns a form, loopat + * renders it blindly here, submits the values, and renders whatever the provider + * returns next — until `done`. loopat knows nothing about what the fields mean. + */ +import { useState } from "react" +import { submitOnboarding, type OnboardingForm as Form, type OnboardingStatus } from "../api" + +export function OnboardingForm({ + form, + onAdvance, +}: { + form: Form + // Called with the provider's next view after a successful submit. The parent + // re-renders the next form, or clears the gate when done. + onAdvance: (next: OnboardingStatus) => void +}) { + const [values, setValues] = useState<Record<string, string>>({}) + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + + const filledCount = form.fields.filter((f) => (values[f.name] ?? "").trim().length > 0).length + const canSubmit = + form.require === "any" ? filledCount >= 1 : filledCount === form.fields.length + + const submit = async () => { + setSaving(true) + setError("") + try { + const trimmed: Record<string, string> = {} + for (const f of form.fields) { + const v = (values[f.name] ?? "").trim() + if (v) trimmed[f.name] = v + } + const next = await submitOnboarding(trimmed) + if ("error" in next) { + setError(next.error) + return + } + onAdvance(next) + } finally { + setSaving(false) + } + } + + return ( + <div className="max-w-2xl mx-auto mt-12 px-6 py-8 rounded-lg border border-gray-200 bg-white shadow-sm"> + <div className="text-2xl mb-2">{form.title}</div> + {form.description && ( + <p className="text-sm text-gray-600 leading-relaxed mb-5">{form.description}</p> + )} + + <div className="flex flex-col gap-4"> + {form.fields.map((f) => ( + <div key={f.name} className="flex flex-col gap-1"> + <div className="flex items-baseline justify-between"> + <label className="text-sm font-medium text-gray-800">{f.label}</label> + {f.help && ( + <a + href={f.help} + target="_blank" + rel="noreferrer" + className="text-xs text-blue-600 hover:text-blue-800" + > + 获取 → + </a> + )} + </div> + <input + type={f.type === "text" ? "text" : "password"} + autoComplete="off" + spellCheck={false} + placeholder={f.placeholder ?? f.label} + value={values[f.name] ?? ""} + onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))} + className="h-9 px-3 rounded border border-gray-300 text-sm font-mono focus:outline-none focus:border-gray-500" + /> + </div> + ))} + </div> + + {error && <p className="mt-3 text-sm text-red-600">{error}</p>} + + <div className="mt-6 flex items-center gap-3"> + <button + onClick={submit} + disabled={!canSubmit || saving} + className="px-4 h-9 rounded text-sm bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" + > + {saving ? "处理中…" : form.submitLabel ?? "继续 →"} + </button> + {form.require === "any" && form.fields.length > 1 && ( + <span className="text-xs text-gray-400">至少填一个</span> + )} + </div> + </div> + ) +} diff --git a/web/src/components/OnboardingInfo.tsx b/web/src/components/OnboardingInfo.tsx new file mode 100644 index 00000000..893c222a --- /dev/null +++ b/web/src/components/OnboardingInfo.tsx @@ -0,0 +1,85 @@ +/** + * Onboarding "info" remediation: instructions the user must act on outside + * loopat (register an ssh key, request repo access, …). Shows the provider's + * text + copyable values + help links + a "re-check" button that re-runs the + * provider's onboarding check. No auto-poll — the user clicks re-check when done. + */ +import { useState } from "react" +import { getOnboarding, type OnboardingStatus } from "../api" + +export function OnboardingInfo({ + show, + onAdvance, +}: { + show: { + title: string + description?: string + values?: { label: string; value: string }[] + help?: { label: string; url: string }[] + } + onAdvance: (next: OnboardingStatus) => void +}) { + const [checking, setChecking] = useState(false) + const [copied, setCopied] = useState<string | null>(null) + + const recheck = async () => { + setChecking(true) + const next = await getOnboarding() + setChecking(false) + if (next) onAdvance(next) + } + + return ( + <div className="max-w-2xl mx-auto mt-12 px-6 py-8 rounded-lg border border-gray-200 bg-white shadow-sm"> + <div className="text-2xl mb-2">🔐 {show.title}</div> + {show.description && ( + <p className="text-sm text-gray-600 leading-relaxed mb-4 whitespace-pre-line">{show.description}</p> + )} + + {show.values && show.values.length > 0 && ( + <div className="flex flex-col gap-2 mb-4"> + {show.values.map((v) => ( + <div key={v.label}> + <div className="text-[11px] text-gray-500 mb-1">{v.label}</div> + <div className="flex items-start gap-2"> + <code className="flex-1 min-w-0 break-all bg-gray-50 border border-gray-200 rounded px-2 py-1.5 text-[10px] font-mono">{v.value}</code> + <button + onClick={() => { navigator.clipboard.writeText(v.value); setCopied(v.label); setTimeout(() => setCopied(null), 1500) }} + className="shrink-0 text-[11px] text-gray-500 hover:text-gray-800 px-1.5 py-1" + > + {copied === v.label ? "copied" : "copy"} + </button> + </div> + </div> + ))} + </div> + )} + + {show.help && show.help.length > 0 && ( + <div className="flex flex-wrap gap-3 mb-5"> + {show.help.map((h) => ( + <a + key={h.url} + href={h.url} + target="_blank" + rel="noreferrer" + className="text-xs text-blue-600 hover:text-blue-800" + > + {h.label} → + </a> + ))} + </div> + )} + + <div className="flex items-center gap-3"> + <button + onClick={recheck} + disabled={checking} + className="px-4 h-9 rounded text-sm bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-40" + > + {checking ? "检查中…" : "我已配置好,重新检查"} + </button> + </div> + </div> + ) +} diff --git a/web/src/components/ResizablePanels.tsx b/web/src/components/ResizablePanels.tsx new file mode 100644 index 00000000..d51913ff --- /dev/null +++ b/web/src/components/ResizablePanels.tsx @@ -0,0 +1,60 @@ +import { useState, useCallback } from "react" +import { Panel, Group, Separator } from "react-resizable-panels" + +type PanelId = "chat" | "workdir" | "editor" | "terminal" | "info" | "git" + +interface PanelConfig { + id: PanelId + label: string + minSize?: number + defaultSize?: number +} + +interface ResizablePanelsProps { + panels: PanelConfig[] + renderPanel: (id: PanelId) => React.ReactNode + direction?: "horizontal" | "vertical" +} + +export function ResizablePanels({ panels, renderPanel, direction = "horizontal" }: ResizablePanelsProps) { + const [sizes, setSizes] = useState<Record<string, number>>({}) + const isVert = direction === "vertical" + + const onLayout = useCallback((layout: Record<string, number>) => { + setSizes(layout) + }, []) + + if (panels.length === 0) return null + if (panels.length === 1) return <>{renderPanel(panels[0].id)}</> + + return ( + <Group + orientation={isVert ? "vertical" : "horizontal"} + className="flex-1 min-w-0 min-h-0" + onLayoutChange={onLayout} + > + {panels.map((panel) => ( + <Panel + key={panel.id} + id={panel.id} + minSize={panel.minSize ?? 15} + defaultSize={sizes[panel.id] ?? panel.defaultSize ?? undefined} + className="flex flex-col min-h-0 min-w-0" + > + {renderPanel(panel.id)} + </Panel> + ))} + {panels.slice(0, -1).map((_, i) => ( + <Separator + key={`handle-${i}`} + className={isVert + ? "relative h-1.5 cursor-row-resize group flex items-center justify-center after:absolute after:left-0 after:right-0 after:top-1/2 after:h-px after:-translate-y-1/2 after:bg-gray-200 after:transition-colors hover:after:bg-blue-400" + : "relative w-1.5 cursor-col-resize group flex items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-gray-200 after:transition-colors hover:after:bg-blue-400" + } + > + <div className={`absolute ${isVert ? "left-1/2 -translate-x-1/2 w-4 h-1.5 rounded-full bg-gray-300 group-hover:bg-blue-400" : "top-1/2 -translate-y-1/2 h-4 w-1.5 rounded-full bg-gray-300 group-hover:bg-blue-400"}`} /> + </Separator> + ))} + </Group> + ) +} diff --git a/web/src/components/SetupPersonalRepoCard.tsx b/web/src/components/SetupPersonalRepoCard.tsx new file mode 100644 index 00000000..a4671008 --- /dev/null +++ b/web/src/components/SetupPersonalRepoCard.tsx @@ -0,0 +1,83 @@ +/** + * First-thing-after-login card shown when the user hasn't imported their + * personal repo yet. Explains loopat's "no database" data model and links + * to Settings → Personal Repo. + * + * Dismiss is localStorage-only (`loopat:setupPersonalRepoDismissed`). There's + * no per-user persistence here on purpose: without a personal repo there's + * nowhere to store per-user state. The user can still operate loopat using + * the workspace's shared provider keys; "skip" means "let me explore for now, + * I'll come back if I need my own credentials." + * + * Once imported (personal.imported === true), this card hides naturally + * regardless of the dismiss flag. + */ +import { useNavigate } from "react-router-dom" + +const DISMISS_KEY = "loopat:setupPersonalRepoDismissed" + +export function isSetupPersonalRepoDismissed(): boolean { + try { + return localStorage.getItem(DISMISS_KEY) === "1" + } catch { + return false + } +} + +export function SetupPersonalRepoCard({ + onDismiss, + hideSkip, +}: { + onDismiss: () => void + // Hard-gate mode (provider requires onboarding): no "skip" — the personal + // repo is mandatory because the required keys live in its vault. + hideSkip?: boolean +}) { + const navigate = useNavigate() + + const dismiss = () => { + try { + localStorage.setItem(DISMISS_KEY, "1") + } catch {} + onDismiss() + } + + return ( + <div className="max-w-2xl mx-auto mt-12 px-6 py-8 rounded-lg border border-gray-200 bg-white shadow-sm"> + <div className="text-2xl mb-2">👋 欢迎来到 loopat</div> + <p className="text-sm text-gray-600 leading-relaxed mb-3"> + 在你开始之前,建议先配一个<b>个人仓库</b>—— + </p> + <p className="text-sm text-gray-600 leading-relaxed mb-3"> + loopat 一个反直觉的设计:服务器<b>不存你的数据</b>。你的 API key、ssh、token、 + 笔记、memory 全部存在你自己的 GitHub 私有仓库里,由你自己持有解密密钥。 + 这是 loopat 跟其他工具最大的不同。 + </p> + <p className="text-sm text-gray-600 leading-relaxed mb-5"> + 配置 3 步,大约 2 分钟:创建一个空的私有仓库 → 把 loopat 的 deploy key + 贴上去 → 把仓库 URL 填进来。配完后回到这个页面,AI 引导会自动接上。 + </p> + <div className="flex items-center gap-3"> + <button + onClick={() => navigate("/settings/personal-repo")} + className="px-4 h-9 rounded text-sm bg-gray-900 text-white hover:bg-gray-700" + > + 去配置个人仓库 → + </button> + {!hideSkip && ( + <button + onClick={dismiss} + className="text-xs text-gray-500 hover:text-gray-700" + > + 跳过(先用 workspace 的 key) + </button> + )} + </div> + <p className="mt-4 text-[11px] text-gray-400 leading-relaxed"> + 跳过后这个提示就不再出现。但 loopat 的核心安全模型依赖你的个人仓库 —— + 想用自己的凭据、记自己的 memory 时,从右上角 Settings → Personal Repo + 随时可以补上。 + </p> + </div> + ) +} diff --git a/web/src/components/ShareArtifactDialog.tsx b/web/src/components/ShareArtifactDialog.tsx new file mode 100644 index 00000000..66bb34a2 --- /dev/null +++ b/web/src/components/ShareArtifactDialog.tsx @@ -0,0 +1,446 @@ +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" +import { getServeConfig, checkAliasAvailable, getAvailablePort, checkPortAvailable, getCurrentSharePort, type LoopMeta, type ServeConfig } from "../api" +import { Globe, Copy, Check, AlertCircle, Shuffle, RefreshCw } from "lucide-react" + +type ShareMode = "static" | "port" | "direct" | "ephemeral" +type TabKey = "standard" | "direct" | "ephemeral" + +function initialTab(loop: LoopMeta): TabKey { + if (loop.shareMode === "ephemeral") return "ephemeral" + if (loop.shareExternalPort) return "direct" + return "standard" +} + +export function ShareArtifactDialog({ loop, open, onClose, onSaved }: { loop: LoopMeta; open: boolean; onClose: () => void; onSaved?: () => Promise<any> | void }) { + const [sc, setSc] = useState<ServeConfig | null>(null) + const [enabled, setEnabled] = useState(loop.shareEnabled ?? false) + const [mode, setMode] = useState<ShareMode>( + loop.shareMode === "ephemeral" ? "ephemeral" : + loop.shareExternalPort ? "direct" : + (loop.shareMode ?? "static") as ShareMode + ) + const [alias, setAlias] = useState(loop.shareAlias ?? "") + const [port, setPort] = useState(loop.sharePort ?? 3000) + const [externalPort, setExternalPort] = useState(loop.shareExternalPort ?? 10000) + const [protocol, setProtocol] = useState<"tcp" | "udp" | "static">((loop.shareProtocol as any) ?? "tcp") + const [aliasAvailable, setAliasAvailable] = useState<boolean | null>(null) + const [aliasMsg, setAliasMsg] = useState("") + const [copied, setCopied] = useState(false) + const [saving, setSaving] = useState(false) + const [idConflict, setIdConflict] = useState(false) + const [portError, setPortError] = useState("") + const [portChecking, setPortChecking] = useState(false) + const [savedMsg, setSavedMsg] = useState("") + const [error, setError] = useState("") + const [tab, setTab] = useState<TabKey>(initialTab(loop)) + // Ephemeral live port (polled while dialog is open + ephemeral tab active). + const [ephLivePort, setEphLivePort] = useState<number | null>(null) + + const shortId = loop.id.slice(0, 8) + const standardEnabled = !!(sc && sc.serveEnabled) + const directEnabled = !!(sc && sc.serveDynamicEnabled) + const ephEnabled = !!(sc && sc.serveEphemeralEnabled) + const enabledTabsCount = [standardEnabled, directEnabled, ephEnabled].filter(Boolean).length + const showTabs = enabledTabsCount > 1 + const standardOnly = standardEnabled && !directEnabled && !ephEnabled + const directOnly = !standardEnabled && directEnabled && !ephEnabled + const ephOnly = !standardEnabled && !directEnabled && ephEnabled + const bothOn = standardEnabled && directEnabled // legacy alias kept for the existing standard/direct tab rendering + + useEffect(() => { + if (open) { + getServeConfig().then(setSc) + setEnabled(loop.shareEnabled ?? false) + setMode( + loop.shareMode === "ephemeral" ? "ephemeral" : + loop.shareExternalPort ? "direct" : + (loop.shareMode ?? "static") as ShareMode + ) + setAlias(loop.shareAlias ?? "") + setPort(loop.sharePort ?? 3000) + setExternalPort(loop.shareExternalPort ?? 10000) + setProtocol(loop.shareProtocol ?? "tcp") + setTab(initialTab(loop)) + setAliasAvailable(null) + setAliasMsg("") + setPortError("") + setEphLivePort(null) + } + }, [open, loop]) + + // Poll the loop's current host port while the dialog is open and the + // ephemeral tab is active. Loop restart picks a new port; this loop + // reflects that without the user manually reloading. + useEffect(() => { + if (!open) return + const isEphTab = ephOnly || (showTabs && tab === "ephemeral") + if (!isEphTab) return + if (!loop.shareEnabled || loop.shareMode !== "ephemeral") return + let stopped = false + const tick = async () => { + const r = await getCurrentSharePort(loop.id) + if (!stopped) setEphLivePort(r.port) + } + tick() + const h = setInterval(tick, 3000) + return () => { stopped = true; clearInterval(h) } + }, [open, tab, ephOnly, showTabs, loop.id, loop.shareEnabled, loop.shareMode]) + + // Auto-pick port after serve config loads. Only for fresh loops that + // have never had share enabled — if the user already saved a config + // (any mode), leave it alone. + useEffect(() => { + if (!open || !sc) return + if (!sc.serveDynamicEnabled) return + if (loop.shareEnabled && loop.shareExternalPort) return // already configured + if (!loop.shareEnabled && !loop.shareExternalPort) { + getAvailablePort().then((r) => { + if (r.port) setExternalPort(r.port) + }) + } + }, [open, sc, loop.shareEnabled, loop.shareExternalPort]) + + useEffect(() => { + if (!open) return + checkAliasAvailable(shortId, loop.id).then((r) => { + if (!r.available) { setIdConflict(true); setAliasMsg(`ID "${shortId}" is already in use. Please set an alias.`) } + else setIdConflict(false) + }) + }, [open, shortId, loop.id]) + + useEffect(() => { + if (!alias || !open) { setAliasAvailable(null); setAliasMsg(""); return } + const t = setTimeout(async () => { + const r = await checkAliasAvailable(alias, loop.id) + setAliasAvailable(r.available); setAliasMsg(r.reason ?? "") + }, 300) + return () => clearTimeout(t) + }, [alias, open, loop.id]) + + // URL building + const shareHost = alias || shortId + const protocolPrefix = sc?.https ? "https" : "http" + const portSuffix = sc?.withPort ? `:${sc.displayPort}` : "" + const subdomainUrl = sc ? `${protocolPrefix}://${shareHost}${sc.baseUrl}${portSuffix}` : "" + + const dynHost = sc?.serveDynamicDomain || sc?.ip || "<host-ip>" + const directUrl = protocol === "static" + ? `http://${dynHost}:${externalPort}` + : `tcp://${dynHost}:${externalPort}` + + const ephHost = sc?.serveEphemeralDomain || sc?.ip || "<host-ip>" + const ephProtoPrefix = protocol === "udp" ? "udp" : "tcp" + const ephUrl = ephLivePort + ? `${ephProtoPrefix}://${ephHost}:${ephLivePort}` + : "(no active port — save & wait for container restart)" + + const isDirectTab = directOnly || (showTabs && tab === "direct" && directEnabled && !ephOnly) + const isEphTab = ephOnly || (showTabs && tab === "ephemeral") + const shareUrl = isEphTab ? ephUrl : isDirectTab ? directUrl : subdomainUrl + + // Validate external port availability (debounced) + useEffect(() => { + if (!open || !isDirectTab) return + const t = setTimeout(async () => { + setPortChecking(true) + const r = await checkPortAvailable(externalPort, loop.id) + setPortChecking(false) + if (!r.available) setPortError(r.reason ?? "Port unavailable") + else setPortError("") + }, 400) + return () => clearTimeout(t) + }, [externalPort, open, mode, loop.id]) + + const handleSave = async () => { + setSaving(true) + // Final port check on save (direct tab only — ephemeral kernel-picks, + // standard doesn't need it). + if (isDirectTab) { + const r = await checkPortAvailable(externalPort, loop.id) + if (!r.available) { + setPortError(r.reason ?? "Port unavailable") + setSaving(false) + return + } + } + const patch: Record<string, any> = { shareEnabled: enabled } + if (enabled) { + if (isEphTab) { + // Ephemeral: only need sharePort (internal) + protocol; host port + // is assigned by kernel on container start. + patch.shareMode = "ephemeral" + patch.sharePort = port + patch.shareProtocol = protocol === "static" ? "tcp" : protocol + patch.shareAlias = undefined + patch.shareExternalPort = undefined + } else if (isDirectTab) { + patch.shareMode = "port" + patch.sharePort = port + patch.shareExternalPort = externalPort + patch.shareProtocol = protocol + patch.shareAlias = undefined + } else { + patch.shareMode = mode + patch.shareAlias = alias.trim() || undefined + if (mode === "port") patch.sharePort = port + patch.shareExternalPort = undefined + patch.shareProtocol = undefined + } + } else { + patch.shareMode = undefined; patch.shareAlias = undefined; patch.sharePort = undefined + // Keep shareExternalPort and shareProtocol — restoring on re-enable + } + const r = await fetch(`/api/loops/${loop.id}`, { + method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch), + }) + setSaving(false) + if (r.ok) { + setSavedMsg("Saved") + await onSaved?.() + setTimeout(() => { setSavedMsg(""); onClose() }, 800) + } else { + try { const j = await r.json(); setError(j.error ?? `Save failed (${r.status})`) } + catch { setError(`Save failed (${r.status})`) } + } + } + + const copyUrl = async () => { + try { await navigator.clipboard.writeText(shareUrl); setCopied(true); setTimeout(() => setCopied(false), 1500) } + catch { window.prompt("Copy this URL:", shareUrl) } + } + + const canSave = + !saving && !portError && !portChecking && + (isEphTab + ? (port >= 1 && port <= 65535) + : ( + (!isDirectTab || (externalPort >= 1024)) && + (!isDirectTab || (port >= 1 || protocol === "static")) && + (isDirectTab || mode !== "port" || (!idConflict || alias.trim().length > 0)) && + (isDirectTab || mode !== "port" || (port >= 1024)) + )) + + if (!sc) return null + + return ( + <Dialog open={open} onOpenChange={(o) => { if (!o) onClose() }}> + <DialogContent className="sm:max-w-md bg-white max-h-[85vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2 text-sm"> + <Globe size={16} /> + Share Artifact + </DialogTitle> + <DialogDescription className="sr-only">Share this artifact via a public URL.</DialogDescription> + </DialogHeader> + + <div className="space-y-4 text-[13px]"> + {/* Enable toggle */} + <div className="flex items-center justify-between"> + <span className="text-sm text-gray-700">Enable sharing</span> + <button + onClick={() => setEnabled(!enabled)} + className={`w-10 h-5 rounded-full transition-colors ${enabled ? "bg-blue-600" : "bg-gray-300"}`} + > + <div className={`w-4 h-4 rounded-full bg-white shadow transition-transform ${enabled ? "translate-x-5" : "translate-x-0.5"}`} /> + </button> + </div> + + {enabled && ( + <> + {/* Tabs when more than one serve mode is enabled in workspace config */} + {showTabs && ( + <div className="flex gap-1.5 border-b border-gray-200 pb-2"> + {standardEnabled && ( + <button onClick={() => { setTab("standard"); setMode("static") }} + className={`px-3 py-1 text-xs rounded-t ${tab === "standard" ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-500 hover:text-gray-700"}`}> + Subdomain + </button> + )} + {directEnabled && ( + <button onClick={() => { setTab("direct"); setMode("direct") }} + className={`px-3 py-1 text-xs rounded-t ${tab === "direct" ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-500 hover:text-gray-700"}`}> + Direct Port + </button> + )} + {ephEnabled && ( + <button onClick={() => { setTab("ephemeral"); setMode("ephemeral") }} + className={`px-3 py-1 text-xs rounded-t ${tab === "ephemeral" ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-500 hover:text-gray-700"}`}> + Ephemeral + </button> + )} + </div> + )} + + {/* ── Standard (subdomain) modes ── */} + {(standardOnly || (showTabs && tab === "standard")) && ( + <> + <div> + <label className="text-[11px] text-gray-500 uppercase tracking-wider">Share mode</label> + <div className="grid grid-cols-2 gap-1.5 mt-1"> + <button onClick={() => setMode("static")} + className={`px-2 py-1.5 text-xs rounded border ${mode === "static" ? "bg-blue-50 border-blue-300 text-blue-700" : "border-gray-200 text-gray-600"}`}> + Static files + </button> + <button onClick={() => setMode("port")} + className={`px-2 py-1.5 text-xs rounded border ${mode === "port" ? "bg-blue-50 border-blue-300 text-blue-700" : "border-gray-200 text-gray-600"}`}> + Port proxy + </button> + </div> + </div> + + <div> + <label className="text-[11px] text-gray-500 uppercase tracking-wider"> + Alias {idConflict ? <span className="text-red-500">(required)</span> : "(optional)"} + </label> + <input value={alias} + onChange={(e) => setAlias(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} + className={`w-full mt-1 px-2 py-1.5 text-sm border rounded outline-none focus:border-gray-300 ${idConflict ? "border-red-300 bg-red-50" : "border-gray-200"}`} + placeholder={idConflict ? "Set an alias to resolve conflict" : "my-project"} /> + {idConflict && <span className="text-[11px] text-red-500 mt-0.5 flex items-center gap-1"><AlertCircle size={11} /> {aliasMsg || `ID "${shortId}" is already in use`}</span>} + {!idConflict && aliasAvailable === true && <span className="text-[11px] text-emerald-600 mt-0.5">Available</span>} + {!idConflict && aliasAvailable === false && <span className="text-[11px] text-red-500 mt-0.5 flex items-center gap-1"><AlertCircle size={11} /> {aliasMsg || "Already in use"}</span>} + </div> + + {mode === "port" && ( + <div> + <label className="text-[11px] text-gray-500 uppercase tracking-wider">Port (1024-65535)</label> + <input type="number" min={1024} max={65535} value={port} + onChange={(e) => setPort(parseInt(e.target.value, 10) || 3000)} + className="w-full mt-1 px-2 py-1.5 text-sm border border-gray-200 rounded outline-none focus:border-gray-300" /> + </div> + )} + </> + )} + + {/* ── Ephemeral Port mode ── */} + {isEphTab && ( + <> + <div> + <label className="text-[11px] text-gray-500 uppercase tracking-wider">Container port</label> + <input type="number" min={1} max={65535} value={port} + onChange={(e) => setPort(parseInt(e.target.value, 10) || 3000)} + className="w-full mt-1 px-2 py-1.5 text-sm border border-gray-200 rounded outline-none focus:border-gray-300" /> + <p className="text-[10px] text-gray-400 mt-0.5">The port your app listens on inside the sandbox</p> + </div> + + <div> + <label className="text-[11px] text-gray-500 uppercase tracking-wider">Protocol</label> + <div className="flex gap-1.5 mt-1"> + <button onClick={() => setProtocol("tcp")} + className={`flex-1 px-2 py-1.5 text-xs rounded border ${protocol === "tcp" ? "bg-blue-50 border-blue-300 text-blue-700" : "border-gray-200 text-gray-600"}`}>TCP</button> + <button onClick={() => setProtocol("udp")} + className={`flex-1 px-2 py-1.5 text-xs rounded border ${protocol === "udp" ? "bg-blue-50 border-blue-300 text-blue-700" : "border-gray-200 text-gray-600"}`}>UDP</button> + </div> + </div> + + <div> + <label className="text-[11px] text-gray-500 uppercase tracking-wider flex items-center gap-1.5"> + Current host port + <RefreshCw size={10} className="text-gray-300" /> + </label> + <div className="mt-1 px-2 py-1.5 text-sm bg-gray-50 rounded text-gray-700"> + {ephLivePort != null ? ( + <span className="font-mono">{ephLivePort}</span> + ) : ( + <span className="text-gray-400 text-xs"> + {loop.shareEnabled && loop.shareMode === "ephemeral" + ? "(container starting / not running)" + : "(save to start — port assigned by kernel on restart)"} + </span> + )} + </div> + <p className="text-[10px] text-amber-600 mt-1 flex items-center gap-1"> + <AlertCircle size={10} /> Changes on every loop restart. Saving also restarts the container. + </p> + </div> + </> + )} + + {/* ── Direct Port mode ── */} + {(directOnly || (showTabs && tab === "direct" && !isEphTab)) && ( + <> + <div> + <label className="text-[11px] text-gray-500 uppercase tracking-wider">External port</label> + <div className="flex gap-1.5 mt-1"> + <input type="number" min={1024} max={65535} value={externalPort} + onChange={(e) => setExternalPort(parseInt(e.target.value, 10) || 10000)} + className="flex-1 px-2 py-1.5 text-sm border border-gray-200 rounded outline-none focus:border-gray-300" placeholder="10000" /> + <button + onClick={async () => { + const r = await getAvailablePort() + if (r.port) setExternalPort(r.port) + }} + className="px-2 py-1.5 rounded border border-gray-200 hover:bg-gray-100 text-gray-500" + title="Pick a random available port" + > + <Shuffle size={14} /> + </button> + </div> + <p className="text-[10px] text-gray-400 mt-0.5">The public port clients connect to</p> + {portChecking && <span className="text-[11px] text-gray-400 mt-0.5">Checking availability…</span>} + {portError && <span className="text-[11px] text-red-500 mt-0.5 flex items-center gap-1"><AlertCircle size={11} /> {portError}</span>} + {!portChecking && !portError && <span className="text-[11px] text-emerald-600 mt-0.5">Available</span>} + </div> + + {protocol !== "static" && ( + <div> + <label className="text-[11px] text-gray-500 uppercase tracking-wider">Container port</label> + <input type="number" min={1} max={65535} value={port} + onChange={(e) => setPort(parseInt(e.target.value, 10) || 3000)} + className="w-full mt-1 px-2 py-1.5 text-sm border border-gray-200 rounded outline-none focus:border-gray-300" /> + <p className="text-[10px] text-gray-400 mt-0.5">The port your app listens on inside the sandbox</p> + </div> + )} + + <div> + <label className="text-[11px] text-gray-500 uppercase tracking-wider">Protocol</label> + <div className="flex gap-1.5 mt-1"> + <button onClick={() => setProtocol("tcp")} + className={`flex-1 px-2 py-1.5 text-xs rounded border ${protocol === "tcp" ? "bg-blue-50 border-blue-300 text-blue-700" : "border-gray-200 text-gray-600"}`}>TCP</button> + {sc.serveDynamicUdpEnabled && ( + <button onClick={() => setProtocol("udp")} + className={`flex-1 px-2 py-1.5 text-xs rounded border ${protocol === "udp" ? "bg-blue-50 border-blue-300 text-blue-700" : "border-gray-200 text-gray-600"}`}>UDP</button> + )} + {sc.serveDynamicStaticEnabled && ( + <button onClick={() => setProtocol("static")} + className={`flex-1 px-2 py-1.5 text-xs rounded border ${protocol === "static" ? "bg-blue-50 border-blue-300 text-blue-700" : "border-gray-200 text-gray-600"}`}>Static</button> + )} + </div> + </div> + </> + )} + + {/* Access URL */} + <div> + <label className="text-[11px] text-gray-500 uppercase tracking-wider">Access URL</label> + <div className="flex gap-2 mt-1"> + <code className="flex-1 px-2 py-1.5 text-xs bg-gray-50 rounded text-gray-700 truncate">{shareUrl}</code> + <button onClick={copyUrl} className="px-2 py-1.5 rounded hover:bg-gray-100 text-gray-500" title="Copy URL"> + {copied ? <Check size={14} className="text-emerald-600" /> : <Copy size={14} />} + </button> + </div> + <p className="text-[10px] text-gray-400 mt-1"> + {isDirectTab + ? "Direct TCP/UDP access — no domain needed" + : "Domain suffix configured in Settings → Workspace → Workspace Serve"} + </p> + </div> + </> + )} + + {/* Save button */} + <div className="flex items-center justify-end gap-2 pt-2"> + {error && <span className="text-xs text-red-500">{error}</span>} + {savedMsg && <span className="text-xs text-emerald-600 flex items-center gap-1"><Check size={12} /> {savedMsg}</span>} + <button onClick={onClose} className="px-3 py-1.5 text-xs rounded bg-gray-100 text-gray-700 hover:bg-gray-200">Cancel</button> + <button onClick={handleSave} disabled={!canSave} + className="px-3 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"> + {saving ? "Saving..." : "Save"} + </button> + </div> + </div> + </DialogContent> + </Dialog> + ) +} diff --git a/web/src/components/TopicChip.tsx b/web/src/components/TopicChip.tsx new file mode 100644 index 00000000..6e029f13 --- /dev/null +++ b/web/src/components/TopicChip.tsx @@ -0,0 +1,52 @@ +/** + * TopicChip — small clickable pill rendering #xxx. + * + * Topic is the cross-entity association concept: anything that mentions + * `#xxx` in its content (focus markdown body, loop title, future channel) + * is associated. Clicking a chip jumps to the topic view aggregating all + * entities that share this topic. + */ + +import { type MouseEvent } from "react" + +export function TopicChip({ + name, + onClick, + onEdit, + size = "sm", +}: { + name: string + onClick?: (e: MouseEvent<HTMLButtonElement>) => void + onEdit?: () => void + size?: "sm" | "md" +}) { + const cls = + size === "md" + ? "px-2 py-0.5 text-[12px]" + : "px-1.5 py-0.5 text-[10px]" + return ( + <span className="relative inline-flex items-center group/chip"> + <button + type="button" + onClick={onClick} + className={ + "inline-flex items-center rounded-full bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 transition-colors font-mono " + + cls + } + title={`#${name}`} + > + #{name} + </button> + {onEdit && ( + <button + type="button" + onClick={(e) => { e.stopPropagation(); onEdit() }} + className="absolute -top-1 -right-1 z-10 w-4 h-4 rounded-full bg-white border border-gray-300 hover:border-red-400 hover:text-red-500 text-[9px] text-gray-400 opacity-0 group-hover/chip:opacity-100 transition-opacity flex items-center justify-center shadow-sm" + title="Remove tag" + > + × + </button> + )} + </span> + ) +} diff --git a/web/src/components/Tree.tsx b/web/src/components/Tree.tsx new file mode 100644 index 00000000..d441fb6d --- /dev/null +++ b/web/src/components/Tree.tsx @@ -0,0 +1,370 @@ +/** + * Generic reusable tree component with: + * - Visual hierarchy (icons, indentation) + * - Right-click context menu (portal-rendered) + * - Persisted expansion state (localStorage) + */ +import { useEffect, useState, useRef, useCallback, type ReactNode } from "react" +import { createPortal } from "react-dom" +import { Folder, FolderOpen, File, Upload, Trash2, Eye, FilePlus, FolderPlus, Plus } from "lucide-react" + +const EXPANDED_PREFIX = "loopat:tree:expanded:" + +function getExpanded(treeId: string): Set<string> { + try { + const raw = localStorage.getItem(EXPANDED_PREFIX + treeId) + if (!raw) return new Set() + return new Set(JSON.parse(raw)) + } catch { + return new Set() + } +} + +function setExpanded(treeId: string, paths: Set<string>) { + try { + localStorage.setItem(EXPANDED_PREFIX + treeId, JSON.stringify([...paths])) + } catch {} +} + +export type TreeNodeData = { + name: string + path: string + type: "file" | "dir" + size?: number +} + +export type TreeContextAction = { + label: string + icon: ReactNode + action: string + danger?: boolean + hidden?: boolean +} + +export type TreeProps = { + /** Unique ID for persisting expansion state */ + treeId: string + /** Root entries to render */ + entries: TreeNodeData[] + /** Called when a file is picked */ + onPick: (path: string) => void + /** Currently picked file path */ + picked: string | null + /** Called to load children of a directory */ + onLoadChildren: (path: string) => Promise<TreeNodeData[]> + /** Returns context menu items for a node */ + getContextActions: (node: TreeNodeData) => TreeContextAction[] + /** Called when a context action is triggered */ + onAction: (action: string, node: TreeNodeData) => void + /** Depth offset (default 0) */ + depthOffset?: number + /** Custom node renderer (optional) */ + renderNode?: (node: TreeNodeData, depth: number, isOpen: boolean, toggleOpen: () => void) => ReactNode + /** Optional className for the row element (for special styling without custom renderer) */ + nodeClassName?: (node: TreeNodeData, depth: number, isOpen: boolean, isPicked: boolean) => string + /** Bump to force re-fetch children for all nodes */ + reloadKey?: number +} + +export function Tree({ + treeId, + entries, + onPick, + picked, + onLoadChildren, + getContextActions, + onAction, + depthOffset = 0, + renderNode, + nodeClassName, + reloadKey, +}: TreeProps) { + return ( + <> + {entries.map((e) => ( + <TreeNode + key={e.path} + treeId={treeId} + entry={e} + depth={depthOffset} + onPick={onPick} + picked={picked} + onLoadChildren={onLoadChildren} + getContextActions={getContextActions} + onAction={onAction} + renderNode={renderNode} + nodeClassName={nodeClassName} + reloadKey={reloadKey} + /> + ))} + </> + ) +} + +function TreeNode({ + treeId, + entry, + depth, + onPick, + picked, + onLoadChildren, + getContextActions, + onAction, + renderNode, + nodeClassName, + reloadKey, +}: { + treeId: string + entry: TreeNodeData + depth: number + onPick: (path: string) => void + picked: string | null + onLoadChildren: (path: string) => Promise<TreeNodeData[]> + getContextActions: (node: TreeNodeData) => TreeContextAction[] + onAction: (action: string, node: TreeNodeData) => void + renderNode?: (node: TreeNodeData, depth: number, isOpen: boolean, toggleOpen: () => void) => ReactNode + nodeClassName?: (node: TreeNodeData, depth: number, isOpen: boolean, isPicked: boolean) => string + reloadKey?: number +}) { + const [open, setOpen] = useState(() => getExpanded(treeId).has(entry.path)) + const [children, setChildren] = useState<TreeNodeData[] | null>(null) + const [loading, setLoading] = useState(false) + const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) + + const toggleOpen = useCallback(() => { + const expanded = getExpanded(treeId) + if (expanded.has(entry.path)) expanded.delete(entry.path) + else expanded.add(entry.path) + setExpanded(treeId, expanded) + setOpen((o) => !o) + }, [treeId, entry.path]) + + const prevReloadKey = useRef(reloadKey) + useEffect(() => { + if (prevReloadKey.current !== reloadKey) { + setChildren(null) + prevReloadKey.current = reloadKey + } + }, [reloadKey]) + + useEffect(() => { + if (!open || children !== null) return + if (entry.type !== "dir") return + setLoading(true) + onLoadChildren(entry.path) + .then(setChildren) + .finally(() => setLoading(false)) + }, [open, entry.path, onLoadChildren, children]) + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setMenuPos({ x: e.clientX, y: e.clientY }) + } + + const paddingLeft = 8 + depth * 12 + + if (entry.type === "dir") { + if (renderNode) { + return ( + <> + <div onContextMenu={handleContextMenu}> + {renderNode(entry, depth, open, toggleOpen)} + </div> + {open && children && ( + <> + {children.map((child) => ( + <TreeNode + key={child.path} + treeId={treeId} + entry={child} + depth={depth + 1} + onPick={onPick} + picked={picked} + onLoadChildren={onLoadChildren} + getContextActions={getContextActions} + onAction={onAction} + renderNode={renderNode} + nodeClassName={nodeClassName} + reloadKey={reloadKey} + /> + ))} + </> + )} + {menuPos && ( + <ContextMenu + x={menuPos.x} + y={menuPos.y} + items={getContextActions(entry).filter((a) => !a.hidden)} + onAction={(action) => onAction(action, entry)} + onClose={() => setMenuPos(null)} + /> + )} + </> + ) + } + + return ( + <> + <div onContextMenu={handleContextMenu}> + <button + type="button" + onClick={toggleOpen} + className={nodeClassName + ? nodeClassName(entry, depth, open, false) + : "w-full py-1 flex items-center gap-1.5 hover:bg-gray-50 text-left group" + } + style={{ paddingLeft, paddingRight: 8 }} + > + <span className="text-gray-500 w-3">{open ? "▾" : "▸"}</span> + <span className="text-gray-500">{open ? <FolderOpen size={13} /> : <Folder size={13} />}</span> + <span className="text-[13px] text-gray-900 truncate">{entry.name}</span> + </button> + </div> + {loading && ( + <div className="text-[12px] text-gray-400 italic" style={{ paddingLeft: paddingLeft + 12 }}> + ... + </div> + )} + {open && children && ( + <> + {children.map((child) => ( + <TreeNode + key={child.path} + treeId={treeId} + entry={child} + depth={depth + 1} + onPick={onPick} + picked={picked} + onLoadChildren={onLoadChildren} + getContextActions={getContextActions} + onAction={onAction} + renderNode={renderNode} + nodeClassName={nodeClassName} + reloadKey={reloadKey} + /> + ))} + {children.length === 0 && ( + <div className="text-[12px] text-gray-400 italic py-1" style={{ paddingLeft: paddingLeft + 12 }}> + (empty) + </div> + )} + </> + )} + {menuPos && ( + <ContextMenu + x={menuPos.x} + y={menuPos.y} + items={getContextActions(entry).filter((a) => !a.hidden)} + onAction={(action) => onAction(action, entry)} + onClose={() => setMenuPos(null)} + /> + )} + </> + ) + } + + const isPicked = picked === entry.path + + if (renderNode) { + return ( + <> + <div + onContextMenu={handleContextMenu} + onClick={() => onPick(entry.path)} + > + {renderNode(entry, depth, false, () => {})} + </div> + {menuPos && ( + <ContextMenu + x={menuPos.x} + y={menuPos.y} + items={getContextActions(entry).filter((a) => !a.hidden)} + onAction={(action) => onAction(action, entry)} + onClose={() => setMenuPos(null)} + /> + )} + </> + ) + } + + return ( + <> + <div + className="relative" + onContextMenu={handleContextMenu} + > + <button + type="button" + onClick={() => onPick(entry.path)} + className={nodeClassName + ? nodeClassName(entry, depth, false, isPicked) + : ("w-full py-1 flex items-center gap-2 text-left " + + (isPicked ? "bg-gray-100" : "hover:bg-gray-50")) + } + style={{ paddingLeft, paddingRight: 8 }} + > + <span className="w-3 text-gray-400"><File size={13} /></span> + <span className="text-[13px] text-gray-900 flex-1 min-w-0 truncate">{entry.name}</span> + </button> + {menuPos && ( + <ContextMenu + x={menuPos.x} + y={menuPos.y} + items={getContextActions(entry).filter((a) => !a.hidden)} + onAction={(action) => onAction(action, entry)} + onClose={() => setMenuPos(null)} + /> + )} + </div> + </> + ) +} + +function ContextMenu({ x, y, items, onAction, onClose }: { + x: number; y: number; + items: { label: string; icon: ReactNode; action: string; danger?: boolean }[]; + onAction: (action: string) => void; + onClose: () => void; +}) { + useEffect(() => { + const handler = (e: MouseEvent) => { + const target = e.target as HTMLElement | null + if (target?.closest("[data-context-menu]")) return + onClose() + } + const keyHandler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose() + } + document.addEventListener("mousedown", handler) + document.addEventListener("keydown", keyHandler) + return () => { + document.removeEventListener("mousedown", handler) + document.removeEventListener("keydown", keyHandler) + } + }, [onClose]) + + return createPortal( + <div + data-context-menu + className="fixed z-[9999] bg-white border border-gray-200 rounded-lg shadow-lg py-1 text-[12px] min-w-[140px]" + style={{ left: x, top: y }} + onContextMenu={(e) => e.preventDefault()} + > + {items.map((item) => ( + <button + key={item.action} + onClick={() => { onAction(item.action); onClose() }} + className={ + "w-full px-3 py-1.5 flex items-center gap-2 text-left hover:bg-gray-50 " + + (item.danger ? "text-red-600 hover:bg-red-50" : "text-gray-700") + } + > + {item.icon} + {item.label} + </button> + ))} + </div>, + document.body + ) +} diff --git a/web/src/components/assistant-ui/attachment.tsx b/web/src/components/assistant-ui/attachment.tsx new file mode 100644 index 00000000..b18739fc --- /dev/null +++ b/web/src/components/assistant-ui/attachment.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { type PropsWithChildren, useEffect, useState, type FC } from "react"; +import { XIcon, PlusIcon, FileText } from "lucide-react"; +import { + AttachmentPrimitive, + ComposerPrimitive, + MessagePrimitive, + useAuiState, + useAui, +} from "@assistant-ui/react"; +import { useShallow } from "zustand/shallow"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; + +const useFileSrc = (file: File | undefined) => { + const [src, setSrc] = useState<string | undefined>(undefined); + + useEffect(() => { + if (!file) { + setSrc(undefined); + return; + } + + const objectUrl = URL.createObjectURL(file); + setSrc(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [file]); + + return src; +}; + +const useAttachmentSrc = () => { + const { file, src } = useAuiState( + useShallow((s): { file?: File; src?: string } => { + if (s.attachment.type !== "image") return {}; + if (s.attachment.file) return { file: s.attachment.file }; + const src = s.attachment.content?.filter((c) => c.type === "image")[0] + ?.image; + if (!src) return {}; + return { src }; + }), + ); + + return useFileSrc(file) ?? src; +}; + +type AttachmentPreviewProps = { + src: string; +}; + +const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src }) => { + const [isLoaded, setIsLoaded] = useState(false); + return ( + <img + src={src} + alt="Attachment preview" + className={cn( + "block h-auto max-h-[80vh] w-auto max-w-full object-contain", + isLoaded + ? "aui-attachment-preview-image-loaded" + : "aui-attachment-preview-image-loading invisible", + )} + onLoad={() => setIsLoaded(true)} + /> + ); +}; + +const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => { + const src = useAttachmentSrc(); + + if (!src) return children; + + return ( + <Dialog> + <DialogTrigger + className="aui-attachment-preview-trigger cursor-pointer transition-colors hover:bg-accent/50" + asChild + > + {children} + </DialogTrigger> + <DialogContent className="aui-attachment-preview-dialog-content p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:bg-foreground/60 [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0! [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive"> + <DialogTitle className="aui-sr-only sr-only"> + Image Attachment Preview + </DialogTitle> + <div className="aui-attachment-preview relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden bg-background"> + <AttachmentPreview src={src} /> + </div> + </DialogContent> + </Dialog> + ); +}; + +const AttachmentThumb: FC = () => { + const src = useAttachmentSrc(); + + return ( + <Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none"> + <AvatarImage + src={src} + alt="Attachment preview" + className="aui-attachment-tile-image object-cover" + /> + <AvatarFallback> + <FileText className="aui-attachment-tile-fallback-icon size-8 text-muted-foreground" /> + </AvatarFallback> + </Avatar> + ); +}; + +const AttachmentUI: FC = () => { + const aui = useAui(); + const isComposer = aui.attachment.source !== "message"; + + const isImage = useAuiState((s) => s.attachment.type === "image"); + const typeLabel = useAuiState((s) => { + const type = s.attachment.type; + switch (type) { + case "image": + return "Image"; + case "document": + return "Document"; + case "file": + return "File"; + default: + return type; + } + }); + + return ( + <Tooltip> + <AttachmentPrimitive.Root + className={cn( + "aui-attachment-root relative", + isImage && "aui-attachment-root-composer only:*:first:size-24", + )} + > + <AttachmentPreviewDialog> + <TooltipTrigger asChild> + <div + className="aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[calc(var(--composer-radius)-var(--composer-padding))] border bg-muted transition-opacity hover:opacity-75" + role="button" + tabIndex={0} + aria-label={`${typeLabel} attachment`} + > + <AttachmentThumb /> + </div> + </TooltipTrigger> + </AttachmentPreviewDialog> + {isComposer && <AttachmentRemove />} + </AttachmentPrimitive.Root> + <TooltipContent side="top"> + <AttachmentPrimitive.Name /> + </TooltipContent> + </Tooltip> + ); +}; + +const AttachmentRemove: FC = () => { + return ( + <AttachmentPrimitive.Remove asChild> + <TooltipIconButton + tooltip="Remove file" + className="aui-attachment-tile-remove absolute end-1.5 top-1.5 size-3.5 rounded-full bg-white text-muted-foreground opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black hover:[&_svg]:text-destructive" + side="top" + > + <XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]" /> + </TooltipIconButton> + </AttachmentPrimitive.Remove> + ); +}; + +export const UserMessageAttachments: FC = () => { + return ( + <div className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row justify-end gap-2"> + <MessagePrimitive.Attachments> + {() => <AttachmentUI />} + </MessagePrimitive.Attachments> + </div> + ); +}; + +export const ComposerAttachments: FC = () => { + return ( + <div className="aui-composer-attachments flex w-full flex-row items-center gap-2 overflow-x-auto empty:hidden"> + <ComposerPrimitive.Attachments> + {() => <AttachmentUI />} + </ComposerPrimitive.Attachments> + </div> + ); +}; + +export const ComposerAddAttachment: FC = () => { + return ( + <ComposerPrimitive.AddAttachment asChild> + <TooltipIconButton + tooltip="Add Attachment" + side="bottom" + variant="ghost" + size="icon" + className="aui-composer-add-attachment size-8 rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30" + aria-label="Add Attachment" + > + <PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" /> + </TooltipIconButton> + </ComposerPrimitive.AddAttachment> + ); +}; diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx new file mode 100644 index 00000000..c0852ed0 --- /dev/null +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -0,0 +1,245 @@ +"use client"; + +import "@assistant-ui/react-markdown/styles/dot.css"; + +import { + type CodeHeaderProps, + MarkdownTextPrimitive, + unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, + useIsMarkdownCodeBlock, +} from "@assistant-ui/react-markdown"; +import remarkGfm from "remark-gfm"; +import { type FC, memo, useState } from "react"; +import { CheckIcon, CopyIcon } from "lucide-react"; + +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; + +const MarkdownTextImpl = () => { + return ( + <MarkdownTextPrimitive + remarkPlugins={[remarkGfm]} + className="aui-md" + components={defaultComponents} + /> + ); +}; + +export const MarkdownText = memo(MarkdownTextImpl); + +const CodeHeader: FC<CodeHeaderProps> = ({ language, code }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard(); + const onCopy = () => { + if (!code || isCopied) return; + copyToClipboard(code); + }; + + return ( + <div className="aui-code-header-root mt-2.5 flex items-center justify-between rounded-t-lg border border-border/50 border-b-0 bg-muted/50 px-3 py-1.5 text-xs"> + <span className="aui-code-header-language font-medium text-muted-foreground lowercase"> + {language} + </span> + <TooltipIconButton tooltip="Copy" onClick={onCopy}> + {!isCopied && <CopyIcon />} + {isCopied && <CheckIcon />} + </TooltipIconButton> + </div> + ); +}; + +const useCopyToClipboard = ({ + copiedDuration = 3000, +}: { + copiedDuration?: number; +} = {}) => { + const [isCopied, setIsCopied] = useState<boolean>(false); + + const copyToClipboard = (value: string) => { + if (!value) return; + + navigator.clipboard.writeText(value).then(() => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), copiedDuration); + }); + }; + + return { isCopied, copyToClipboard }; +}; + +const defaultComponents = memoizeMarkdownComponents({ + h1: ({ className, ...props }) => ( + <h1 + className={cn( + "aui-md-h1 mb-2 scroll-m-20 font-semibold text-base first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + h2: ({ className, ...props }) => ( + <h2 + className={cn( + "aui-md-h2 mt-3 mb-1.5 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + h3: ({ className, ...props }) => ( + <h3 + className={cn( + "aui-md-h3 mt-2.5 mb-1 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + h4: ({ className, ...props }) => ( + <h4 + className={cn( + "aui-md-h4 mt-2 mb-1 scroll-m-20 font-medium text-sm first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + h5: ({ className, ...props }) => ( + <h5 + className={cn( + "aui-md-h5 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + h6: ({ className, ...props }) => ( + <h6 + className={cn( + "aui-md-h6 mt-2 mb-1 font-medium text-sm first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + p: ({ className, ...props }) => ( + <p + className={cn( + "aui-md-p my-2.5 leading-normal first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + a: ({ className, ...props }) => ( + <a + className={cn( + "aui-md-a text-primary underline underline-offset-2 hover:text-primary/80", + className, + )} + {...props} + /> + ), + blockquote: ({ className, ...props }) => ( + <blockquote + className={cn( + "aui-md-blockquote my-2.5 border-muted-foreground/30 border-s-2 ps-3 text-muted-foreground italic", + className, + )} + {...props} + /> + ), + ul: ({ className, ...props }) => ( + <ul + className={cn( + "aui-md-ul my-2 ms-4 list-disc marker:text-muted-foreground [&>li]:mt-1", + className, + )} + {...props} + /> + ), + ol: ({ className, ...props }) => ( + <ol + className={cn( + "aui-md-ol my-2 ms-4 list-decimal marker:text-muted-foreground [&>li]:mt-1", + className, + )} + {...props} + /> + ), + hr: ({ className, ...props }) => ( + <hr + className={cn("aui-md-hr my-2 border-muted-foreground/20", className)} + {...props} + /> + ), + table: ({ className, ...props }) => ( + <div className="my-2 overflow-x-auto"> + <table + className={cn( + "aui-md-table min-w-full border-separate border-spacing-0", + className, + )} + {...props} + /> + </div> + ), + th: ({ className, ...props }) => ( + <th + className={cn( + "aui-md-th bg-muted px-2 py-1 text-start font-medium first:rounded-ss-lg last:rounded-se-lg [[align=center]]:text-center [[align=right]]:text-right", + className, + )} + {...props} + /> + ), + td: ({ className, ...props }) => ( + <td + className={cn( + "aui-md-td border-muted-foreground/20 border-s border-b px-2 py-1 text-start last:border-e [[align=center]]:text-center [[align=right]]:text-right", + className, + )} + {...props} + /> + ), + tr: ({ className, ...props }) => ( + <tr + className={cn( + "aui-md-tr m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-es-lg [&:last-child>td:last-child]:rounded-ee-lg", + className, + )} + {...props} + /> + ), + li: ({ className, ...props }) => ( + <li className={cn("aui-md-li leading-normal", className)} {...props} /> + ), + sup: ({ className, ...props }) => ( + <sup + className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)} + {...props} + /> + ), + pre: ({ className, ...props }) => ( + <pre + className={cn( + "aui-md-pre overflow-x-auto rounded-t-none rounded-b-lg border border-border/50 border-t-0 bg-muted/30 p-3 text-xs leading-relaxed", + className, + )} + {...props} + /> + ), + code: function Code({ className, ...props }) { + const isCodeBlock = useIsMarkdownCodeBlock(); + return ( + <code + className={cn( + !isCodeBlock && + "aui-md-inline-code rounded-md border border-border/50 bg-muted/50 px-1.5 py-0.5 font-mono text-[0.85em]", + className, + )} + {...props} + /> + ); + }, + CodeHeader, +}); diff --git a/web/src/components/assistant-ui/reasoning.tsx b/web/src/components/assistant-ui/reasoning.tsx new file mode 100644 index 00000000..f7340088 --- /dev/null +++ b/web/src/components/assistant-ui/reasoning.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { memo, useCallback, useRef, useState } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { BrainIcon, ChevronDownIcon } from "lucide-react"; +import { + useScrollLock, + useAuiState, + type ReasoningMessagePartComponent, + type ReasoningGroupComponent, +} from "@assistant-ui/react"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; + +const ANIMATION_DURATION = 200; + +const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", { + variants: { + variant: { + outline: "rounded-lg border px-3 py-2", + ghost: "", + muted: "rounded-lg bg-muted/50 px-3 py-2", + }, + }, + defaultVariants: { + variant: "outline", + }, +}); + +export type ReasoningRootProps = Omit< + React.ComponentProps<typeof Collapsible>, + "open" | "onOpenChange" +> & + VariantProps<typeof reasoningVariants> & { + open?: boolean; + onOpenChange?: (open: boolean) => void; + defaultOpen?: boolean; + }; + +function ReasoningRoot({ + className, + variant, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + defaultOpen = false, + children, + ...props +}: ReasoningRootProps) { + const collapsibleRef = useRef<HTMLDivElement>(null); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + lockScroll(); + } + if (!isControlled) { + setUncontrolledOpen(open); + } + controlledOnOpenChange?.(open); + }, + [lockScroll, isControlled, controlledOnOpenChange], + ); + + return ( + <Collapsible + ref={collapsibleRef} + data-slot="reasoning-root" + data-variant={variant} + open={isOpen} + onOpenChange={handleOpenChange} + className={cn( + "group/reasoning-root", + reasoningVariants({ variant, className }), + )} + style={ + { + "--animation-duration": `${ANIMATION_DURATION}ms`, + } as React.CSSProperties + } + {...props} + > + {children} + </Collapsible> + ); +} + +function ReasoningFade({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="reasoning-fade" + className={cn( + "aui-reasoning-fade pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8", + "bg-[linear-gradient(to_top,var(--color-background),transparent)]", + "group-data-[variant=muted]/reasoning-root:bg-[linear-gradient(to_top,hsl(var(--muted)/0.5),transparent)]", + "fade-in-0 animate-in", + "group-data-[state=open]/collapsible-content:animate-out", + "group-data-[state=open]/collapsible-content:fade-out-0", + "group-data-[state=open]/collapsible-content:delay-[calc(var(--animation-duration)*0.75)]", + "group-data-[state=open]/collapsible-content:fill-mode-forwards", + "duration-(--animation-duration)", + "group-data-[state=open]/collapsible-content:duration-(--animation-duration)", + className, + )} + {...props} + /> + ); +} + +function ReasoningTrigger({ + active, + duration, + className, + ...props +}: React.ComponentProps<typeof CollapsibleTrigger> & { + active?: boolean; + duration?: number; +}) { + const durationText = duration ? ` (${duration}s)` : ""; + + return ( + <CollapsibleTrigger + data-slot="reasoning-trigger" + className={cn( + "aui-reasoning-trigger group/trigger flex max-w-[75%] items-center gap-2 py-1 text-muted-foreground text-sm transition-colors hover:text-foreground", + className, + )} + {...props} + > + <BrainIcon + data-slot="reasoning-trigger-icon" + className="aui-reasoning-trigger-icon size-4 shrink-0" + /> + <span + data-slot="reasoning-trigger-label" + className="aui-reasoning-trigger-label-wrapper relative inline-block leading-none" + > + <span>Reasoning{durationText}</span> + {active ? ( + <span + aria-hidden + data-slot="reasoning-trigger-shimmer" + className="aui-reasoning-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none" + > + Reasoning{durationText} + </span> + ) : null} + </span> + <ChevronDownIcon + data-slot="reasoning-trigger-chevron" + className={cn( + "aui-reasoning-trigger-chevron mt-0.5 size-4 shrink-0", + "transition-transform duration-(--animation-duration) ease-out", + "group-data-[state=closed]/trigger:-rotate-90", + "group-data-[state=open]/trigger:rotate-0", + )} + /> + </CollapsibleTrigger> + ); +} + +function ReasoningContent({ + className, + children, + ...props +}: React.ComponentProps<typeof CollapsibleContent>) { + return ( + <CollapsibleContent + data-slot="reasoning-content" + className={cn( + "aui-reasoning-content relative overflow-hidden text-muted-foreground text-sm outline-none", + "group/collapsible-content ease-out", + "data-[state=closed]:animate-collapsible-up", + "data-[state=open]:animate-collapsible-down", + "data-[state=closed]:fill-mode-forwards", + "data-[state=closed]:pointer-events-none", + "data-[state=open]:duration-(--animation-duration)", + "data-[state=closed]:duration-(--animation-duration)", + className, + )} + {...props} + > + {children} + <ReasoningFade /> + </CollapsibleContent> + ); +} + +function ReasoningText({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="reasoning-text" + className={cn( + "aui-reasoning-text relative z-0 max-h-64 space-y-4 overflow-y-auto ps-6 pt-2 pb-2 leading-relaxed", + "transform-gpu transition-[transform,opacity]", + "group-data-[state=open]/collapsible-content:animate-in", + "group-data-[state=closed]/collapsible-content:animate-out", + "group-data-[state=open]/collapsible-content:fade-in-0", + "group-data-[state=closed]/collapsible-content:fade-out-0", + "group-data-[state=open]/collapsible-content:slide-in-from-top-4", + "group-data-[state=closed]/collapsible-content:slide-out-to-top-4", + "group-data-[state=open]/collapsible-content:duration-(--animation-duration)", + "group-data-[state=closed]/collapsible-content:duration-(--animation-duration)", + className, + )} + {...props} + /> + ); +} + +const ReasoningImpl: ReasoningMessagePartComponent = () => <MarkdownText />; + +const ReasoningGroupImpl: ReasoningGroupComponent = ({ + children, + startIndex, + endIndex, +}) => { + const isReasoningStreaming = useAuiState((s) => { + if (s.message.status?.type !== "running") return false; + const lastIndex = s.message.parts.length - 1; + if (lastIndex < 0) return false; + const lastType = s.message.parts[lastIndex]?.type; + if (lastType !== "reasoning") return false; + return lastIndex >= startIndex && lastIndex <= endIndex; + }); + + return ( + <ReasoningRoot defaultOpen={isReasoningStreaming}> + <ReasoningTrigger active={isReasoningStreaming} /> + <ReasoningContent aria-busy={isReasoningStreaming}> + <ReasoningText>{children}</ReasoningText> + </ReasoningContent> + </ReasoningRoot> + ); +}; + +const Reasoning = memo( + ReasoningImpl, +) as unknown as ReasoningMessagePartComponent & { + Root: typeof ReasoningRoot; + Trigger: typeof ReasoningTrigger; + Content: typeof ReasoningContent; + Text: typeof ReasoningText; + Fade: typeof ReasoningFade; +}; + +Reasoning.displayName = "Reasoning"; +Reasoning.Root = ReasoningRoot; +Reasoning.Trigger = ReasoningTrigger; +Reasoning.Content = ReasoningContent; +Reasoning.Text = ReasoningText; +Reasoning.Fade = ReasoningFade; + +/** + * @deprecated This wrapper targets the legacy `components.ReasoningGroup` + * prop on `<MessagePrimitive.Parts>`. Use `<MessagePrimitive.GroupedParts>` + * with a `groupBy` returning `"group-reasoning"` and compose `ReasoningRoot` + * / `ReasoningTrigger` / `ReasoningContent` / `ReasoningText` directly. + * See `thread.tsx` for an example. + */ +const ReasoningGroup = memo(ReasoningGroupImpl); +ReasoningGroup.displayName = "ReasoningGroup"; + +export { + Reasoning, + ReasoningGroup, + ReasoningRoot, + ReasoningTrigger, + ReasoningContent, + ReasoningText, + ReasoningFade, + reasoningVariants, +}; diff --git a/web/src/components/assistant-ui/tool-fallback.tsx b/web/src/components/assistant-ui/tool-fallback.tsx new file mode 100644 index 00000000..8de58a2f --- /dev/null +++ b/web/src/components/assistant-ui/tool-fallback.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { memo, useCallback, useRef, useState } from "react"; +import { + AlertCircleIcon, + CheckIcon, + ChevronDownIcon, + LoaderIcon, + XCircleIcon, +} from "lucide-react"; +import { + useScrollLock, + type ToolCallMessagePartStatus, + type ToolCallMessagePartComponent, +} from "@assistant-ui/react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; + +const ANIMATION_DURATION = 200; + +export type ToolFallbackRootProps = Omit< + React.ComponentProps<typeof Collapsible>, + "open" | "onOpenChange" +> & { + open?: boolean; + onOpenChange?: (open: boolean) => void; + defaultOpen?: boolean; +}; + +function ToolFallbackRoot({ + className, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + defaultOpen = false, + children, + ...props +}: ToolFallbackRootProps) { + const collapsibleRef = useRef<HTMLDivElement>(null); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + lockScroll(); + } + if (!isControlled) { + setUncontrolledOpen(open); + } + controlledOnOpenChange?.(open); + }, + [lockScroll, isControlled, controlledOnOpenChange], + ); + + return ( + <Collapsible + ref={collapsibleRef} + data-slot="tool-fallback-root" + open={isOpen} + onOpenChange={handleOpenChange} + className={cn( + "aui-tool-fallback-root group/tool-fallback-root w-full rounded-lg border py-3", + className, + )} + style={ + { + "--animation-duration": `${ANIMATION_DURATION}ms`, + } as React.CSSProperties + } + {...props} + > + {children} + </Collapsible> + ); +} + +type ToolStatus = ToolCallMessagePartStatus["type"]; + +const statusIconMap: Record<ToolStatus, React.ElementType> = { + running: LoaderIcon, + complete: CheckIcon, + incomplete: XCircleIcon, + "requires-action": AlertCircleIcon, +}; + +function ToolFallbackTrigger({ + toolName, + status, + className, + ...props +}: React.ComponentProps<typeof CollapsibleTrigger> & { + toolName: string; + status?: ToolCallMessagePartStatus; +}) { + const statusType = status?.type ?? "complete"; + const isRunning = statusType === "running"; + const isCancelled = + status?.type === "incomplete" && status.reason === "cancelled"; + + const Icon = statusIconMap[statusType]; + const label = isCancelled ? "Cancelled tool" : "Used tool"; + + return ( + <CollapsibleTrigger + data-slot="tool-fallback-trigger" + className={cn( + "aui-tool-fallback-trigger group/trigger flex w-full items-center gap-2 px-4 text-sm transition-colors", + className, + )} + {...props} + > + <Icon + data-slot="tool-fallback-trigger-icon" + className={cn( + "aui-tool-fallback-trigger-icon size-4 shrink-0", + isCancelled && "text-muted-foreground", + isRunning && "animate-spin", + )} + /> + <span + data-slot="tool-fallback-trigger-label" + className={cn( + "aui-tool-fallback-trigger-label-wrapper relative inline-block grow text-start leading-none", + isCancelled && "text-muted-foreground line-through", + )} + > + <span> + {label}: <b>{toolName}</b> + </span> + {isRunning && ( + <span + aria-hidden + data-slot="tool-fallback-trigger-shimmer" + className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none" + > + {label}: <b>{toolName}</b> + </span> + )} + </span> + <ChevronDownIcon + data-slot="tool-fallback-trigger-chevron" + className={cn( + "aui-tool-fallback-trigger-chevron size-4 shrink-0", + "transition-transform duration-(--animation-duration) ease-out", + "group-data-[state=closed]/trigger:-rotate-90", + "group-data-[state=open]/trigger:rotate-0", + )} + /> + </CollapsibleTrigger> + ); +} + +function ToolFallbackContent({ + className, + children, + ...props +}: React.ComponentProps<typeof CollapsibleContent>) { + return ( + <CollapsibleContent + data-slot="tool-fallback-content" + className={cn( + "aui-tool-fallback-content relative overflow-hidden text-sm outline-none", + "group/collapsible-content ease-out", + "data-[state=closed]:animate-collapsible-up", + "data-[state=open]:animate-collapsible-down", + "data-[state=closed]:fill-mode-forwards", + "data-[state=closed]:pointer-events-none", + "data-[state=open]:duration-(--animation-duration)", + "data-[state=closed]:duration-(--animation-duration)", + className, + )} + {...props} + > + <div className="mt-3 flex flex-col gap-2 border-t pt-2">{children}</div> + </CollapsibleContent> + ); +} + +function ToolFallbackArgs({ + argsText, + className, + ...props +}: React.ComponentProps<"div"> & { + argsText?: string; +}) { + if (!argsText) return null; + + return ( + <div + data-slot="tool-fallback-args" + className={cn("aui-tool-fallback-args px-4", className)} + {...props} + > + <pre className="aui-tool-fallback-args-value whitespace-pre-wrap"> + {argsText} + </pre> + </div> + ); +} + +function ToolFallbackResult({ + result, + className, + ...props +}: React.ComponentProps<"div"> & { + result?: unknown; +}) { + if (result === undefined) return null; + + return ( + <div + data-slot="tool-fallback-result" + className={cn( + "aui-tool-fallback-result border-t border-dashed px-4 pt-2", + className, + )} + {...props} + > + <p className="aui-tool-fallback-result-header font-semibold">Result:</p> + <pre className="aui-tool-fallback-result-content whitespace-pre-wrap"> + {typeof result === "string" ? result : JSON.stringify(result, null, 2)} + </pre> + </div> + ); +} + +function ToolFallbackError({ + status, + className, + ...props +}: React.ComponentProps<"div"> & { + status?: ToolCallMessagePartStatus; +}) { + if (status?.type !== "incomplete") return null; + + const error = status.error; + const errorText = error + ? typeof error === "string" + ? error + : JSON.stringify(error) + : null; + + if (!errorText) return null; + + const isCancelled = status.reason === "cancelled"; + const headerText = isCancelled ? "Cancelled reason:" : "Error:"; + + return ( + <div + data-slot="tool-fallback-error" + className={cn("aui-tool-fallback-error px-4", className)} + {...props} + > + <p className="aui-tool-fallback-error-header font-semibold text-muted-foreground"> + {headerText} + </p> + <p className="aui-tool-fallback-error-reason text-muted-foreground"> + {errorText} + </p> + </div> + ); +} + +const ToolFallbackImpl: ToolCallMessagePartComponent = ({ + toolName, + argsText, + result, + status, +}) => { + const isCancelled = + status?.type === "incomplete" && status.reason === "cancelled"; + + return ( + <ToolFallbackRoot + className={cn(isCancelled && "border-muted-foreground/30 bg-muted/30")} + > + <ToolFallbackTrigger toolName={toolName} status={status} /> + <ToolFallbackContent> + <ToolFallbackError status={status} /> + <ToolFallbackArgs + argsText={argsText} + className={cn(isCancelled && "opacity-60")} + /> + {!isCancelled && <ToolFallbackResult result={result} />} + </ToolFallbackContent> + </ToolFallbackRoot> + ); +}; + +const ToolFallback = memo( + ToolFallbackImpl, +) as unknown as ToolCallMessagePartComponent & { + Root: typeof ToolFallbackRoot; + Trigger: typeof ToolFallbackTrigger; + Content: typeof ToolFallbackContent; + Args: typeof ToolFallbackArgs; + Result: typeof ToolFallbackResult; + Error: typeof ToolFallbackError; +}; + +ToolFallback.displayName = "ToolFallback"; +ToolFallback.Root = ToolFallbackRoot; +ToolFallback.Trigger = ToolFallbackTrigger; +ToolFallback.Content = ToolFallbackContent; +ToolFallback.Args = ToolFallbackArgs; +ToolFallback.Result = ToolFallbackResult; +ToolFallback.Error = ToolFallbackError; + +export { + ToolFallback, + ToolFallbackRoot, + ToolFallbackTrigger, + ToolFallbackContent, + ToolFallbackArgs, + ToolFallbackResult, + ToolFallbackError, +}; diff --git a/web/src/components/assistant-ui/tool-group.tsx b/web/src/components/assistant-ui/tool-group.tsx new file mode 100644 index 00000000..c5d5009c --- /dev/null +++ b/web/src/components/assistant-ui/tool-group.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { + memo, + useCallback, + useRef, + useState, + type FC, + type PropsWithChildren, +} from "react"; +import { ChevronDownIcon, LoaderIcon } from "lucide-react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { useScrollLock } from "@assistant-ui/react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; + +const ANIMATION_DURATION = 200; + +const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", { + variants: { + variant: { + outline: "rounded-lg border py-3", + ghost: "", + muted: "rounded-lg border border-muted-foreground/30 bg-muted/30 py-3", + }, + }, + defaultVariants: { variant: "outline" }, +}); + +export type ToolGroupRootProps = Omit< + React.ComponentProps<typeof Collapsible>, + "open" | "onOpenChange" +> & + VariantProps<typeof toolGroupVariants> & { + open?: boolean; + onOpenChange?: (open: boolean) => void; + defaultOpen?: boolean; + }; + +function ToolGroupRoot({ + className, + variant, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + defaultOpen = false, + children, + ...props +}: ToolGroupRootProps) { + const collapsibleRef = useRef<HTMLDivElement>(null); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + lockScroll(); + } + if (!isControlled) { + setUncontrolledOpen(open); + } + controlledOnOpenChange?.(open); + }, + [lockScroll, isControlled, controlledOnOpenChange], + ); + + return ( + <Collapsible + ref={collapsibleRef} + data-slot="tool-group-root" + data-variant={variant ?? "outline"} + open={isOpen} + onOpenChange={handleOpenChange} + className={cn( + toolGroupVariants({ variant }), + "group/tool-group-root", + className, + )} + style={ + { + "--animation-duration": `${ANIMATION_DURATION}ms`, + } as React.CSSProperties + } + {...props} + > + {children} + </Collapsible> + ); +} + +function ToolGroupTrigger({ + count, + active = false, + className, + ...props +}: React.ComponentProps<typeof CollapsibleTrigger> & { + count: number; + active?: boolean; +}) { + const label = `${count} tool ${count === 1 ? "call" : "calls"}`; + + return ( + <CollapsibleTrigger + data-slot="tool-group-trigger" + className={cn( + "aui-tool-group-trigger group/trigger flex items-center gap-2 text-sm transition-colors", + "group-data-[variant=outline]/tool-group-root:w-full group-data-[variant=outline]/tool-group-root:px-4", + "group-data-[variant=muted]/tool-group-root:w-full group-data-[variant=muted]/tool-group-root:px-4", + className, + )} + {...props} + > + {active && ( + <LoaderIcon + data-slot="tool-group-trigger-loader" + className="aui-tool-group-trigger-loader size-4 shrink-0 animate-spin" + /> + )} + <span + data-slot="tool-group-trigger-label" + className={cn( + "aui-tool-group-trigger-label-wrapper relative inline-block text-start font-medium leading-none", + "group-data-[variant=outline]/tool-group-root:grow", + "group-data-[variant=muted]/tool-group-root:grow", + )} + > + <span>{label}</span> + {active && ( + <span + aria-hidden + data-slot="tool-group-trigger-shimmer" + className="aui-tool-group-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none" + > + {label} + </span> + )} + </span> + <ChevronDownIcon + data-slot="tool-group-trigger-chevron" + className={cn( + "aui-tool-group-trigger-chevron size-4 shrink-0", + "transition-transform duration-(--animation-duration) ease-out", + "group-data-[state=closed]/trigger:-rotate-90", + "group-data-[state=open]/trigger:rotate-0", + )} + /> + </CollapsibleTrigger> + ); +} + +function ToolGroupContent({ + className, + children, + ...props +}: React.ComponentProps<typeof CollapsibleContent>) { + return ( + <CollapsibleContent + data-slot="tool-group-content" + className={cn( + "aui-tool-group-content relative overflow-hidden text-sm outline-none", + "group/collapsible-content ease-out", + "data-[state=closed]:animate-collapsible-up", + "data-[state=open]:animate-collapsible-down", + "data-[state=closed]:fill-mode-forwards", + "data-[state=closed]:pointer-events-none", + "data-[state=open]:duration-(--animation-duration)", + "data-[state=closed]:duration-(--animation-duration)", + className, + )} + {...props} + > + <div + className={cn( + "mt-2 flex flex-col gap-2", + "group-data-[variant=outline]/tool-group-root:mt-3 group-data-[variant=outline]/tool-group-root:border-t group-data-[variant=outline]/tool-group-root:px-4 group-data-[variant=outline]/tool-group-root:pt-3", + "group-data-[variant=muted]/tool-group-root:mt-3 group-data-[variant=muted]/tool-group-root:border-t group-data-[variant=muted]/tool-group-root:px-4 group-data-[variant=muted]/tool-group-root:pt-3", + )} + > + {children} + </div> + </CollapsibleContent> + ); +} + +type ToolGroupComponent = FC< + PropsWithChildren<{ startIndex: number; endIndex: number }> +> & { + Root: typeof ToolGroupRoot; + Trigger: typeof ToolGroupTrigger; + Content: typeof ToolGroupContent; +}; + +const ToolGroupImpl: FC< + PropsWithChildren<{ startIndex: number; endIndex: number }> +> = ({ children, startIndex, endIndex }) => { + const toolCount = endIndex - startIndex + 1; + + return ( + <ToolGroupRoot> + <ToolGroupTrigger count={toolCount} /> + <ToolGroupContent>{children}</ToolGroupContent> + </ToolGroupRoot> + ); +}; + +/** + * @deprecated This wrapper targets the legacy `components.ToolGroup` prop + * on `<MessagePrimitive.Parts>`. Use `<MessagePrimitive.GroupedParts>` with + * a `groupBy` returning `"group-tool"` and compose `ToolGroupRoot` / + * `ToolGroupTrigger` / `ToolGroupContent` directly. See `thread.tsx`. + */ +const ToolGroup = memo(ToolGroupImpl) as unknown as ToolGroupComponent; + +ToolGroup.displayName = "ToolGroup"; +ToolGroup.Root = ToolGroupRoot; +ToolGroup.Trigger = ToolGroupTrigger; +ToolGroup.Content = ToolGroupContent; + +export { + ToolGroup, + ToolGroupRoot, + ToolGroupTrigger, + ToolGroupContent, + toolGroupVariants, +}; diff --git a/web/src/components/assistant-ui/tooltip-icon-button.tsx b/web/src/components/assistant-ui/tooltip-icon-button.tsx new file mode 100644 index 00000000..dd7dbf04 --- /dev/null +++ b/web/src/components/assistant-ui/tooltip-icon-button.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { type ComponentPropsWithRef, forwardRef } from "react"; +import { Slot } from "radix-ui"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & { + tooltip: string; + side?: "top" | "bottom" | "left" | "right"; +}; + +export const TooltipIconButton = forwardRef< + HTMLButtonElement, + TooltipIconButtonProps +>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => { + return ( + <TooltipProvider delayDuration={0}> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + {...rest} + className={cn("aui-button-icon size-6 p-1", className)} + ref={ref} + > + <Slot.Slottable>{children}</Slot.Slottable> + <span className="aui-sr-only sr-only">{tooltip}</span> + </Button> + </TooltipTrigger> + <TooltipContent side={side}>{tooltip}</TooltipContent> + </Tooltip> + </TooltipProvider> + ); +}); + +TooltipIconButton.displayName = "TooltipIconButton"; diff --git a/web/src/components/chat/AgentRenderer.tsx b/web/src/components/chat/AgentRenderer.tsx new file mode 100644 index 00000000..feb0c880 --- /dev/null +++ b/web/src/components/chat/AgentRenderer.tsx @@ -0,0 +1,257 @@ +import { useState } from "react" +import { + LoaderIcon, + XCircleIcon, + BotIcon, + UserIcon, + WrenchIcon, + TerminalIcon, + FileTextIcon, + SearchIcon, + PencilIcon, + ChevronDownIcon, +} from "lucide-react" +import type { TaskState } from "@/useLoopRuntime" +import { useLoopRuntimeExtra, convertMessage } from "@/useLoopRuntime" +import { cn } from "@/lib/utils" + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + const s = Math.floor(ms / 1000) + if (s < 60) return `${s}s` + const m = Math.floor(s / 60) + const rem = s % 60 + return `${m}m ${rem}s` +} + +interface AgentRendererProps { + args: { + description?: string + prompt?: string + subagent_type?: string + } + result?: string + status: string + taskState?: TaskState + elapsedSeconds?: number + toolCallId?: string +} + +function childToolIcon(name: string) { + if (name === "Bash") return TerminalIcon + if (["Edit", "Write", "ApplyPatch"].includes(name)) return PencilIcon + if (["Grep", "Glob"].includes(name)) return SearchIcon + if (name === "Read") return FileTextIcon + return WrenchIcon +} + +export default function AgentRenderer({ + args, + result, + status, + taskState, + elapsedSeconds, + toolCallId, +}: AgentRendererProps) { + const isRunning = status === "running" + const subagentType = args.subagent_type ?? taskState?.task_type ?? "general-purpose" + const description = args.description ?? taskState?.description ?? "" + + const { childMessagesByAgentId } = useLoopRuntimeExtra() + const childRawMsgs = toolCallId ? childMessagesByAgentId.get(toolCallId) ?? [] : [] + + // Convert child messages once + const childMsgs = childRawMsgs.map((m) => { + try { return convertMessage(m) } catch { return null } + }).filter(Boolean) + + const hasChildren = childMsgs.length > 0 + + return ( + <div className="space-y-2"> + {/* Header */} + <div className="flex items-center gap-2"> + <BotIcon className="h-4 w-4 text-purple-500 shrink-0" /> + <span className="text-[11px] font-medium text-purple-700 uppercase tracking-wide"> + {subagentType} + </span> + <span + className={ + isRunning + ? "rounded px-1.5 py-px text-[10px] font-medium bg-sky-100 text-sky-700" + : status === "incomplete" + ? "rounded px-1.5 py-px text-[10px] font-medium bg-red-100 text-red-700" + : "rounded px-1.5 py-px text-[10px] font-medium bg-emerald-100 text-emerald-700" + } + > + {isRunning ? "running" : status === "incomplete" ? "failed" : "done"} + </span> + {isRunning && elapsedSeconds !== undefined && elapsedSeconds > 0 && ( + <span className="text-[10px] text-gray-400 tabular-nums"> + {formatDuration(elapsedSeconds * 1000)} + </span> + )} + </div> + + {/* Description */} + {description && ( + <p className="text-[12px] text-gray-600 leading-relaxed">{description}</p> + )} + + {/* Running indicator */} + {isRunning && ( + <div className="flex items-center gap-2 py-1"> + <LoaderIcon className="h-3 w-3 animate-spin text-sky-500" /> + <span className="text-[11px] text-gray-400"> + {taskState?.last_tool_name + ? `Running ${taskState.last_tool_name}...` + : "Working..."} + </span> + </div> + )} + + {/* Task completion stats */} + {taskState?.usage && status === "complete" && ( + <div className="flex items-center gap-3 text-[10px] text-gray-400"> + <span>{taskState.usage.tool_uses} tools</span> + <span>{(taskState.usage.total_tokens / 1000).toFixed(1)}k tokens</span> + <span>{formatDuration(taskState.usage.duration_ms)}</span> + </div> + )} + + {/* Result / Summary */} + {status === "complete" && result && ( + <pre className="overflow-x-auto rounded border border-gray-100 bg-gray-100/50 p-2 text-[11px] leading-relaxed text-gray-700 whitespace-pre-wrap font-mono max-h-32 overflow-auto"> + {result} + </pre> + )} + {status === "complete" && !result && taskState?.summary && ( + <p className="text-[11px] text-gray-500 italic">{taskState.summary}</p> + )} + + {/* Child messages — agent-internal prompts & tool calls */} + {hasChildren && ( + <div className="border-t border-purple-100 pt-2 mt-2 space-y-1.5"> + {childMsgs.map((msg: any) => { + if (!msg || !Array.isArray(msg.content)) return null + const isUser = msg.role === "user" + + return ( + <div key={msg.id} className="space-y-1"> + {msg.content.map((part: any, pi: number) => { + if (part.type === "text" && part.text) { + return isUser ? ( + /* Agent-internal user prompt — distinct from end-user messages */ + <div key={pi} className="flex items-start gap-1.5 pl-2 border-l-2 border-purple-200"> + <UserIcon className="h-3 w-3 text-purple-400 shrink-0 mt-0.5" /> + <p className="text-[11px] text-purple-800 leading-relaxed whitespace-pre-wrap break-words"> + {part.text} + </p> + </div> + ) : ( + <p key={pi} className="text-[11px] text-gray-500 leading-relaxed pl-2 whitespace-pre-wrap break-words"> + {part.text} + </p> + ) + } + + if (part.type === "tool-call") { + return ( + <ChildToolCard + key={pi} + toolName={part.toolName ?? "?"} + args={part.args ?? {}} + result={part.result} + status={part.status?.type ?? "complete"} + /> + ) + } + + if (part.type === "reasoning" && part.text) { + return ( + <p key={pi} className="text-[10px] text-gray-400 italic pl-2"> + {part.text.length > 200 ? part.text.slice(0, 200) + "…" : part.text} + </p> + ) + } + + return null + })} + </div> + ) + })} + </div> + )} + + {/* Error state */} + {status === "incomplete" && ( + <div className="flex items-center gap-2 text-[11px] text-red-500"> + <XCircleIcon className="h-3.5 w-3.5" /> + {taskState?.error ?? "Agent call failed"} + </div> + )} + </div> + ) +} + +/* ─── Compact child tool card ─── */ + +function ChildToolCard({ + toolName, + args, + result, + status, +}: { + toolName: string + args: Record<string, unknown> + result?: string + status: string +}) { + const [open, setOpen] = useState(false) + const Icon = childToolIcon(toolName) + const isRunning = status === "running" + const isDone = status === "complete" + + // one-line summary from args + const summary = + args.file_path as string || + args.command as string || + args.pattern as string || + args.query as string || + args.description as string || + "" + + const hasContent = (summary && summary.length > 0) || result + + return ( + <div className="pl-4"> + <div + className={cn( + "flex items-center gap-1.5 rounded border px-2 py-1 text-[10px] w-full", + isRunning ? "border-sky-100 bg-sky-50/30" : "border-gray-100 bg-gray-50/50", + )} + > + <Icon className="h-2.5 w-2.5 text-gray-400 shrink-0" /> + <span className="font-medium text-gray-600 shrink-0">{toolName}</span> + {summary && ( + <span className="text-gray-400 truncate min-w-0">{String(summary)}</span> + )} + {isRunning && <LoaderIcon className="h-2.5 w-2.5 animate-spin text-sky-500 shrink-0" />} + {isDone && result !== undefined && hasContent && ( + <button + type="button" + onClick={() => setOpen(!open)} + className="text-gray-400 hover:text-gray-600 shrink-0 ml-auto" + > + <ChevronDownIcon className={cn("h-2.5 w-2.5 transition-transform", open && "rotate-180")} /> + </button> + )} + </div> + {open && result && ( + <pre className="mt-0.5 overflow-x-auto rounded border border-gray-100 bg-gray-50 p-1.5 text-[10px] leading-relaxed text-gray-600 whitespace-pre-wrap font-mono max-h-24 overflow-auto"> + {typeof result === "string" ? result : JSON.stringify(result, null, 2)} + </pre> + )} + </div> + ) +} diff --git a/web/src/components/chat/AskUserQuestionRenderer.tsx b/web/src/components/chat/AskUserQuestionRenderer.tsx new file mode 100644 index 00000000..8c8edf26 --- /dev/null +++ b/web/src/components/chat/AskUserQuestionRenderer.tsx @@ -0,0 +1,313 @@ +import { useState } from "react" +import { CheckIcon, ChevronRightIcon, HelpCircleIcon, SendIcon, XIcon } from "lucide-react" +import { cn } from "@/lib/utils" + +interface QuestionOption { + label: string + description: string +} + +interface QuestionDef { + question: string + header: string + options: QuestionOption[] + multiSelect: boolean +} + +interface AskUserQuestionRendererProps { + questions: QuestionDef[] + toolUseId?: string + onAnswers?: (toolUseId: string, answers: Record<string, string>) => void + onDismiss?: (toolUseId: string) => void + disabled?: boolean +} + +export default function AskUserQuestionRenderer({ + questions: rawQuestions, + toolUseId, + onAnswers, + onDismiss, + disabled = false, +}: AskUserQuestionRendererProps) { + const [answers, setAnswers] = useState<Record<string, string>>({}) + const [customInputs, setCustomInputs] = useState<Record<string, string>>({}) + const [submitted, setSubmitted] = useState(false) + const [activeTab, setActiveTab] = useState(0) + + const questions = Array.isArray(rawQuestions) + ? rawQuestions.filter( + (q) => + q != null && + typeof q.question === "string" && + q.question.length > 0 && + Array.isArray(q.options), + ) + : [] + + if (questions.length === 0) return null + + const handleSelect = (questionText: string, label: string) => { + if (disabled || submitted) return + setCustomInputs((prev) => { + if (!prev[questionText]) return prev + const next = { ...prev } + delete next[questionText] + return next + }) + setAnswers((prev) => ({ ...prev, [questionText]: label })) + } + + const handleMultiSelect = (questionText: string, label: string) => { + if (disabled || submitted) return + setCustomInputs((prev) => { + if (!prev[questionText]) return prev + const next = { ...prev } + delete next[questionText] + return next + }) + setAnswers((prev) => { + const current = (prev[questionText] ?? "").split(",").filter(Boolean) + const idx = current.indexOf(label) + if (idx >= 0) { + current.splice(idx, 1) + } else { + current.push(label) + } + return { ...prev, [questionText]: current.join(",") } + }) + } + + const handleCustomInput = (questionText: string, value: string) => { + if (disabled || submitted) return + setCustomInputs((prev) => ({ ...prev, [questionText]: value })) + setAnswers((prev) => { + if (!prev[questionText]) return prev + const next = { ...prev } + delete next[questionText] + return next + }) + } + + const effectiveAnswer = (q: QuestionDef): string => + customInputs[q.question]?.trim() || answers[q.question] || "" + + const allAnswered = questions.every((q) => { + const ans = effectiveAnswer(q) + return ans !== undefined && ans !== "" + }) + + const isQuestionAnswered = (q: QuestionDef): boolean => { + const ans = effectiveAnswer(q) + return ans !== undefined && ans !== "" + } + + const handleSubmit = () => { + if (!allAnswered || !toolUseId || !onAnswers || submitted) return + setSubmitted(true) + const merged: Record<string, string> = {} + for (const q of questions) { + merged[q.question] = effectiveAnswer(q) + } + onAnswers(toolUseId, merged) + } + + const handleDismiss = () => { + if (disabled || submitted) return + setSubmitted(true) + onDismiss?.(toolUseId || "") + } + + const goToNextTab = () => { + setActiveTab((prev) => Math.min(prev + 1, questions.length - 1)) + } + + const tabLabel = (q: QuestionDef, idx: number): string => { + if (q.header) return q.header + return `Question ${idx + 1}` + } + + const q = questions[activeTab] + const selected = answers[q.question] ?? "" + const selectedSet = new Set(selected.split(",").filter(Boolean)) + const options = Array.isArray(q.options) ? q.options : [] + const customValue = customInputs[q.question] ?? "" + + return ( + <div className="space-y-3"> + {/* Header with dismiss */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2 text-[11px] text-violet-600 font-medium"> + <HelpCircleIcon className="h-3.5 w-3.5" /> + <span> + {questions.length > 1 + ? `Question ${activeTab + 1} of ${questions.length}` + : "Question"} + </span> + </div> + <button + type="button" + onClick={handleDismiss} + disabled={disabled || submitted} + className="flex items-center justify-center h-5 w-5 rounded text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors" + > + <XIcon className="h-3.5 w-3.5" /> + </button> + </div> + + {/* Tab bar — only shown when multiple questions */} + {questions.length > 1 && ( + <div className="flex gap-1 border-b border-gray-200"> + {questions.map((tq, ti) => { + const answered = isQuestionAnswered(tq) + const isActive = ti === activeTab + return ( + <button + key={ti} + type="button" + disabled={disabled || submitted} + onClick={() => setActiveTab(ti)} + className={cn( + "flex items-center gap-1 px-2.5 py-1.5 text-[11px] font-medium transition-colors border-b-2 -mb-[1px] whitespace-nowrap", + isActive + ? "border-violet-500 text-violet-700" + : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300", + (disabled || submitted) && "cursor-default opacity-70", + )} + > + {answered && <CheckIcon className="h-3 w-3 text-emerald-500" />} + {tabLabel(tq, ti)} + </button> + ) + })} + </div> + )} + + {/* Active question content — scrollable */} + <div className="overflow-y-auto max-h-[260px] space-y-1.5"> + {q.header && ( + <div className="text-[10px] font-medium uppercase tracking-wide text-gray-400"> + {q.header} + </div> + )} + <p className="text-[12px] text-gray-800 font-medium">{q.question}</p> + + {/* Option buttons */} + <div className="space-y-1"> + {options.map((opt, oi) => { + const label = typeof opt?.label === "string" ? opt.label : "" + const desc = typeof opt?.description === "string" ? opt.description : "" + if (!label) return null + + const isSelected = q.multiSelect + ? selectedSet.has(label) + : selected === label + return ( + <button + key={oi} + type="button" + disabled={disabled || submitted} + onClick={() => + q.multiSelect + ? handleMultiSelect(q.question, label) + : handleSelect(q.question, label) + } + className={cn( + "w-full text-left px-3 py-2 rounded-md border text-[12px] transition-colors", + isSelected + ? "border-violet-400 bg-violet-50 text-violet-900" + : "border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50", + (disabled || submitted) && "cursor-default opacity-70", + )} + > + <div className="flex items-center gap-2"> + {q.multiSelect ? ( + <span + className={cn( + "h-3.5 w-3.5 rounded border flex-shrink-0 flex items-center justify-center", + isSelected ? "border-violet-500 bg-violet-500" : "border-gray-300", + )} + > + {isSelected && ( + <svg className="h-2.5 w-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> + </svg> + )} + </span> + ) : ( + <span + className={cn( + "h-3.5 w-3.5 rounded-full border flex-shrink-0", + isSelected ? "border-violet-500 bg-violet-500" : "border-gray-300", + )} + > + {isSelected && ( + <span className="block h-1.5 w-1.5 rounded-full bg-white m-0.5" /> + )} + </span> + )} + <span>{label}</span> + {desc && ( + <span className="text-[10px] text-gray-400">— {desc}</span> + )} + </div> + </button> + ) + })} + </div> + + {/* Custom text input for free-form answers */} + <input + type="text" + value={customValue} + onChange={(e) => handleCustomInput(q.question, e.target.value)} + placeholder="Or type your own answer..." + disabled={disabled || submitted} + className={cn( + "w-full px-3 py-1.5 rounded-md border text-[12px] outline-none transition-colors", + customValue + ? "border-violet-300 bg-violet-50/50 text-violet-900 placeholder:text-violet-300" + : "border-gray-200 bg-white text-gray-700 placeholder:text-gray-300 hover:border-gray-300 focus:border-violet-300", + (disabled || submitted) && "cursor-default opacity-70", + )} + /> + </div> + + {/* Bottom bar: next tab + submit */} + {!submitted && ( + <div className="flex items-center justify-between pt-1"> + {questions.length > 1 && activeTab < questions.length - 1 ? ( + <button + type="button" + disabled={disabled} + onClick={goToNextTab} + className="inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-[11px] font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-100 transition-colors" + > + Next + <ChevronRightIcon className="h-3 w-3" /> + </button> + ) : ( + <div /> + )} + <button + type="button" + disabled={!allAnswered || disabled} + onClick={handleSubmit} + className={cn( + "inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors", + allAnswered + ? "bg-gray-900 text-white hover:bg-gray-800" + : "bg-gray-100 text-gray-400 cursor-not-allowed", + )} + > + <SendIcon className="h-3 w-3" /> + Submit + </button> + </div> + )} + + {submitted && ( + <p className="text-[11px] text-emerald-600 font-medium text-right">Submitted</p> + )} + </div> + ) +} diff --git a/web/src/components/chat/AssistantMessage.tsx b/web/src/components/chat/AssistantMessage.tsx new file mode 100644 index 00000000..fe47fc9f --- /dev/null +++ b/web/src/components/chat/AssistantMessage.tsx @@ -0,0 +1,257 @@ +import { + MessagePrimitive, + useAuiState, +} from "@assistant-ui/react"; +import { BrainIcon, ChevronDownIcon } from "lucide-react"; +import { MarkdownBlock } from "./MarkdownBlock"; +import ToolRenderer from "./ToolRenderer"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { useLoopRuntimeExtra } from "@/useLoopRuntime"; +import { cn } from "@/lib/utils"; +import ErrorBoundary from "./ErrorBoundary"; + +function extractTime(messageId: string | undefined): string { + if (!messageId) return ""; + const match = messageId.match(/(\d{13})/); + if (match) { + return new Date(parseInt(match[1], 10)).toLocaleTimeString(); + } + return ""; +} + +/* ─── JSON detection helper ─── */ + +function JsonBlock({ content }: { content: string }) { + const trimmed = content.trim(); + if ( + !(trimmed.startsWith("{") || trimmed.startsWith("[")) || + !(trimmed.endsWith("}") || trimmed.endsWith("]")) + ) { + return null; + } + try { + const parsed = JSON.parse(trimmed); + const formatted = JSON.stringify(parsed, null, 2); + return ( + <div className="my-2"> + <div className="mb-2 flex items-center gap-2 text-sm text-gray-500"> + <svg + className="h-4 w-4" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" + /> + </svg> + <span className="font-medium">JSON</span> + </div> + <div className="overflow-hidden rounded-lg border border-gray-600/30 bg-gray-900"> + <pre className="overflow-x-auto p-4"> + <code className="block whitespace-pre font-mono text-sm text-gray-200"> + {formatted} + </code> + </pre> + </div> + </div> + ); + } catch { + return null; + } +} + +/* ─── Dot type for message-level indicator ─── */ + +type DotType = "gray" | "green" | "blink-green"; + +function getDotType(parts: any[]): DotType { + let hasRunning = false; + let hasTool = false; + for (const p of parts) { + if (p?.type === "tool-call") { + hasTool = true; + if (p?.status?.type === "running") hasRunning = true; + } + } + if (hasRunning) return "blink-green"; + if (hasTool) return "green"; + return "gray"; +} + +/* ─── Assistant message ─── */ + +export default function AssistantMessage() { + // IMPORTANT: call ALL hooks before any conditional early return so React's + // hook-order invariant holds across renders (content shape changes during + // streaming / clear-boundary insertion). + const messageId = useAuiState((s) => s.message.id); + const { toolProgressMap, taskMap, thinkingOpen, setThinkingOpen } = useLoopRuntimeExtra(); + const messageParts = useAuiState((s) => s.message.content); + const textContent = useAuiState((s) => { + const parts = s.message.content; + if (!Array.isArray(parts)) return ""; + return parts + .filter((p: { type: string }) => p.type === "text") + .map((p: { text?: string }) => p.text ?? "") + .join(""); + }); + + const time = extractTime(messageId); + + const hasContent = Array.isArray(messageParts) && messageParts.some( + (p: any) => + (p?.type === "text" && (p.text ?? "").length > 0) || + p?.type === "tool-call" || + p?.type === "reasoning" || + p?.type === "image" || + p?.type === "file", + ); + if (!hasContent) return null; + + const dotType = Array.isArray(messageParts) ? getDotType(messageParts) : "gray"; + + // Position dot at first-line-of-text height for text, or centered on tool bar for tool-call + const firstVisiblePart = Array.isArray(messageParts) + ? messageParts.find((p: any) => p?.type === "text" || p?.type === "tool-call") + : null; + const dotTopClass = firstVisiblePart?.type === "text" ? "top-[6px]" : "top-[17px]"; + + const children = ( + <MessagePrimitive.GroupedParts + groupBy={(part) => { + if (part.type === "reasoning") + return ["group-chainOfThought", "group-reasoning"]; + return null; + }} + > + {({ part, children }) => { + switch (part.type) { + case "group-chainOfThought": + return <div data-slot="chain-of-thought">{children}</div>; + case "group-reasoning": { + const running = part.status.type === "running"; + const charCount = (part as any).indices?.reduce((sum: number, i: number) => { + return sum + ((messageParts[i] as any)?.text?.length ?? 0); + }, 0) ?? 0; + const label = charCount > 0 + ? `Thinking · ${charCount.toLocaleString()} chars` + : "Thinking"; + return ( + <Collapsible + open={running ? true : thinkingOpen} + onOpenChange={setThinkingOpen} + className="group/think my-1 overflow-hidden rounded-md border border-gray-100 bg-gray-50/50" + > + <CollapsibleTrigger className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs transition-colors hover:bg-gray-100/50"> + <BrainIcon className="h-3 w-3 shrink-0 text-gray-400" /> + <span className="text-gray-400">{label}</span> + {running && ( + <span className="shrink-0 rounded px-1 py-px text-[10px] font-medium text-gray-400"> + thinking + </span> + )} + <ChevronDownIcon + className={cn( + "ml-auto h-3 w-3 shrink-0 text-gray-300 transition-transform", + (running || thinkingOpen) && "rotate-180", + )} + /> + </CollapsibleTrigger> + <CollapsibleContent + className={cn( + "overflow-hidden", + "data-[state=open]:animate-collapsible-down", + "data-[state=closed]:animate-collapsible-up", + )} + > + <div className="border-t border-gray-100 px-3 py-2"> + <div className="max-h-64 overflow-y-auto text-[12px] text-gray-500 leading-relaxed"> + {children} + </div> + </div> + </CollapsibleContent> + </Collapsible> + ); + } + case "text": { + const content = textContent; + const jsonBlock = JsonBlock({ content }); + if (jsonBlock) { + return <div>{jsonBlock}</div>; + } + return <MarkdownBlock />; + } + case "reasoning": + return <MarkdownBlock />; + case "tool-call": { + const args = (part as any).args ?? {}; + const result = (part as any).result; + const status = (part as any).status?.type ?? "complete"; + const toolCallId = (part as any).toolCallId as string | undefined; + const toolName = (part as any).toolName ?? "Unknown"; + const toolProgress = toolCallId ? toolProgressMap.get(toolCallId) : undefined; + const taskFromToolUseId = toolCallId + ? Array.from(taskMap.values()).find((t) => t.tool_use_id === toolCallId) + : undefined; + return ( + <ErrorBoundary name={"ToolRenderer:" + toolName}> + <ToolRenderer + toolName={toolName} + args={args} + result={result} + status={status} + elapsedSeconds={toolProgress?.elapsed_time_seconds} + taskState={taskFromToolUseId} + toolCallId={toolCallId} + /> + </ErrorBoundary> + ); + } + default: + return null; + } + }} + </MessagePrimitive.GroupedParts> + ); + + return ( + <MessagePrimitive.Root + data-role="assistant" + className="relative pl-6 md:pl-8" + > + {/* Vertical line gutter — dot sits on the line. Extends past bounds to bridge gap between messages */} + <div className="absolute left-[5px] -top-2 -bottom-2 w-[2px] bg-gray-200" /> + + {/* Dot indicator — positioned per first-content-type */} + <div + className={cn( + "absolute left-[3px] z-10 h-[6px] w-[6px] rounded-full", + dotTopClass, + dotType === "gray" && "bg-gray-300", + dotType === "green" && "bg-green-500", + dotType === "blink-green" && "animate-[blink-green_2s_ease-in-out_infinite]", + )} + /> + + {/* Content */} + <div className="w-full text-[13px] md:text-sm text-gray-700"> + {children} + </div> + + {/* Footer: time */} + {time && ( + <div className="mt-1 flex items-center gap-2 text-[11px] text-gray-400"> + <span>{time}</span> + </div> + )} + </MessagePrimitive.Root> + ); +} diff --git a/web/src/components/chat/ChatInterface.tsx b/web/src/components/chat/ChatInterface.tsx new file mode 100644 index 00000000..6bb41660 --- /dev/null +++ b/web/src/components/chat/ChatInterface.tsx @@ -0,0 +1,516 @@ +import { useEffect, useRef, useState, useCallback, type FC } from "react"; +import { + ThreadPrimitive, + useAuiState, + useComposerRuntime, +} from "@assistant-ui/react"; +import { ArrowDownIcon, GitBranch } from "lucide-react"; +import UserMessage from "./UserMessage"; +import AssistantMessage from "./AssistantMessage"; +import Composer from "./Composer"; +import AskUserQuestionRenderer from "./AskUserQuestionRenderer"; + +import { useLoopRuntimeExtra } from "@/useLoopRuntime"; +import ErrorBoundary from "./ErrorBoundary"; + +/* ─── History loading spinner ─── */ + +const HistoryLoading: FC = () => { + return ( + <div className="my-auto flex grow flex-col items-center justify-center gap-3"> + <div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600" /> + <p className="text-sm text-gray-400">Loading history…</p> + </div> + ); +}; + +/* ─── Composer draft cache ─── */ + +const DRAFT_STORAGE_KEY = "loopat:composer:drafts"; + +function getDraft(loopId: string): string | null { + try { + const raw = localStorage.getItem(DRAFT_STORAGE_KEY); + if (!raw) return null; + const drafts = JSON.parse(raw) as Record<string, string>; + return drafts[loopId] ?? null; + } catch { + return null; + } +} + +function setDraft(loopId: string, text: string): void { + try { + const raw = localStorage.getItem(DRAFT_STORAGE_KEY); + const drafts: Record<string, string> = raw ? JSON.parse(raw) : {}; + if (text.trim()) { + drafts[loopId] = text; + } else { + delete drafts[loopId]; + } + localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(drafts)); + } catch { + // ignore storage errors + } +} + +/* ─── Chat Interface ─── */ + +export default function ChatInterface({ archived = false, onUnarchive, readOnly = false, repo, branch, title, driver, driverHistory, rfdRequestedAt, rfdRequestedBy, onTakeDrive, pickedFile, editorSelection }: { archived?: boolean; onUnarchive?: () => void; readOnly?: boolean; repo?: string; branch?: string; title?: string; driver?: string; driverHistory?: Array<{ driver: string; since: string }>; rfdRequestedAt?: string; rfdRequestedBy?: string; onTakeDrive?: () => void; pickedFile?: string | null; editorSelection?: { from: number; to: number } | null } = {}) { + const { questions, sendAnswers, loadingHistory, loopId, hasHistory, showHistory, toggleShowHistory, hasOlderMessages, loadMoreMessages, thinkingBudget, setMaxThinkingTokens } = useLoopRuntimeExtra(); + const [thinkingNullMode, setThinkingNullMode] = useState<"normal" | "ultra">("normal") + const isEmpty = useAuiState((s) => s.thread.isEmpty && !s.thread.isRunning) && !loadingHistory; + const containerRef = useRef<HTMLDivElement>(null); + const vpRef = useRef<HTMLElement | null>(null); + + // Custom scroll-to-bottom button — only shows when scrolled > 200px from bottom + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const scrollToBottom = useCallback(() => { + const vp = vpRef.current; + if (!vp) return; + vp.scrollTo({ top: vp.scrollHeight, behavior: "smooth" }); + }, []); + + // Ref-backed copies so the scroll listener (which runs in a []-memoized effect) + // always sees the latest values without re-subscribing. + const hasHistoryRef = useRef(hasHistory); + hasHistoryRef.current = hasHistory; + const showHistoryRef = useRef(showHistory); + showHistoryRef.current = showHistory; + const hasOlderRef = useRef(hasOlderMessages); + hasOlderRef.current = hasOlderMessages; + + // Scroll-to-top history button — shows when user scrolls to the very top + // and there is pre-clear history to reveal. + const [showHistoryButton, setShowHistoryButton] = useState(false); + + // Scroll-anchor refs: preserve scroll position when history is toggled + const scrollAnchorRef = useRef({ oldScrollTop: 0, oldScrollHeight: 0, active: false }); + const handleShowHistory = useCallback(() => { + const vp = vpRef.current; + if (vp) { + scrollAnchorRef.current = { + oldScrollTop: vp.scrollTop, + oldScrollHeight: vp.scrollHeight, + active: true, + }; + } + toggleShowHistory(); + }, [toggleShowHistory]); + + // Scroll-anchor for load-more: preserve position when older messages appear above + const loadMoreAnchorRef = useRef({ oldScrollTop: 0, oldScrollHeight: 0, active: false }); + const handleLoadMore = useCallback(() => { + const vp = vpRef.current; + if (vp) { + loadMoreAnchorRef.current = { + oldScrollTop: vp.scrollTop, + oldScrollHeight: vp.scrollHeight, + active: true, + }; + } + setLoadingMore(true); + loadMoreMessages(); + }, [loadMoreMessages]); + + const [loadingMore, setLoadingMore] = useState(false); + const loadingMoreRef = useRef(false); + loadingMoreRef.current = loadingMore; + + // Persist composer text across loop switches and page refresh. + const composer = useComposerRuntime(); + const composerText = useAuiState((s) => s.composer.text); + const composerTextRef = useRef(composerText); + composerTextRef.current = composerText; + + // Load draft from localStorage on mount + useEffect(() => { + const draft = getDraft(loopId); + if (draft) { + composer.setText(draft); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Save draft to localStorage on unmount and when text changes + useEffect(() => { + return () => { + setDraft(loopId, composerTextRef.current); + }; + }, [loopId]); + + // Also save when text changes (debounced) + useEffect(() => { + if (!composerText) return; + const timer = setTimeout(() => { + setDraft(loopId, composerText); + }, 300); + return () => clearTimeout(timer); + }, [composerText, loopId]); + + // Clear draft when message is sent (composer text becomes empty after send) + const [prevText, setPrevText] = useState(composerText); + useEffect(() => { + // If user manually cleared the input, also clear storage + if (prevText && !composerText) { + setDraft(loopId, ""); + } + setPrevText(composerText); + }, [composerText, loopId]); + + // Auto-scroll to bottom: instant on load, throttled during streaming. + // During history replay (loadingHistory=true), keep snapping to bottom on + // every content change without setting didInitialScroll — content-visibility + // may cause scrollHeight to be underestimated until all messages render. + const bottomRef = useRef<HTMLDivElement>(null); + const didInitialScroll = useRef(false); + const loadingHistoryRef = useRef(loadingHistory); + loadingHistoryRef.current = loadingHistory; + const prevLoading = useRef(loadingHistory); + useEffect(() => { + const inner = containerRef.current; + const vp = inner?.parentElement as HTMLElement | null; + if (!inner || !vp) return; + vpRef.current = vp; + const nearBottom = () => vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 120; + + let userScrolledUp = false; + // Track upward wheel events so we can suppress auto-scroll immediately, + // before the user has scrolled past the 120px nearBottom() threshold. + // Without this, during rapid streaming the user can never escape the + // nearBottom() zone — each ResizeObserver tick yanks them back down. + let wheelUpTimer: ReturnType<typeof setTimeout> | null = null; + const onWheel = (e: WheelEvent) => { + if (e.deltaY < 0) { + userScrolledUp = true; + if (wheelUpTimer) clearTimeout(wheelUpTimer); + wheelUpTimer = setTimeout(() => { + wheelUpTimer = null; + if (nearBottom()) userScrolledUp = false; + }, 200); + } + }; + const onScroll = () => { + // Only reset userScrolledUp when near bottom if there's no pending + // upward-wheel timer — otherwise the onWheel handler's timer will + // decide once the user stops scrolling. + if (!wheelUpTimer && nearBottom()) { + userScrolledUp = false; + } else if (!nearBottom()) { + userScrolledUp = true; + } + setShowScrollToBottom(vp.scrollTop + vp.clientHeight < vp.scrollHeight - 200); + setShowHistoryButton(vp.scrollTop < 20 && hasHistoryRef.current); + // Auto-load when scrolled near top + if (vp.scrollTop < 40 && hasOlderRef.current && !loadingMoreRef.current) { + handleLoadMore(); + } + }; + vp.addEventListener("scroll", onScroll, { passive: true }); + vp.addEventListener("wheel", onWheel, { passive: true }); + + let timer: ReturnType<typeof setTimeout> | null = null; + let suppressScroll = false; + const scroll = () => { + if (didInitialScroll.current && userScrolledUp) return; + // After a programmatic scroll, briefly suppress re-triggers to break + // the cycle: scrollTop change → content-visibility renders → + // layout shift → ResizeObserver → scroll → ... + if (suppressScroll) return; + if (!didInitialScroll.current && vp.scrollHeight <= vp.clientHeight + 10) return; + const prev = vp.style.scrollBehavior; + vp.style.scrollBehavior = "auto"; + vp.scrollTop = vp.scrollHeight; + vp.style.scrollBehavior = prev; + if (!didInitialScroll.current) { + if (!loadingHistoryRef.current) { + didInitialScroll.current = true; + } + userScrolledUp = false; + } + // Suppress ResizeObserver callbacks for 120ms after a programmatic + // scroll to let content-visibility settle without re-triggering. + suppressScroll = true; + requestAnimationFrame(() => { + setTimeout(() => { suppressScroll = false; }, 120); + }); + }; + scroll(); + const ro = new ResizeObserver(() => { + if (timer) return; + if (loadingHistoryRef.current) return; + if (suppressScroll) return; + timer = setTimeout(() => { timer = null; scroll(); }, 80); + }); + ro.observe(inner); + return () => { + ro.disconnect(); + if (timer) clearTimeout(timer); + if (wheelUpTimer) clearTimeout(wheelUpTimer); + vp.removeEventListener("scroll", onScroll); + vp.removeEventListener("wheel", onWheel); + }; + }, []); + // When history finishes loading, do one final snap to bottom and finalize + // initial-scroll. No resize may happen at the exact moment loadingHistory + // flips, so we drive the scroll directly. + useEffect(() => { + if (prevLoading.current && !loadingHistory) { + const vp = vpRef.current; + if (vp) { + const prev = vp.style.scrollBehavior; + vp.style.scrollBehavior = "auto"; + vp.scrollTop = vp.scrollHeight; + vp.style.scrollBehavior = prev; + didInitialScroll.current = true; + } + } + prevLoading.current = loadingHistory; + }, [loadingHistory]); + + // When showHistory toggles, preserve the user's visible scroll position + // so prepended/removed messages don't cause a jump. + useEffect(() => { + if (!scrollAnchorRef.current.active) return; + const vp = vpRef.current; + if (!vp) return; + const { oldScrollTop, oldScrollHeight } = scrollAnchorRef.current; + scrollAnchorRef.current.active = false; + requestAnimationFrame(() => { + const delta = vp.scrollHeight - oldScrollHeight; + vp.scrollTop = oldScrollTop + delta; + }); + }, [showHistory]); + + // When loadMore increases renderCount, preserve scroll position so + // older messages appearing at top don't cause a jump. + useEffect(() => { + if (!loadMoreAnchorRef.current.active) return; + const vp = vpRef.current; + if (!vp) return; + const { oldScrollTop, oldScrollHeight } = loadMoreAnchorRef.current; + loadMoreAnchorRef.current.active = false; + requestAnimationFrame(() => { + const delta = vp.scrollHeight - oldScrollHeight; + vp.scrollTop = oldScrollTop + delta; + }); + }, [hasOlderMessages]); + + // Reset loading spinner after messages render. + useEffect(() => { + if (!loadingMore) return; + const timer = setTimeout(() => setLoadingMore(false), 200); + return () => clearTimeout(timer); + }, [loadingMore, hasOlderMessages]); + + const questionEntries = questions.size > 0 + ? Array.from(questions.entries()) + : []; + + return ( + <ThreadPrimitive.Root + className="flex h-full flex-col bg-white relative" + style={ + { + "--thread-max-width": "44rem", + } as React.CSSProperties + } + > + <ThreadPrimitive.Viewport + turnAnchor="top" + className={isEmpty ? "hidden" : "relative flex-1 overflow-x-auto overflow-y-scroll scroll-smooth"} + > + <div ref={containerRef} className="mx-auto flex w-full min-h-full flex-col px-2 md:px-3 pt-3 md:pt-4"> + {/* Loading state — show skeleton while history is being replayed */} + {loadingHistory && ( + <HistoryLoading /> + )} + + {/* View/collapse earlier messages — appears when scrolled to top and there is pre-clear history */} + {showHistoryButton && ( + <div className="flex justify-center pb-2"> + <button + type="button" + onClick={handleShowHistory} + className="rounded-full border border-gray-200 bg-white px-4 py-1.5 text-xs text-gray-500 hover:text-gray-700 hover:border-gray-300 shadow-sm transition-colors" + > + {showHistory ? "Collapse earlier messages" : "View earlier messages"} + </button> + </div> + )} + + {/* Loading spinner — shown briefly while older messages render */} + {loadingMore && ( + <div className="flex justify-center py-2"> + <span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-500" /> + </div> + )} + + {/* Driver handoff timeline. First entry is the creator at creation + time — drop it; subsequent entries are real handoffs and the + ones worth surfacing. Anchored at the top of the viewport so + users skimming the conversation see who's been driving and when + control changed hands. Timestamps stay explicit so handoffs can + be correlated with nearby messages. */} + {(driverHistory ?? []).length > 1 && ( + <div className="flex flex-col gap-1 pb-2"> + {(driverHistory ?? []).slice(1).map((h) => ( + <div key={h.since} className="flex items-center gap-2 text-[11px] text-gray-500"> + <div className="flex-1 h-px bg-gray-200" /> + <span className="text-gray-600"> + <span className="text-amber-600">▸</span>{" "}driving by <span className="text-gray-900 font-medium">{h.driver}</span> since {new Date(h.since).toLocaleString()} + </span> + <div className="flex-1 h-px bg-gray-200" /> + </div> + ))} + </div> + )} + + {/* Message list */} + <div className="flex flex-col gap-2 pb-1"> + <ThreadPrimitive.Messages> + {({ message }) => + message.role === "user" ? ( + <UserMessage /> + ) : ( + <AssistantMessage /> + ) + } + </ThreadPrimitive.Messages> + <div ref={bottomRef} /> + + {/* Pending questions (AskUserQuestion tool) — inside scroll, + rendered as part of the message list so they don't cover history. */} + {questionEntries.length > 0 && ( + <ErrorBoundary name="QuestionsPanel"> + <div className="space-y-3 w-full pt-2"> + {questionEntries.map(([toolUseId, qs]) => + Array.isArray(qs) && qs.length > 0 ? ( + <div + key={toolUseId} + className="rounded-lg border border-violet-100 bg-violet-50/30 p-4 shadow-sm" + > + <AskUserQuestionRenderer + questions={qs} + toolUseId={toolUseId} + onAnswers={sendAnswers} + onDismiss={(id) => sendAnswers(id, {})} + /> + </div> + ) : null, + )} + </div> + </ErrorBoundary> + )} + </div> + </div> + </ThreadPrimitive.Viewport> + + {/* Footer — outside viewport so it stays fixed, never scrolls. + When thread is empty the footer fills the page and is centered. */} + <div className={isEmpty ? "flex-1 flex items-center justify-center px-2 md:px-3 pb-3 md:pb-6" : "shrink-0 z-10 bg-gradient-to-t from-white via-white to-transparent px-2 md:px-3 pt-1 md:pt-2 pb-3 md:pb-6"}> + <div className={isEmpty ? "w-full max-w-[36rem]" : ""}> + {/* Empty-state info & settings — repo info + thinking depth */} + {isEmpty && ( + <div className="mb-4 space-y-2 px-4"> + <div className="flex items-baseline gap-3"> + {title && ( + <h1 className="text-xl font-semibold text-gray-800">{title}</h1> + )} + {driver && ( + <span className="text-xs text-gray-400">driver: {driver}</span> + )} + </div> + <div className="flex items-center gap-3 text-xs text-gray-400"> + {repo && ( + <span className="flex items-center gap-1.5 min-w-0"> + <GitBranch className="h-3.5 w-3.5 shrink-0 text-gray-400" /> + <span className="font-mono truncate">{repo}{branch ? <span className="text-gray-300"> · {branch}</span> : ""}</span> + </span> + )} + <span className="ml-auto flex gap-0.5"> + {[ + { label: "Normal", tokens: null, mode: "normal" }, + { label: "Think", tokens: 16000, mode: "think" }, + { label: "Hard", tokens: 32000, mode: "hard" }, + { label: "Ultrathink", tokens: null, mode: "ultra" }, + ].map((opt) => { + const active = opt.mode === "normal" + ? thinkingBudget === null && thinkingNullMode === "normal" + : opt.mode === "ultra" + ? thinkingBudget === null && thinkingNullMode === "ultra" + : thinkingBudget === opt.tokens + return ( + <button + key={opt.label} + type="button" + onClick={() => { + setThinkingNullMode(opt.mode === "normal" ? "normal" : opt.mode === "ultra" ? "ultra" : "normal") + setMaxThinkingTokens(opt.tokens) + }} + className={ + "px-1.5 py-0.5 text-[11px] rounded transition-colors " + + (active + ? "bg-gray-200 text-gray-700 font-medium" + : "text-gray-400 hover:text-gray-600 hover:bg-gray-100") + } + > + {opt.label} + </button> + ) + })} + </span> + </div> + </div> + )} + + {archived ? ( + <div className="mx-3 md:mx-5 mb-3 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 flex items-center gap-2 text-[12px] text-amber-800"> + <span>📦</span> + <span className="flex-1">This loop is archived (read-only).</span> + {onUnarchive && ( + <button + type="button" + onClick={onUnarchive} + className="rounded border border-amber-300 bg-white px-2 py-0.5 text-[11px] font-medium text-amber-900 hover:bg-amber-100" + > + Unarchive + </button> + )} + </div> + ) : rfdRequestedAt ? ( + <div className="mx-3 md:mx-5 mb-3 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 flex items-center gap-2 text-[12px] text-amber-800"> + <span>✋</span> + <span className="flex-1"> + Released for drive{rfdRequestedBy ? ` by ${rfdRequestedBy}` : ""} — no one can write until someone takes over. + </span> + {onTakeDrive && ( + <button + type="button" + onClick={onTakeDrive} + className="rounded bg-amber-500 px-2 py-0.5 text-[11px] font-medium text-white hover:bg-amber-600" + > + Drive + </button> + )} + </div> + ) : readOnly ? null : ( + <Composer pickedFile={pickedFile} editorSelection={editorSelection} /> + )} + </div> + </div> + + {/* Scroll-to-bottom button — bottom-right, outside viewport so it isn't clipped */} + {showScrollToBottom && ( + <button + type="button" + onClick={scrollToBottom} + className="absolute bottom-40 right-4 z-20 flex h-7 w-7 items-center justify-center rounded-full bg-white border border-gray-200 shadow-sm text-gray-400 hover:text-gray-600 hover:border-gray-300 transition-all" + aria-label="Scroll to bottom" + > + <ArrowDownIcon className="h-3.5 w-3.5" /> + </button> + )} + </ThreadPrimitive.Root> + ); +} diff --git a/web/src/components/chat/ClaudeStatus.tsx b/web/src/components/chat/ClaudeStatus.tsx new file mode 100644 index 00000000..b557b776 --- /dev/null +++ b/web/src/components/chat/ClaudeStatus.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from "react"; +import { useLoopRuntimeExtra } from "@/useLoopRuntime"; + +const ACTION_WORDS = [ + "Thinking", + "Processing", + "Analyzing", + "Working", + "Computing", + "Reasoning", +]; + +function formatElapsedTime(totalSeconds: number) { + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`; +} + +function formatTokens(n: number) { + if (n < 0) n = 0; + if (n < 1000) return String(Math.round(n)); + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(1)}M`; +} + +interface ClaudeStatusProps { + isLoading: boolean; + /** Stable getter that reads the latest streaming output tokens directly from + * the ref — no React re-render needed. The rAF loop polls this every frame. */ + getTokenCount: () => number; + /** Stable getter for the waiting-for-first-token flag. True while an LLM + * request is in-flight with no tokens yet (arrow-up), false once streaming + * (arrow-down). Reset on each message_start within a turn. */ + getWaitingForResponse: () => boolean; +} + +export default function ClaudeStatus({ isLoading, getTokenCount, getWaitingForResponse }: ClaudeStatusProps) { + const { thinkingBudget, turnGeneration, turnStartedAt } = useLoopRuntimeExtra(); + const [elapsedTime, setElapsedTime] = useState(0); + const [displayTokens, setDisplayTokens] = useState(0); + const [ellipsis, setEllipsis] = useState(""); + + // Elapsed timer + ellipsis cycle. + // Uses turnStartedAt (persisted in sessionStorage) as the effective start + // time when available, so the timer survives page refreshes during an + // active generation. + useEffect(() => { + if (!isLoading) { + setElapsedTime(0); + setDisplayTokens(0); + setEllipsis(""); + return; + } + const effectiveStart = turnStartedAt ?? Date.now(); + const timer = setInterval(() => { + setElapsedTime(Math.floor((Date.now() - effectiveStart) / 1000)); + }, 250); + const dotTimer = setInterval(() => { + setEllipsis((prev) => (prev.length >= 3 ? "" : prev + ".")); + }, 400); + return () => { + clearInterval(timer); + clearInterval(dotTimer); + }; + }, [isLoading, turnGeneration, turnStartedAt]); + + // Smooth easing rAF loop — polls the getter every frame, no React re-render + // needed on the hot content_block_delta path. Same pattern as the official + // Claude Code TUI (SpinnerAnimationRow / useAnimationFrame(50)). + useEffect(() => { + if (!isLoading) return; + + let raf: number; + const step = () => { + const target = getTokenCount(); + setDisplayTokens((prev) => { + if (Math.abs(target - prev) < 0.5) return target; + const diff = target - prev; + const speed = Math.max(1, Math.ceil(Math.abs(diff) / 8)); + return prev + Math.sign(diff) * Math.min(speed, Math.abs(diff)); + }); + raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [isLoading, getTokenCount]); + + if (!isLoading) return null; + + const statusText = + ACTION_WORDS[Math.floor(elapsedTime / 3) % ACTION_WORDS.length]; + + // ↑ uploading (LLM request in-flight, no tokens yet), ↓ streaming. + // Uses the getter directly so it switches immediately on message_start + // without waiting for a React re-render. + const arrow = getWaitingForResponse() ? "↑" : "↓"; + + const budgetLabel = + thinkingBudget === null + ? null + : thinkingBudget >= 32000 + ? "think-hard" + : thinkingBudget >= 16000 + ? "think" + : null; + + return ( + <div className="relative mb-1.5 pl-6 md:pl-8"> + + {/* Morphing dot — vertically centered with text line */} + <div className="absolute left-[3px] top-1/2 -translate-y-1/2 z-10 h-[6px] w-[6px] animate-[morph_2s_ease-in-out_infinite] bg-blue-500 shadow-[0_0_6px_rgba(59,130,246,0.4)]" /> + + {/* Content */} + <div className="flex items-center gap-2 md:gap-3 text-xs text-gray-500 py-1"> + {/* Status text + ellipsis */} + <span className="font-medium text-gray-600"> + {statusText}<span className="inline-block w-4 tabular-nums">{ellipsis}</span> + </span> + + {/* Thinking budget badge (skip ultrathink) */} + {budgetLabel && ( + <> + <span className="text-gray-300">·</span> + <span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600"> + {budgetLabel} + </span> + </> + )} + + {/* Elapsed time — pushed right but not flush */} + <span className="tabular-nums text-gray-500 ml-auto mr-1"> + {formatElapsedTime(elapsedTime)} + </span> + + {/* Token count — this turn's streaming tokens, eased toward real value */} + <span className="tabular-nums text-gray-600 font-medium"> + {arrow}{formatTokens(displayTokens)} tk + </span> + </div> + </div> + ); +} diff --git a/web/src/components/chat/Composer.tsx b/web/src/components/chat/Composer.tsx new file mode 100644 index 00000000..29a141a6 --- /dev/null +++ b/web/src/components/chat/Composer.tsx @@ -0,0 +1,451 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { + ComposerPrimitive, + AuiIf, + useAuiState, + useAui, +} from "@assistant-ui/react"; +import { + ArrowUpIcon, + SquareIcon, + ListOrderedIcon, + X, + FileText, + Plus, + FilePlus, + Target, + CircleCheck, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import ClaudeStatus from "./ClaudeStatus"; + +import PlanModeToggle from "./PlanModeToggle"; +import ModelSelector from "./ModelSelector"; +import PluginsButton from "./PluginsButton"; +import SlashCommand from "./SlashCommand"; +import TokenUsagePie from "./TokenUsagePie"; +import { FilePicker } from "./FilePicker"; +import { useLoopRuntimeExtra } from "@/useLoopRuntime"; +import { getChatHistory, appendChatHistory, readFile } from "@/api"; + +const FALLBACK_CONTEXT_WINDOW = 200_000; +const MAX_HISTORY = 500; + +export default function Composer({ pickedFile, editorSelection }: { pickedFile?: string | null; editorSelection?: { from: number; to: number } | null }) { + const isRunning = useAuiState((s) => s.thread.isRunning); + const hasInput = useAuiState( + (s) => typeof s.composer.text === "string" && s.composer.text.trim().length > 0, + ); + const composerText = useAuiState((s) => s.composer.text); + + const { provider, permissionMode, setPermissionMode, enqueueMessage, queue, clearQueue, removeFromQueue, loopId, contextTokens, cumulativeTokens, getStreamingTokenCount, getWaitingForResponse, suppressSlashRef, goal, setGoal, goalStatus, completeGoal } = useLoopRuntimeExtra(); + const contextWindow = provider?.contextWindow ?? FALLBACK_CONTEXT_WINDOW; + + const aui = useAui(); + + // ── File references ── + const [includeEditorFile, setIncludeEditorFile] = useState(false) + const [addedFiles, setAddedFiles] = useState<string[]>([]) + const [pickerOpen, setPickerOpen] = useState(false) + + const allFileRefs = [ + ...(includeEditorFile && pickedFile ? [pickedFile] : []), + ...addedFiles, + ] + + const toggleEditorFile = () => setIncludeEditorFile((v) => !v) + + const addFile = (path: string) => { + setAddedFiles((prev) => prev.includes(path) ? prev : [...prev, path]) + setPickerOpen(false) + } + + const removeFile = (path: string) => { + setAddedFiles((prev) => prev.filter((p) => p !== path)) + } + + const shortFileName = (path: string) => { + const idx = path.lastIndexOf("/") + return idx >= 0 ? path.slice(idx + 1) : path + } + + // Pre-computed file context, updated when file refs change + const fileContextRef = useRef("") + const fileBlocksRef = useRef<{ path: string; content: string }[]>([]) + const [fileContextLoading, setFileContextLoading] = useState(false) + + useEffect(() => { + let cancelled = false + async function build() { + if (allFileRefs.length === 0) { + fileContextRef.current = "" + fileBlocksRef.current = [] + return + } + setFileContextLoading(true) + const parts: string[] = [] + const blocks: { path: string; content: string }[] = [] + for (const path of allFileRefs) { + if (cancelled) return + try { + const r = await readFile(loopId, path) + if (r && r.content) { + const ext = path.includes(".") ? path.split(".").pop() ?? "" : "" + const sel = (path === pickedFile) ? editorSelection : null + let content = r.content + let label = path + if (sel) { + const allLines = r.content.split("\n") + const fromIdx = Math.max(0, sel.from - 1) + const toIdx = Math.min(allLines.length, sel.to) + content = allLines.slice(fromIdx, toIdx).join("\n") + label = `${path} (${sel.from}-${sel.to})` + } + // Escape ``` in content so the regex-based parser in UserMessage can reliably detect boundaries + const safe = content.replace(/```/g, "``​`") + parts.push(`\n# File: ${label}\n\`\`\`${ext}\n${safe}\n\`\`\`\n`) + blocks.push({ path: label, content }) + } + } catch {} + } + if (!cancelled) { + fileContextRef.current = parts.join("") + fileBlocksRef.current = blocks + setFileContextLoading(false) + } + } + build() + return () => { cancelled = true } + }, [allFileRefs.join(","), loopId, pickedFile, editorSelection?.from, editorSelection?.to]) + + const wrapWithContext = (text: string) => { + const ctx = fileContextRef.current + return ctx ? `${ctx}\n${text}` : text + } + + // ── chat history ── + const [history, setHistory] = useState<string[]>([]); + const [historyIdx, setHistoryIdx] = useState(-1); + const pendingDraftRef = useRef(""); + const textRef = useRef(""); + textRef.current = typeof composerText === "string" ? composerText : ""; + + useEffect(() => { + if (!loopId) return; + getChatHistory(loopId).then((entries) => { + setHistory(entries); + setHistoryIdx(-1); + }); + }, [loopId]); + + const saveToHistory = (text: string) => { + if (!text.trim() || !loopId) return; + const trimmed = text.trim(); + setHistory((prev) => { + if (prev[prev.length - 1] === trimmed) return prev; + const next = [...prev, trimmed]; + if (next.length > MAX_HISTORY) next.splice(0, next.length - MAX_HISTORY); + return next; + }); + setHistoryIdx(-1); + appendChatHistory(loopId, trimmed).catch(() => {}); + }; + + const handleEnqueue = () => { + const text = typeof composerText === "string" ? composerText.trim() : ""; + if (!text) return; + saveToHistory(text); + if (fileContextRef.current) { + try { sessionStorage.setItem("loopat:pendingFileContext", fileContextRef.current) } catch {} + } + enqueueMessage(wrapWithContext(text)); + aui.composer().setText(""); + }; + + const handleSubmit = () => { + const text = textRef.current.trim(); + if (!text) return; + saveToHistory(text); + if (fileContextRef.current) { + try { sessionStorage.setItem("loopat:pendingFileContext", fileContextRef.current) } catch {} + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Ignore Enter during IME composition (e.g. Chinese input method + // confirmation) to avoid prematurely sending unfinished text. + if ((e.nativeEvent as any).isComposing || e.keyCode === 229) { + return; + } + // Ctrl+C clears the input (macOS / Linux only; conflicts with copy on Windows). + if ( + e.key === "c" && + e.ctrlKey && + !e.altKey && + !e.metaKey && + !e.shiftKey && + !/windows/i.test(navigator.userAgent) + ) { + const ta = e.target as HTMLTextAreaElement + if (ta.selectionStart === ta.selectionEnd && textRef.current.trim().length > 0) { + e.preventDefault() + aui.composer().setText("") + return + } + } + // Reset slash-suppression on any printable keystroke so the / menu + // reappears once the user actually starts typing again. + if (e.key !== "ArrowUp" && e.key !== "ArrowDown") { + suppressSlashRef.current = false; + } + if (e.key === "Enter" && !e.nativeEvent.isComposing && !e.shiftKey && isRunning) { + e.preventDefault(); + handleEnqueue(); + return; + } + if (e.key === "ArrowUp" && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) { + if (history.length === 0) return; + e.preventDefault(); + suppressSlashRef.current = true; + if (historyIdx === -1) { + pendingDraftRef.current = textRef.current; + setHistoryIdx(history.length - 1); + aui.composer().setText(history[history.length - 1]); + } else if (historyIdx > 0) { + const nextIdx = historyIdx - 1; + setHistoryIdx(nextIdx); + aui.composer().setText(history[nextIdx]); + } + return; + } + if (e.key === "ArrowDown" && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) { + if (historyIdx === -1) return; + e.preventDefault(); + suppressSlashRef.current = true; + if (historyIdx < history.length - 1) { + const nextIdx = historyIdx + 1; + setHistoryIdx(nextIdx); + aui.composer().setText(history[nextIdx]); + } else { + setHistoryIdx(-1); + aui.composer().setText(pendingDraftRef.current); + } + return; + } + }; + + return ( + <ComposerPrimitive.Root className="relative flex w-full flex-col" onSubmit={handleSubmit}> + {/* Claude Status bar */} + <ClaudeStatus isLoading={isRunning} getTokenCount={getStreamingTokenCount} getWaitingForResponse={getWaitingForResponse} /> + + {/* Queue: inline items with per-item remove */} + {queue.length > 0 && ( + <div className="mb-1.5 space-y-1 px-2"> + {queue.map((msg, i) => ( + <div + key={i} + className="flex items-center justify-between gap-2 bg-gray-50 border border-gray-200 rounded-md px-2.5 py-1.5" + > + <span className="text-xs text-gray-600 line-clamp-3 break-words whitespace-pre-wrap min-w-0"> + <span className="text-gray-400 mr-1.5 shrink-0">{i + 1}.</span> + {msg} + </span> + <button + onClick={() => removeFromQueue(i)} + className="text-gray-400 hover:text-gray-600 shrink-0 p-0.5 rounded hover:bg-gray-100 transition-colors" + title="Remove from queue" + > + <X className="h-3.5 w-3.5" /> + </button> + </div> + ))} + </div> + )} + + {/* File references chips */} + {allFileRefs.length > 0 && ( + <div className="flex items-center gap-1.5 px-1 mb-1 flex-wrap"> + {allFileRefs.map((path) => ( + <span + key={path} + className="inline-flex items-center gap-1 rounded-md border border-blue-200 bg-blue-50 px-2 py-0.5 text-[11px] text-blue-700" + > + <FileText size={11} className="shrink-0" /> + <span className="truncate max-w-[160px]">{shortFileName(path)}</span> + {path !== pickedFile && ( + <button + onClick={() => removeFile(path)} + className="ml-0.5 hover:text-blue-900" + > + <X size={11} /> + </button> + )} + {path === pickedFile && includeEditorFile && ( + <span className="text-[9px] text-blue-400 ml-0.5"> + {editorSelection ? `(${editorSelection.from}-${editorSelection.to})` : "editor"} + </span> + )} + </span> + ))} + {fileContextLoading && ( + <span className="text-[10px] text-gray-400">loading...</span> + )} + </div> + )} + + {/* Goal banner — shown when a /goal is active */} + {goal && ( + <div className={`mb-1.5 flex items-center gap-2 px-2 py-1.5 rounded-lg border group ${ + goalStatus === "completed" + ? "border-green-200 bg-green-50" + : "border-amber-200 bg-amber-50" + }`}> + {goalStatus === "completed" ? ( + <CircleCheck className="h-3.5 w-3.5 text-green-600 shrink-0" /> + ) : ( + <Target className="h-3.5 w-3.5 text-amber-600 shrink-0" /> + )} + <span className={`text-xs font-medium truncate flex-1 min-w-0 ${ + goalStatus === "completed" + ? "text-green-800 line-through" + : "text-amber-800" + }`}>{goal}</span> + {goalStatus === "active" && ( + <button + onClick={() => completeGoal?.()} + className="shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium text-amber-700 hover:bg-amber-200 opacity-0 group-hover:opacity-100 transition-opacity" + title="Mark goal as complete" + > + Done + </button> + )} + <button + onClick={() => setGoal?.(null)} + className="shrink-0 p-0.5 rounded hover:bg-amber-200/50 opacity-0 group-hover:opacity-100 transition-opacity" + title="Clear goal" + > + <X className={`h-3 w-3 ${goalStatus === "completed" ? "text-green-500" : "text-amber-600"}`} /> + </button> + </div> + )} + + <div + data-slot="composer-shell" + className="flex w-full flex-col gap-2 rounded-2xl border border-gray-200 bg-white p-2.5 shadow-sm" + > + <SlashCommand /> + + <ComposerPrimitive.Input + placeholder="Send a message..." + className="max-h-32 min-h-10 w-full resize-none bg-transparent px-1.5 py-1 text-sm text-gray-900 outline-none placeholder:text-gray-400" + rows={1} + autoFocus + aria-label="Message input" + onKeyDown={handleKeyDown} + /> + + {/* Toolbar */} + <div className="flex items-center justify-between gap-0.5 border-t border-gray-100 pt-2 overflow-hidden"> + <div className="flex items-center gap-0.5 min-w-0"> + <TokenUsagePie + used={Math.min(contextTokens, contextWindow)} + total={contextWindow} + /> + + <ModelSelector /> + + <PluginsButton + onPick={(slashCommand) => { + const current = textRef.current + const next = current.length === 0 || current.endsWith(" ") + ? `${current}${slashCommand}` + : `${current} ${slashCommand}` + aui.composer().setText(next) + }} + /> + + {/* File reference buttons */} + {pickedFile && ( + <button + type="button" + onClick={toggleEditorFile} + className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors ${ + includeEditorFile + ? "bg-blue-50 text-blue-600 border border-blue-200" + : "text-gray-400 hover:text-gray-600 hover:bg-gray-100" + }`} + title={includeEditorFile ? "Remove editor file from context" : "Include editor file in context"} + > + <FileText size={12} /> + {shortFileName(pickedFile)} + </button> + )} + <button + type="button" + onClick={() => setPickerOpen((v) => !v)} + className="inline-flex items-center justify-center rounded-md border border-gray-200 bg-gray-50 w-[21px] h-[21px] mt-px text-gray-500 hover:bg-gray-100 transition-colors" + title="Add file to context" + > + <FilePlus size={12} /> + </button> + {pickerOpen && ( + <FilePicker loopId={loopId} onPick={addFile} onClose={() => setPickerOpen(false)} /> + )} + </div> + + <div className="flex items-center gap-1 sm:gap-2 shrink-0"> + <PlanModeToggle + mode={permissionMode} + onChange={setPermissionMode} + /> + + {/* Send / Enqueue button */} + {hasInput && ( + isRunning ? ( + <Button + type="button" + variant="default" + size="icon" + onClick={handleEnqueue} + className="h-8 w-8 rounded-lg bg-amber-500 hover:bg-amber-600 text-white" + aria-label="Enqueue message" + title="Enqueue message" + > + <ListOrderedIcon className="h-4 w-4" /> + </Button> + ) : ( + <ComposerPrimitive.Send asChild> + <Button + type="button" + variant="default" + size="icon" + className="h-8 w-8 rounded-lg bg-gray-800 hover:bg-gray-900 text-white" + aria-label="Send message" + > + <ArrowUpIcon className="h-4 w-4" /> + </Button> + </ComposerPrimitive.Send> + ) + )} + + {/* Stop button: only visible when running */} + <AuiIf condition={(s) => s.thread.isRunning}> + <ComposerPrimitive.Cancel asChild> + <Button + type="button" + variant="default" + size="icon" + className="h-8 w-8 rounded-lg bg-red-500 hover:bg-red-600 text-white" + aria-label="Stop generating" + > + <SquareIcon className="h-3 w-3 fill-current" /> + </Button> + </ComposerPrimitive.Cancel> + </AuiIf> + </div> + </div> + </div> + </ComposerPrimitive.Root> + ); +} diff --git a/web/src/components/chat/CopyButton.tsx b/web/src/components/chat/CopyButton.tsx new file mode 100644 index 00000000..11575b14 --- /dev/null +++ b/web/src/components/chat/CopyButton.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface CopyButtonProps { + content: string; + className?: string; + iconClassName?: string; +} + +export default function CopyButton({ content, className, iconClassName }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + if (!content || copied) return; + navigator.clipboard.writeText(content).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + return ( + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon-xs" + onClick={handleCopy} + className={className} + title="Copy message" + > + {copied ? ( + <CheckIcon className={cn("h-3 w-3 text-emerald-500", iconClassName)} /> + ) : ( + <CopyIcon className={cn("h-3 w-3", iconClassName)} /> + )} + </Button> + </TooltipTrigger> + <TooltipContent>{copied ? "Copied!" : "Copy"}</TooltipContent> + </Tooltip> + ); +} diff --git a/web/src/components/chat/ErrorBoundary.tsx b/web/src/components/chat/ErrorBoundary.tsx new file mode 100644 index 00000000..3da08422 --- /dev/null +++ b/web/src/components/chat/ErrorBoundary.tsx @@ -0,0 +1,37 @@ +import { Component } from "react" +import type { ReactNode } from "react" + +interface Props { + name: string + children: ReactNode +} + +interface State { + error: Error | null +} + +export default class ErrorBoundary extends Component<Props, State> { + constructor(props: Props) { + super(props) + this.state = { error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: any) { + console.error("[ErrorBoundary:" + this.props.name + "]", error.message, error.stack, info) + } + + render() { + if (this.state.error) { + return ( + <div className="rounded border border-red-200 bg-red-50 p-3 text-xs text-red-700"> + <b>Error in {this.props.name}:</b> {this.state.error.message} + </div> + ) + } + return this.props.children + } +} diff --git a/web/src/components/chat/FilePicker.tsx b/web/src/components/chat/FilePicker.tsx new file mode 100644 index 00000000..266fe1b2 --- /dev/null +++ b/web/src/components/chat/FilePicker.tsx @@ -0,0 +1,198 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from "react" +import { createPortal } from "react-dom" +import { FileText, FolderOpen, ChevronRight, ChevronDown, Search, CornerDownLeft, ArrowUp, ArrowDown } from "lucide-react" +import { listFiles, listFilesTree, type FileEntry } from "@/api" +import { cn } from "@/lib/utils" + +interface FilePickerProps { + loopId: string + onPick: (path: string) => void + onClose: () => void +} + +type VisibleEntry = { entry: FileEntry; depth: number } + +export function FilePicker({ loopId, onPick, onClose }: FilePickerProps) { + const [roots, setRoots] = useState<FileEntry[]>([]) + const [allFiles, setAllFiles] = useState<FileEntry[]>([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + const [selectedIdx, setSelectedIdx] = useState(0) + const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set()) + const [dirChildren, setDirChildren] = useState<Record<string, FileEntry[]>>({}) + const inputRef = useRef<HTMLInputElement>(null) + const listRef = useRef<HTMLDivElement>(null) + + useEffect(() => { + setLoading(true) + Promise.all([ + listFiles(loopId, "workdir").catch(() => [] as FileEntry[]), + listFilesTree(loopId, "workdir").catch(() => [] as FileEntry[]), + ]).then(([wd, tree]) => { + setRoots(wd) + // tree already includes the shallow entries plus all descendants + setAllFiles(tree) + setLoading(false) + }) + }, [loopId]) + + const showSearch = search.trim().length > 0 + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase() + if (!q) return [] + return allFiles.filter((e) => e.type === "file" && (e.path.toLowerCase().includes(q) || e.name.toLowerCase().includes(q))) + }, [allFiles, search]) + + // Build flat list of visible tree entries + const visibleTree = useMemo(() => { + if (showSearch) return [] + const result: VisibleEntry[] = [] + function walk(entries: FileEntry[], depth: number) { + for (const e of entries) { + result.push({ entry: e, depth }) + if (e.type === "dir" && expandedDirs.has(e.path) && dirChildren[e.path]) { + walk(dirChildren[e.path], depth + 1) + } + } + } + walk(roots, 0) + return result + }, [roots, showSearch, expandedDirs, dirChildren]) + + useEffect(() => { setSelectedIdx(0) }, [search]) + + useEffect(() => { + if (!listRef.current) return + const el = listRef.current.children[selectedIdx] as HTMLElement | undefined + el?.scrollIntoView({ block: "nearest" }) + }, [selectedIdx]) + + const toggleDir = useCallback(async (entry: FileEntry) => { + if (expandedDirs.has(entry.path)) { + setExpandedDirs((prev) => { const next = new Set(prev); next.delete(entry.path); return next }) + } else { + if (!dirChildren[entry.path]) { + const c = await listFiles(loopId, entry.path).catch(() => [] as FileEntry[]) + setDirChildren((prev) => ({ ...prev, [entry.path]: c })) + } + setExpandedDirs((prev) => new Set(prev).add(entry.path)) + } + }, [expandedDirs, dirChildren, loopId]) + + const onPickOrToggle = useCallback((entry: FileEntry) => { + if (entry.type === "dir") { toggleDir(entry); return } + onPick(entry.path) + }, [toggleDir, onPick]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { e.preventDefault(); onClose(); return } + if (showSearch) { + if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1)) } + else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIdx((i) => Math.max(i - 1, 0)) } + else if (e.key === "Enter") { e.preventDefault(); if (filtered[selectedIdx]) onPick(filtered[selectedIdx].path) } + } else { + if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIdx((i) => Math.min(i + 1, visibleTree.length - 1)) } + else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIdx((i) => Math.max(i - 1, 0)) } + else if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + e.preventDefault() + const cur = visibleTree[selectedIdx] + if (!cur || cur.entry.type !== "dir") return + const isOpen = expandedDirs.has(cur.entry.path) + if (e.key === "ArrowRight" && !isOpen) toggleDir(cur.entry) + else if (e.key === "ArrowLeft" && isOpen) toggleDir(cur.entry) + } + else if (e.key === "Enter") { e.preventDefault(); if (visibleTree[selectedIdx]) onPickOrToggle(visibleTree[selectedIdx].entry) } + } + } + + return createPortal( + <> + <div className="fixed inset-0 z-50 bg-black/20" onClick={onClose} /> + <div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]" onClick={onClose}> + <div + className="w-[420px] max-w-[calc(100vw-2rem)] max-h-[60vh] bg-white rounded-xl shadow-2xl border border-gray-200 flex flex-col overflow-hidden" + onClick={(e) => e.stopPropagation()} + onKeyDown={handleKeyDown} + > + <div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 shrink-0"> + <Search className="h-4 w-4 text-gray-400 shrink-0" /> + <input + ref={inputRef} + autoFocus + type="text" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search files..." + className="flex-1 text-sm outline-none text-gray-900 placeholder:text-gray-400" + /> + </div> + + <div ref={listRef} className="flex-1 overflow-y-auto py-2" role="listbox"> + {loading ? ( + <div className="px-4 py-8 text-center text-sm text-gray-400">Loading...</div> + ) : showSearch ? ( + filtered.length === 0 ? ( + <div className="px-4 py-8 text-center text-sm text-gray-400">No files match your search</div> + ) : ( + filtered.map((entry, i) => ( + <button + key={entry.path} + type="button" + role="option" + aria-selected={i === selectedIdx} + onClick={() => onPick(entry.path)} + onMouseEnter={() => setSelectedIdx(i)} + className={cn("w-full px-6 py-1.5 text-left flex items-center gap-2 transition-colors text-[12px]", i === selectedIdx && "bg-blue-50")} + > + <FileText size={12} className="text-blue-400 shrink-0" /> + <span className="truncate font-mono text-gray-700">{entry.path}</span> + </button> + )) + ) + ) : roots.length === 0 ? ( + <div className="px-4 py-8 text-center text-sm text-gray-400">No files found</div> + ) : ( + visibleTree.map(({ entry, depth }, i) => { + const isDir = entry.type === "dir" + const isOpen = expandedDirs.has(entry.path) + return ( + <button + key={entry.path} + type="button" + role="option" + aria-selected={i === selectedIdx} + onClick={() => onPickOrToggle(entry)} + onMouseEnter={() => setSelectedIdx(i)} + className={cn("w-full text-left flex items-center gap-1.5 px-2 py-1 text-[12px] transition-colors", i === selectedIdx && "bg-blue-50")} + style={{ paddingLeft: 12 + depth * 16 }} + > + {isDir ? ( + <> + {isOpen ? <ChevronDown size={11} className="text-gray-300 shrink-0" /> : <ChevronRight size={11} className="text-gray-300 shrink-0" />} + <FolderOpen size={13} className="text-amber-500 shrink-0" /> + </> + ) : ( + <> + <span className="w-[11px] shrink-0" /> + <FileText size={13} className="text-blue-400 shrink-0" /> + </> + )} + <span className="truncate">{entry.name}</span> + </button> + ) + }) + )} + </div> + + <div className="flex items-center gap-4 px-4 py-2 border-t border-gray-100 text-[10px] text-gray-400 shrink-0"> + <span className="flex items-center gap-1"><ArrowUp className="h-3 w-3" /><ArrowDown className="h-3 w-3" />navigate</span> + <span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" />select</span> + <span className="flex items-center gap-1"><kbd className="px-1 py-0.5 rounded bg-gray-100 font-mono text-[9px]">esc</kbd>close</span> + </div> + </div> + </div> + </>, + document.body, + ) +} diff --git a/web/src/components/chat/MarkdownBlock.tsx b/web/src/components/chat/MarkdownBlock.tsx new file mode 100644 index 00000000..dca85da2 --- /dev/null +++ b/web/src/components/chat/MarkdownBlock.tsx @@ -0,0 +1,236 @@ +"use client"; + +import "@assistant-ui/react-markdown/styles/dot.css"; + +import { + type CodeHeaderProps, + MarkdownTextPrimitive, + unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, + useIsMarkdownCodeBlock, +} from "@assistant-ui/react-markdown"; +import remarkGfm from "remark-gfm"; +import { memo, useState } from "react"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const MarkdownTextImpl = () => { + return ( + <MarkdownTextPrimitive + remarkPlugins={[remarkGfm]} + className="aui-md" + components={defaultComponents} + /> + ); +}; + +export const MarkdownBlock = memo(MarkdownTextImpl); + +/* ─── Code header with language label and copy button ─── */ + +const CodeHeader: React.FC<CodeHeaderProps> = ({ language, code }) => { + const { isCopied, copyToClipboard } = useClipboard(); + const onCopy = () => { + if (!code || isCopied) return; + copyToClipboard(code); + }; + + return ( + <div className="flex items-center justify-between gap-2 rounded-t-lg border border-gray-700 border-b-0 bg-gray-800 px-3 py-1.5 text-xs"> + <span className="font-medium text-gray-400 lowercase"> + {language || "text"} + </span> + <button + type="button" + onClick={onCopy} + className="flex items-center gap-1 rounded px-1.5 py-0.5 text-gray-400 transition-colors hover:bg-gray-700 hover:text-gray-200" + > + {isCopied ? ( + <CheckIcon className="h-3 w-3 text-emerald-400" /> + ) : ( + <CopyIcon className="h-3 w-3" /> + )} + <span className="text-[10px]">{isCopied ? "Copied" : "Copy"}</span> + </button> + </div> + ); +}; + +/* ─── Clipboard hook ─── */ + +function useClipboard({ copiedDuration = 2000 } = {}) { + const [isCopied, setIsCopied] = useState(false); + + const copyToClipboard = (value: string) => { + if (!value) return; + navigator.clipboard.writeText(value).then(() => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), copiedDuration); + }); + }; + + return { isCopied, copyToClipboard }; +} + +/* ─── Custom markdown components ─── */ + +const defaultComponents = memoizeMarkdownComponents({ + h1: ({ className, ...props }: React.ComponentProps<"h1">) => ( + <h1 + className={cn( + "mb-2 scroll-m-20 font-semibold text-base first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + h2: ({ className, ...props }: React.ComponentProps<"h2">) => ( + <h2 + className={cn( + "mt-3 mb-1.5 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + h3: ({ className, ...props }: React.ComponentProps<"h3">) => ( + <h3 + className={cn( + "mt-2.5 mb-1 scroll-m-20 font-semibold text-sm first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + h4: ({ className, ...props }: React.ComponentProps<"h4">) => ( + <h4 + className={cn( + "mt-2 mb-1 scroll-m-20 font-medium text-sm first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + p: ({ className, ...props }: React.ComponentProps<"p">) => ( + <p + className={cn( + "my-1 leading-normal first:mt-0 last:mb-0", + className, + )} + {...props} + /> + ), + a: ({ className, ...props }: React.ComponentProps<"a">) => ( + <a + className={cn( + "text-blue-600 underline underline-offset-2 hover:text-blue-500", + className, + )} + target="_blank" + rel="noopener noreferrer" + {...props} + /> + ), + blockquote: ({ className, ...props }: React.ComponentProps<"blockquote">) => ( + <blockquote + className={cn( + "my-2.5 border-gray-300 border-s-2 ps-3 text-gray-500 italic", + className, + )} + {...props} + /> + ), + ul: ({ className, ...props }: React.ComponentProps<"ul">) => ( + <ul + className={cn( + "my-2 ms-4 list-disc marker:text-gray-400 [&>li]:mt-1", + className, + )} + {...props} + /> + ), + ol: ({ className, ...props }: React.ComponentProps<"ol">) => ( + <ol + className={cn( + "my-2 ms-4 list-decimal marker:text-gray-400 [&>li]:mt-1", + className, + )} + {...props} + /> + ), + hr: ({ className, ...props }: React.ComponentProps<"hr">) => ( + <hr + className={cn("my-2 border-gray-200", className)} + {...props} + /> + ), + table: ({ className, ...props }: React.ComponentProps<"table">) => ( + <div className="my-2 overflow-x-auto"> + <table + className={cn( + "min-w-full border-separate border-spacing-0", + className, + )} + {...props} + /> + </div> + ), + th: ({ className, ...props }: React.ComponentProps<"th">) => ( + <th + className={cn( + "bg-gray-50 px-2 py-1 text-start font-medium first:rounded-ss-lg last:rounded-se-lg [[align=center]]:text-center [[align=right]]:text-right", + className, + )} + {...props} + /> + ), + td: ({ className, ...props }: React.ComponentProps<"td">) => ( + <td + className={cn( + "border-gray-200 border-s border-b px-2 py-1 text-start last:border-e [[align=center]]:text-center [[align=right]]:text-right", + className, + )} + {...props} + /> + ), + tr: ({ className, ...props }: React.ComponentProps<"tr">) => ( + <tr + className={cn( + "m-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-es-lg [&:last-child>td:last-child]:rounded-ee-lg", + className, + )} + {...props} + /> + ), + li: ({ className, ...props }: React.ComponentProps<"li">) => ( + <li className={cn("leading-normal", className)} {...props} /> + ), + sup: ({ className, ...props }: React.ComponentProps<"sup">) => ( + <sup + className={cn("[&>a]:text-xs [&>a]:no-underline", className)} + {...props} + /> + ), + pre: ({ className, ...props }: React.ComponentProps<"pre">) => ( + <pre + className={cn( + "overflow-x-auto rounded-t-none rounded-b-lg border border-gray-700 border-t-0 bg-gray-900 p-3 text-xs leading-relaxed text-gray-200", + className, + )} + {...props} + /> + ), + code: function Code({ className, ...props }: React.ComponentProps<"code">) { + const isCodeBlock = useIsMarkdownCodeBlock(); + return ( + <code + className={cn( + !isCodeBlock && + "rounded-md border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[0.85em] text-gray-800", + className, + )} + {...props} + /> + ); + }, + CodeHeader, +}); diff --git a/web/src/components/chat/ModelSelector.tsx b/web/src/components/chat/ModelSelector.tsx new file mode 100644 index 00000000..3e2f9b75 --- /dev/null +++ b/web/src/components/chat/ModelSelector.tsx @@ -0,0 +1,251 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { Cpu, ChevronDown, Search, User, Globe, CornerDownLeft, ArrowUp, ArrowDown } from "lucide-react"; +import { getProviders, stripThinkingBlocks, type ProvidersResponse } from "@/api"; +import { useLoopRuntimeExtra } from "@/useLoopRuntime"; + +interface FlatModel { + provName: string + modelId: string + source: "personal" | "workspace" +} + +export default function ModelSelector() { + const { provider, selectProvider, thinkingBlockCount, loopId } = useLoopRuntimeExtra(); + const [providers, setProviders] = useState<ProvidersResponse | null>(null); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [selectedIdx, setSelectedIdx] = useState(0); + const inputRef = useRef<HTMLInputElement>(null); + const listRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + getProviders().then(setProviders); + }, []); + + const currentName = provider?.name || "default"; + const currentModel = provider?.model || ""; + + // Build a flat list of enabled models from all enabled providers, + // filtered by the search query. + const flatModels = useMemo<FlatModel[]>(() => { + if (!providers) return []; + const result: FlatModel[] = []; + for (const [provName, info] of Object.entries(providers.providers)) { + if (info.enabled === false || !info.hasKey) continue; + for (const m of info.models ?? []) { + if (m.enabled === false) continue; + const q = search.toLowerCase().trim(); + if (q && !m.id.toLowerCase().includes(q) && !provName.toLowerCase().includes(q)) continue; + result.push({ provName, modelId: m.id, source: info.source }); + } + } + return result; + }, [providers, search]); + + // Reset selection when filter changes. + useEffect(() => { + setSelectedIdx(0); + }, [search, open]); + + // Scroll selected item into view. + useEffect(() => { + if (!listRef.current) return; + const el = listRef.current.children[selectedIdx] as HTMLElement | undefined; + el?.scrollIntoView({ block: "nearest" }); + }, [selectedIdx]); + + // Keyboard shortcut: Ctrl+K / Cmd+K to open. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + setOpen((prev) => !prev); + } + if (e.key === "Escape" && open) { + setOpen(false); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open]); + + // Group flat models by provider for display. + const groups = useMemo(() => { + const map = new Map<string, { source: "personal" | "workspace"; models: { modelId: string; idx: number }[] }>(); + flatModels.forEach((m, i) => { + const g = map.get(m.provName) ?? { source: m.source, models: [] }; + g.models.push({ modelId: m.modelId, idx: i }); + map.set(m.provName, g); + }); + return map; + }, [flatModels]); + + const isClaudeModel = (model: string) => model.toLowerCase().startsWith("claude"); + const onPick = useCallback(async (item: FlatModel) => { + setOpen(false); + if (item.provName === currentName && item.modelId === currentModel) return; + const crossClaudeBoundary = isClaudeModel(currentModel) !== isClaudeModel(item.modelId); + if (crossClaudeBoundary && thinkingBlockCount > 0 && loopId) { + const msg = + `Switching between Claude and non-Claude models makes the ${thinkingBlockCount} ` + + `existing thinking block${thinkingBlockCount === 1 ? "" : "s"} in this conversation ` + + `fail signature validation.\n\n` + + `Click OK to remove them from the AI's context (chat history stays). ` + + `Cancel to keep the current provider.`; + if (!window.confirm(msg)) return; + await stripThinkingBlocks(loopId); + } + selectProvider(item.provName, item.source, item.modelId); + }, [currentName, currentModel, thinkingBlockCount, loopId, selectProvider]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIdx((i) => Math.min(i + 1, flatModels.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIdx((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (flatModels[selectedIdx]) onPick(flatModels[selectedIdx]); + } else if (e.key === "Escape") { + setOpen(false); + } + }; + + return ( + <> + <button + type="button" + onClick={() => setOpen(true)} + className="flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] text-gray-500 hover:bg-gray-100 transition-colors" + title="Select model (Ctrl+K)" + aria-label="Select model" + > + <Cpu className="h-3 w-3" /> + <span className="font-medium text-gray-700 truncate max-w-16 md:max-w-24">{currentName}</span> + {currentModel && ( + <> + <span className="hidden md:inline text-gray-400">/</span> + <span className="hidden md:inline font-mono truncate max-w-20">{currentModel}</span> + </> + )} + <ChevronDown className="h-2.5 w-2.5 text-gray-400" /> + </button> + + {open && ( + <> + {/* Backdrop */} + <div + className="fixed inset-0 z-50 bg-black/20" + onClick={() => setOpen(false)} + /> + + {/* Dialog */} + <div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]" onClick={() => setOpen(false)}> + <div + className="w-[480px] max-h-[60vh] bg-white rounded-xl shadow-2xl border border-gray-200 flex flex-col overflow-hidden" + onClick={(e) => e.stopPropagation()} + onKeyDown={handleKeyDown} + > + {/* Search header */} + <div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 shrink-0"> + <Search className="h-4 w-4 text-gray-400 shrink-0" /> + <input + ref={inputRef} + autoFocus + type="text" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search models..." + className="flex-1 text-sm outline-none text-gray-900 placeholder:text-gray-400" + /> + <kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] bg-gray-100 text-gray-400 font-mono"> + esc + </kbd> + </div> + + {/* Model list */} + <div ref={listRef} className="flex-1 overflow-y-auto py-2" role="listbox"> + {Array.from(groups.entries()).map(([provName, g]) => ( + <div key={provName} className="mb-1"> + {/* Provider header */} + <div className="flex items-center gap-1.5 px-4 py-1"> + <span className="text-[11px] font-medium text-gray-500">{provName}</span> + <span + className={`inline-flex items-center gap-0.5 rounded px-1 py-0 text-[8px] font-medium ${ + g.source === "personal" + ? "bg-violet-100 text-violet-600" + : "bg-gray-100 text-gray-400" + }`} + > + {g.source === "personal" ? <User className="h-2 w-2" /> : <Globe className="h-2 w-2" />} + {g.source} + </span> + </div> + + {g.models.map((m) => { + const isSelected = m.idx === selectedIdx; + const isActive = provName === currentName && m.modelId === currentModel; + return ( + <button + key={m.modelId} + type="button" + role="option" + aria-selected={isSelected} + onClick={() => onPick(flatModels[m.idx])} + onMouseEnter={() => setSelectedIdx(m.idx)} + className={`w-full px-6 py-1.5 text-left flex items-center gap-2 transition-colors ${ + isSelected ? "bg-blue-50" : "" + }`} + > + <span + className={`font-mono text-[12px] flex-1 truncate ${ + isActive + ? "text-blue-700 font-medium" + : isSelected + ? "text-gray-900" + : "text-gray-600" + }`} + > + {m.modelId} + </span> + {isActive && ( + <span className="h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" /> + )} + </button> + ); + })} + </div> + ))} + + {flatModels.length === 0 && ( + <div className="px-4 py-8 text-center text-sm text-gray-400"> + {search ? "No models match your search" : "No models available"} + </div> + )} + </div> + + {/* Footer with shortcuts */} + <div className="flex items-center gap-4 px-4 py-2 border-t border-gray-100 text-[10px] text-gray-400 shrink-0"> + <span className="flex items-center gap-1"> + <ArrowUp className="h-3 w-3" /> + <ArrowDown className="h-3 w-3" /> + navigate + </span> + <span className="flex items-center gap-1"> + <CornerDownLeft className="h-3 w-3" /> + select + </span> + <span className="flex items-center gap-1"> + <kbd className="px-1 py-0.5 rounded bg-gray-100 font-mono text-[9px]">esc</kbd> + close + </span> + </div> + </div> + </div> + </> + )} + </> + ); +} diff --git a/web/src/components/chat/PermissionPrompt.tsx b/web/src/components/chat/PermissionPrompt.tsx new file mode 100644 index 00000000..4fc28d58 --- /dev/null +++ b/web/src/components/chat/PermissionPrompt.tsx @@ -0,0 +1,69 @@ +import { ShieldIcon, AlertTriangleIcon } from "lucide-react" + +interface PermissionPromptProps { + toolName: string + title: string + displayName: string + onAllow: () => void + onDeny: () => void +} + +export default function PermissionPrompt({ + toolName, + title, + displayName, + onAllow, + onDeny, +}: PermissionPromptProps) { + return ( + <div className="rounded-lg border border-amber-200 bg-gradient-to-b from-amber-50/80 to-amber-50/30 p-3 space-y-2.5"> + {/* Header */} + <div className="flex items-start gap-2"> + <div className="flex items-center justify-center h-7 w-7 rounded-full bg-amber-100 shrink-0 mt-0.5"> + <ShieldIcon className="h-3.5 w-3.5 text-amber-600" /> + </div> + <div className="min-w-0"> + <p className="text-[12px] font-semibold text-amber-800 leading-snug"> + {title || "Permission required"} + </p> + <p className="text-[11px] text-amber-600 mt-0.5 leading-relaxed"> + <span className="font-medium">{toolName}</span> + {displayName && displayName !== toolName ? ( + <span> — {displayName}</span> + ) : null} + </p> + </div> + </div> + + {/* Warning hint */} + <div className="flex items-start gap-1.5 text-[10px] text-amber-500 bg-amber-100/50 rounded-md px-2.5 py-1.5"> + <AlertTriangleIcon className="h-3 w-3 shrink-0 mt-px" /> + <span>This tool can modify your system. Only allow if you trust the action.</span> + </div> + + {/* Buttons */} + <div className="flex items-center gap-2"> + <button + type="button" + onClick={onAllow} + className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3.5 py-1.5 text-[11px] font-semibold text-white hover:bg-emerald-700 active:bg-emerald-800 transition-colors shadow-sm" + > + <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> + <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> + </svg> + Allow + </button> + <button + type="button" + onClick={onDeny} + className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3.5 py-1.5 text-[11px] font-medium text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 active:bg-gray-100 transition-colors" + > + <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> + </svg> + Deny + </button> + </div> + </div> + ) +} diff --git a/web/src/components/chat/PlanModeToggle.tsx b/web/src/components/chat/PlanModeToggle.tsx new file mode 100644 index 00000000..f73eca26 --- /dev/null +++ b/web/src/components/chat/PlanModeToggle.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { + Shield, + PenLine, + Zap, + ClipboardList, + FastForward, + Sparkles, + X, +} from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger, PopoverClose } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +/** + * Mirrors the SDK PermissionMode union. Kept in sync with + * @anthropic-ai/claude-agent-sdk PermissionMode: + * 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'dontAsk' | 'auto' + */ +export type PermissionMode = + | "default" + | "acceptEdits" + | "bypassPermissions" + | "plan" + | "dontAsk" + | "auto"; + +const MODES: { + id: PermissionMode; + label: string; + description: string; + icon: React.ElementType; + color: string; + activeBg: string; + activeBorder: string; + activeHover: string; +}[] = [ + { + id: "bypassPermissions", + label: "YOLO", + description: "Sandboxed — safe to proceed freely", + icon: Zap, + color: "text-green-600", + activeBg: "bg-green-50", + activeBorder: "border-green-200", + activeHover: "hover:bg-green-100", + }, + { + id: "auto", + label: "Auto", + description: "Fully automatic, minimal prompts", + icon: Sparkles, + color: "text-amber-600", + activeBg: "bg-amber-50", + activeBorder: "border-amber-200", + activeHover: "hover:bg-amber-100", + }, + { + id: "dontAsk", + label: "Don't Ask", + description: "Skip most confirmations", + icon: FastForward, + color: "text-blue-600", + activeBg: "bg-blue-50", + activeBorder: "border-blue-200", + activeHover: "hover:bg-blue-100", + }, + { + id: "acceptEdits", + label: "Accept Edits", + description: "Auto-approve file edits, ask for rest", + icon: PenLine, + color: "text-yellow-600", + activeBg: "bg-yellow-50", + activeBorder: "border-yellow-200", + activeHover: "hover:bg-yellow-100", + }, + { + id: "plan", + label: "Plan", + description: "Plan first, then implement step by step", + icon: ClipboardList, + color: "text-purple-600", + activeBg: "bg-purple-50", + activeBorder: "border-purple-200", + activeHover: "hover:bg-purple-100", + }, + { + id: "default", + label: "Default", + description: "Ask before each edit (safest)", + icon: Shield, + color: "text-gray-600", + activeBg: "bg-gray-50", + activeBorder: "border-gray-200", + activeHover: "hover:bg-gray-100", + }, +]; + +interface PlanModeToggleProps { + mode: PermissionMode; + onChange: (mode: PermissionMode) => void; + className?: string; +} + +export default function PlanModeToggle({ + mode, + onChange, + className = "", +}: PlanModeToggleProps) { + const [open, setOpen] = useState(false); + const current = MODES.find((m) => m.id === mode) ?? MODES[0]; + const CurrentIcon = current.icon; + const isDefault = mode === "bypassPermissions"; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <button + type="button" + className={cn( + "flex h-8 items-center gap-1 rounded-lg border px-1.5 text-[10px] transition-all", + isDefault + ? "border-gray-200 bg-gray-50 text-gray-500 hover:bg-gray-100" + : `${current.activeBorder} ${current.activeBg} ${current.color} ${current.activeHover}`, + className, + )} + title={`Mode: ${current.label}`} + aria-label="Select permission mode" + > + <CurrentIcon className={cn("h-3 w-3", current.color)} /> + <span className="hidden font-medium sm:inline">{current.label}</span> + </button> + </PopoverTrigger> + + <PopoverContent className="w-56"> + <div className="flex items-center justify-between border-b border-gray-200 p-3"> + <h3 className="text-sm font-semibold text-gray-900">Permission Mode</h3> + <PopoverClose asChild> + <button + type="button" + className="rounded p-1 hover:bg-gray-100" + aria-label="Close" + > + <X className="h-4 w-4 text-gray-500" /> + </button> + </PopoverClose> + </div> + + <div className="py-1"> + {MODES.map((m) => { + const ModeIcon = m.icon; + const isSelected = m.id === mode; + return ( + <button + key={m.id} + type="button" + onClick={() => { + onChange(m.id); + setOpen(false); + }} + className={cn( + "w-full px-4 py-2.5 text-left transition-colors hover:bg-gray-50", + isSelected && "bg-gray-50", + )} + > + <div className="flex items-center gap-2.5"> + <ModeIcon className={cn("h-4 w-4", m.color)} /> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-1.5"> + <span + className={cn( + "text-sm font-medium", + isSelected ? "text-gray-900" : "text-gray-700", + )} + > + {m.label} + </span> + {isSelected && ( + <span className="rounded bg-blue-100 px-1.5 py-0.5 text-[9px] text-blue-700"> + Active + </span> + )} + </div> + <p className="text-xs text-gray-500">{m.description}</p> + </div> + </div> + </button> + ); + })} + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/web/src/components/chat/PluginsButton.tsx b/web/src/components/chat/PluginsButton.tsx new file mode 100644 index 00000000..d804c619 --- /dev/null +++ b/web/src/components/chat/PluginsButton.tsx @@ -0,0 +1,208 @@ +/** + * Loop "plugins" chip — sits in the composer toolbar next to ModelSelector. + * Shows the count of plugin sub-commands available in the current loop and + * opens a centered dialog with the full list (grouped by plugin). Clicking + * a skill row inserts its `/plugin:skill` invocation into the composer. + * + * Data source: useLoopRuntime's availableSlashCommands. Plugin sub-commands + * are the entries whose name contains ":" (per CC's plugin namespace + * convention). The seed list (server/src/session.ts:buildInitialSlashCommands) + * pre-populates these from each enabled plugin's host install dir, so the + * chip is correct on first open — no need to wait for CC's init payload. + */ +import { useMemo, useState, useEffect } from "react" +import { Search, CornerDownLeft, ArrowUp, ArrowDown } from "lucide-react" +import { useLoopRuntimeExtra } from "@/useLoopRuntime" + +type PluginGroup = { + plugin: string + skills: { name: string; description: string }[] +} + +export default function PluginsButton({ onPick }: { onPick: (slashCommand: string) => void }) { + const { availableSlashCommands } = useLoopRuntimeExtra() + const [open, setOpen] = useState(false) + const [search, setSearch] = useState("") + const [selectedIdx, setSelectedIdx] = useState(0) + + // Group plugin sub-commands by plugin name. `availableSlashCommands` may be + // updated mid-render (CC init), so derive fresh every time. + const groups = useMemo<PluginGroup[]>(() => { + const byPlugin = new Map<string, PluginGroup>() + for (const cmd of availableSlashCommands) { + const colonAt = cmd.name.indexOf(":") + if (colonAt < 0) continue + const plugin = cmd.name.slice(0, colonAt) + const skill = cmd.name.slice(colonAt + 1) + const g = byPlugin.get(plugin) ?? { plugin, skills: [] } + g.skills.push({ name: skill, description: cmd.description }) + byPlugin.set(plugin, g) + } + return [...byPlugin.values()].sort((a, b) => a.plugin.localeCompare(b.plugin)) + }, [availableSlashCommands]) + + const total = useMemo(() => groups.reduce((n, g) => n + g.skills.length, 0), [groups]) + + // Flat filtered list for keyboard navigation. + const filtered = useMemo(() => { + const q = search.toLowerCase().trim() + const out: { plugin: string; skill: string; description: string }[] = [] + for (const g of groups) { + for (const s of g.skills) { + if (q && !`${g.plugin}:${s.name}`.toLowerCase().includes(q) && !s.description.toLowerCase().includes(q)) continue + out.push({ plugin: g.plugin, skill: s.name, description: s.description }) + } + } + return out + }, [groups, search]) + + useEffect(() => { + setSelectedIdx(0) + }, [search, open]) + + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false) + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [open]) + + // Don't render the chip if there are no plugins. Avoid noise in freeform loops. + if (total === 0) return null + + const pick = (plugin: string, skill: string) => { + setOpen(false) + onPick(`/${plugin}:${skill} `) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault() + setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1)) + } else if (e.key === "ArrowUp") { + e.preventDefault() + setSelectedIdx((i) => Math.max(i - 1, 0)) + } else if (e.key === "Enter") { + e.preventDefault() + const item = filtered[selectedIdx] + if (item) pick(item.plugin, item.skill) + } + } + + return ( + <> + <button + type="button" + onClick={() => setOpen(true)} + className="flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-[10px] text-gray-500 hover:bg-gray-100 transition-colors" + title={`${total} plugin skill${total === 1 ? "" : "s"} available`} + aria-label="Show plugins" + > + <span className="text-sm leading-none" aria-hidden="true">🧩</span> + <span className="font-medium text-gray-700">{total}</span> + </button> + + {open && ( + <> + <div className="fixed inset-0 z-50 bg-black/20" onClick={() => setOpen(false)} /> + <div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]" onClick={() => setOpen(false)}> + <div + className="w-[560px] max-h-[60vh] bg-white rounded-xl shadow-2xl border border-gray-200 flex flex-col overflow-hidden" + onClick={(e) => e.stopPropagation()} + onKeyDown={handleKeyDown} + > + {/* Search header */} + <div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 shrink-0"> + <Search className="h-4 w-4 text-gray-400 shrink-0" /> + <input + autoFocus + type="text" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search plugins..." + className="flex-1 text-sm outline-none text-gray-900 placeholder:text-gray-400" + /> + <span className="text-[10px] text-gray-400"> + {filtered.length} / {total} + </span> + <kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] bg-gray-100 text-gray-400 font-mono"> + esc + </kbd> + </div> + + {/* List */} + <div className="flex-1 overflow-y-auto py-2" role="listbox"> + {(() => { + // Re-group filtered by plugin for display, preserving the flat + // index used by selectedIdx (so keyboard nav and visual selection align). + const display = new Map<string, { skill: string; description: string; idx: number }[]>() + filtered.forEach((item, idx) => { + const arr = display.get(item.plugin) ?? [] + arr.push({ skill: item.skill, description: item.description, idx }) + display.set(item.plugin, arr) + }) + return Array.from(display.entries()).map(([plugin, skills]) => ( + <div key={plugin} className="mb-1"> + <div className="flex items-center gap-1.5 px-4 py-1"> + <span className="text-xs leading-none" aria-hidden="true">🧩</span> + <span className="text-[11px] font-medium text-gray-500">{plugin}</span> + </div> + {skills.map(({ skill, description, idx }) => { + const isSelected = idx === selectedIdx + return ( + <button + key={skill} + type="button" + role="option" + aria-selected={isSelected} + onClick={() => pick(plugin, skill)} + onMouseEnter={() => setSelectedIdx(idx)} + className={`w-full px-6 py-1.5 text-left flex flex-col gap-0.5 transition-colors ${ + isSelected ? "bg-blue-50" : "" + }`} + > + <span className="font-mono text-[12px] text-gray-900"> + /{plugin}:{skill} + </span> + {description && ( + <span className="text-[11px] text-gray-500 line-clamp-2">{description}</span> + )} + </button> + ) + })} + </div> + )) + })()} + + {filtered.length === 0 && ( + <div className="px-4 py-8 text-center text-sm text-gray-400"> + {search ? "No skills match your search" : "No plugins available"} + </div> + )} + </div> + + {/* Footer */} + <div className="flex items-center gap-4 px-4 py-2 border-t border-gray-100 text-[10px] text-gray-400 shrink-0"> + <span className="flex items-center gap-1"> + <ArrowUp className="h-3 w-3" /> + <ArrowDown className="h-3 w-3" /> + navigate + </span> + <span className="flex items-center gap-1"> + <CornerDownLeft className="h-3 w-3" /> + insert + </span> + <span className="flex items-center gap-1"> + <kbd className="px-1 py-0.5 rounded bg-gray-100 font-mono text-[9px]">esc</kbd> + close + </span> + </div> + </div> + </div> + </> + )} + </> + ) +} diff --git a/web/src/components/chat/SlashCommand.tsx b/web/src/components/chat/SlashCommand.tsx new file mode 100644 index 00000000..00456768 --- /dev/null +++ b/web/src/components/chat/SlashCommand.tsx @@ -0,0 +1,397 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { useAuiState } from "@assistant-ui/react"; +import { useComposerRuntime } from "@assistant-ui/react"; +import { Brain, Zap, Sparkles, Route, Eraser, BarChart3, Terminal, Puzzle, Network, Target } from "lucide-react"; +import { useLoopRuntimeExtra } from "@/useLoopRuntime"; +import { McpStatusPanel } from "../McpStatusPanel"; +import type { PermissionMode } from "./PlanModeToggle"; + +/** + * One group of commands in the dropdown. Rendered with a small header. + * Groups are stable; commands inside a group are filtered by query. + */ +type GroupId = "quick" | "plugin" | "skill"; + +interface SlashCommand { + id: string; + name: string; + description: string; + icon: React.ElementType; + /** Which visual section this row belongs to. */ + group: GroupId; + /** + * - "command" / "toggle": runs locally (clears composer text) + * - "agent": inserted into composer as `/<id> ` for the agent to receive + */ + action: "insert" | "toggle" | "command" | "agent"; + prefix: string; + toggleKey?: "permissionMode"; + /** Required when action === "command"; identifies which runtime action to fire. */ + commandKey?: "clearContext" | "setMaxThinkingTokens" | "getContextUsage" | "openMcpPanel"; + /** Budget in tokens for setMaxThinkingTokens (null = unlimited). */ + tokens?: number | null; + /** Show ON badge when true. */ + isActive?: boolean; + /** For plugin commands: the plugin name (text before `:`). Used to sort + * rows of the same plugin together. */ + pluginName?: string; +} + +const COMMANDS: SlashCommand[] = [ + { + id: "think", + name: "Think", + description: "Extended thinking (16k token budget)", + icon: Brain, + group: "quick", + action: "command", + prefix: "", + commandKey: "setMaxThinkingTokens", + tokens: 16000, + }, + { + id: "think-hard", + name: "Think Hard", + description: "Deep thinking (32k token budget)", + icon: Zap, + group: "quick", + action: "command", + prefix: "", + commandKey: "setMaxThinkingTokens", + tokens: 32000, + }, + { + id: "ultrathink", + name: "Ultrathink", + description: "Maximum thinking (no budget limit)", + icon: Sparkles, + group: "quick", + action: "command", + prefix: "", + commandKey: "setMaxThinkingTokens", + tokens: null, + }, + { + id: "plan", + name: "Permission", + description: "Cycle permission mode", + icon: Route, + group: "quick", + action: "toggle", + prefix: "", + toggleKey: "permissionMode", + }, + { + id: "goal", + name: "Goal", + description: "Set goal (/goal <text>), mark done (/goal done), clear (/goal clear)", + icon: Target, + group: "quick", + action: "agent", + prefix: "", + }, + { + id: "usage", + name: "Context Usage", + description: "Show context window token usage", + icon: BarChart3, + group: "quick", + action: "command", + prefix: "", + commandKey: "getContextUsage", + }, + { + id: "clear", + name: "Clear Context", + description: "Reset AI conversation (history kept)", + icon: Eraser, + group: "quick", + action: "command", + prefix: "", + commandKey: "clearContext", + }, + { + id: "mcp", + name: "MCP Servers", + description: "Show MCP servers + tools available to this loop", + icon: Network, + group: "quick", + action: "command", + prefix: "", + commandKey: "openMcpPanel", + }, +]; + +/** Header text + sort order for each group. Groups missing rows are hidden. */ +const GROUPS: { id: GroupId; label: string }[] = [ + { id: "quick", label: "Quick actions" }, + { id: "plugin", label: "Plugin commands" }, + { id: "skill", label: "Skills" }, +]; + +/** Local-only command ids — when filtering against CC's reported list, these + * must never get hidden as duplicates of CC's "clear" etc. */ +const LOCAL_IDS = new Set(COMMANDS.map((c) => c.id)); + +export default function SlashCommand() { + const text = useAuiState((s) => s.composer.text); + const composerRuntime = useComposerRuntime(); + const { + permissionMode, + setPermissionMode, + clearContext, + setMaxThinkingTokens, + getContextUsage, + availableSlashCommands, + suppressSlashRef, + loopId, + } = useLoopRuntimeExtra(); + const [open, setOpen] = useState(false); + const [selectedIdx, setSelectedIdx] = useState(0); + const [filter, setFilter] = useState(""); + // /mcp opens a floating panel anchored above the composer. Rendered + // alongside the slash-command dropdown but mutually independent. + const [mcpOpen, setMcpOpen] = useState(false); + const listRef = useRef<HTMLDivElement>(null); + + // Merge: built-in loopat quick-actions + commands reported by CC at init. + // Group routing: + // - "<plugin>:<skill>" → group:"plugin" (loopat, jira-pack, …) + // - bare id, not local → group:"skill" (CC built-ins + workspace/personal loose skills) + // - local id → already group:"quick" from COMMANDS + const allCommands = useMemo<SlashCommand[]>(() => { + const fromAgent: SlashCommand[] = availableSlashCommands + .filter((cmd) => !LOCAL_IDS.has(cmd.name)) + .map((cmd) => { + const isPlugin = cmd.name.includes(":"); + const pluginName = isPlugin ? cmd.name.split(":", 1)[0] : undefined; + return { + id: cmd.name, + name: cmd.name, + description: cmd.description || (isPlugin ? `from ${pluginName} plugin` : "skill"), + icon: isPlugin ? Puzzle : Terminal, + group: isPlugin ? ("plugin" as const) : ("skill" as const), + action: "agent" as const, + prefix: "", + pluginName, + }; + }); + return [...COMMANDS, ...fromAgent]; + }, [availableSlashCommands]); + + const textTrimmed = typeof text === "string" ? text.trimStart() : text; + let showDropdown = + typeof textTrimmed === "string" && + textTrimmed.startsWith("/") && + !textTrimmed.includes(" "); + // Suppress dropdown when text was set by history browsing (ArrowUp/Down) + // so the menu doesn't interrupt the history-browsing experience. + // The flag is reset by Composer on the next user keystroke. + if (showDropdown && suppressSlashRef.current) { + showDropdown = false; + } + + const query = showDropdown ? textTrimmed.slice(1).toLowerCase() : ""; + + // Apply filter, then bucket by group. Flat list still exists for keyboard nav. + const filtered = useMemo( + () => + allCommands.filter( + (c) => + !query || + c.id.toLowerCase().includes(query) || + c.name.toLowerCase().includes(query), + ), + [allCommands, query], + ); + + /** Order rows for keyboard nav: by group order, then plugin grouping, then alpha. */ + const flatOrdered = useMemo(() => { + const groupOrder: Record<GroupId, number> = { quick: 0, plugin: 1, skill: 2 }; + return [...filtered].sort((a, b) => { + if (a.group !== b.group) return groupOrder[a.group] - groupOrder[b.group]; + if (a.group === "plugin") { + if (a.pluginName !== b.pluginName) + return (a.pluginName ?? "").localeCompare(b.pluginName ?? ""); + } + return a.id.localeCompare(b.id); + }); + }, [filtered]); + + /** Group → ordered list of rows in that group. Empty groups are dropped at render time. */ + const grouped = useMemo(() => { + const m = new Map<GroupId, SlashCommand[]>(); + for (const c of flatOrdered) { + const arr = m.get(c.group) ?? []; + arr.push(c); + m.set(c.group, arr); + } + return m; + }, [flatOrdered]); + + useEffect(() => { + if (showDropdown) { + setOpen(true); + setFilter(query); + setSelectedIdx(0); + } else { + setOpen(false); + } + }, [showDropdown, query]); + + const executeCommand = useCallback( + (cmd: SlashCommand) => { + if (cmd.action === "toggle" && cmd.toggleKey === "permissionMode") { + const modes: PermissionMode[] = ["bypassPermissions", "auto", "dontAsk", "acceptEdits", "plan", "default"]; + const idx = modes.indexOf(permissionMode); + const next = modes[(idx + 1) % modes.length]; + setPermissionMode(next); + composerRuntime.setText(""); + } else if (cmd.action === "command") { + if (cmd.commandKey === "clearContext") { + clearContext(); + composerRuntime.setText(""); + } else if (cmd.commandKey === "setMaxThinkingTokens") { + setMaxThinkingTokens(cmd.tokens ?? null); + composerRuntime.setText(""); + } else if (cmd.commandKey === "getContextUsage") { + getContextUsage(); + composerRuntime.setText(""); + } else if (cmd.commandKey === "openMcpPanel") { + setMcpOpen(true); + composerRuntime.setText(""); + } + } else if (cmd.action === "agent") { + // CC-side command: fill the composer with `/<id> ` so the user can + // submit (or keep typing args). Don't auto-send — same UX as CC. + composerRuntime.setText(`/${cmd.id} `); + } + setOpen(false); + }, + [composerRuntime, permissionMode, setPermissionMode, clearContext, setMaxThinkingTokens, getContextUsage], + ); + + // Keyboard navigation — uses capture phase to intercept before composer. + // Index is into `flatOrdered` so it matches the visual order (with group + // sections respected); group headers themselves aren't selectable. + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + e.stopImmediatePropagation(); + setSelectedIdx((prev) => Math.min(prev + 1, flatOrdered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + e.stopImmediatePropagation(); + setSelectedIdx((prev) => Math.max(prev - 1, 0)); + } else if (e.key === "Enter" && flatOrdered.length > 0) { + e.preventDefault(); + e.stopImmediatePropagation(); + executeCommand(flatOrdered[selectedIdx]); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopImmediatePropagation(); + composerRuntime.setText(""); + } + }; + window.addEventListener("keydown", onKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); + }, [open, flatOrdered, selectedIdx, composerRuntime, executeCommand]); + + // Scroll selected into view — use data attribute because listRef.children + // includes group header divs, so the index doesn't match flatOrdered. + useEffect(() => { + if (!listRef.current) return; + const sel = listRef.current.querySelector(`[data-slash-idx="${selectedIdx}"]`) as HTMLElement | undefined; + if (sel) sel.scrollIntoView({ block: "nearest" }); + }, [selectedIdx]); + + // Build a flat index → so each row knows its position in flatOrdered for + // selection highlighting + onClick. + let runningIdx = -1; + + // If only the MCP panel is open (no dropdown), render just it. + if (mcpOpen && (!open || flatOrdered.length === 0)) { + return ( + <div className="relative"> + <div className="absolute bottom-0 left-0 mb-1 w-[28rem] rounded-lg border border-gray-200 bg-white shadow-lg z-20"> + <McpStatusPanel onClose={() => setMcpOpen(false)} loopId={loopId} /> + </div> + </div> + ); + } + + if (!open || flatOrdered.length === 0) return null; + + return ( + <div className="relative"> + {mcpOpen && ( + <div className="absolute bottom-0 left-0 mb-1 w-[28rem] rounded-lg border border-gray-200 bg-white shadow-lg z-30"> + <McpStatusPanel onClose={() => setMcpOpen(false)} loopId={loopId} /> + </div> + )} + <div className="absolute bottom-0 left-0 mb-1 w-72 rounded-lg border border-gray-200 bg-white shadow-lg z-20"> + <div ref={listRef} className="max-h-72 overflow-y-auto py-1"> + {GROUPS.map((g) => { + const rows = grouped.get(g.id); + if (!rows || rows.length === 0) return null; + return ( + <div key={g.id}> + <div className="px-3 pt-2 pb-1"> + <p className="text-[10px] font-medium text-gray-400 uppercase tracking-wider"> + {g.label} + </p> + </div> + {rows.map((cmd) => { + runningIdx += 1; + const myIdx = runningIdx; + const Icon = cmd.icon; + const isSelected = myIdx === selectedIdx; + const isActive = + (cmd.toggleKey === "permissionMode" && + permissionMode !== "bypassPermissions") || + cmd.isActive; + return ( + <button + key={cmd.id} + type="button" + data-slash-idx={myIdx} + onMouseEnter={() => setSelectedIdx(myIdx)} + onMouseDown={(e) => { + e.preventDefault(); // prevent blur on textarea + executeCommand(cmd); + }} + className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left transition-colors ${ + isSelected ? "bg-blue-50" : "hover:bg-gray-50" + }`} + > + <Icon + className={`h-4 w-4 flex-shrink-0 ${ + isActive ? "text-blue-600" : "text-gray-400" + }`} + /> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-1.5"> + <span className="text-sm font-medium text-gray-700"> + /{cmd.id} + </span> + {cmd.action === "toggle" && isActive && ( + <span className="rounded bg-blue-100 px-1 py-0.5 text-[9px] text-blue-600"> + ON + </span> + )} + </div> + <p className="text-xs text-gray-500 truncate">{cmd.description}</p> + </div> + </button> + ); + })} + </div> + ); + })} + </div> + </div> + </div> + ); +} diff --git a/web/src/components/chat/TodoRenderer.tsx b/web/src/components/chat/TodoRenderer.tsx new file mode 100644 index 00000000..ff931f30 --- /dev/null +++ b/web/src/components/chat/TodoRenderer.tsx @@ -0,0 +1,62 @@ +import { CheckIcon, LoaderIcon, CircleIcon } from "lucide-react" +import { cn } from "@/lib/utils" + +export interface TodoItem { + content: string + status: string + activeForm: string +} + +function TodoStatusIcon({ status }: { status: string }) { + switch (status) { + case "in_progress": + return <LoaderIcon className="h-3.5 w-3.5 animate-spin text-sky-500 shrink-0" /> + case "completed": + return <CheckIcon className="h-3.5 w-3.5 text-emerald-500 shrink-0" /> + default: + return <CircleIcon className="h-3.5 w-3.5 text-gray-300 shrink-0" /> + } +} + +export default function TodoRenderer({ todos }: { todos: TodoItem[] }) { + if (!Array.isArray(todos) || todos.length === 0) return null + + const doneCount = todos.filter((t) => t?.status === "completed").length + + return ( + <div className="space-y-1.5"> + <div className="flex items-center gap-2 text-[11px] text-gray-400 mb-1"> + <span> + {doneCount}/{todos.length} done + </span> + </div> + <ul className="space-y-0.5"> + {todos.filter((t) => t && typeof t === "object" && typeof t.status === "string").map((todo, i) => { + const isCompleted = todo.status === "completed" + const isRunning = todo.status === "in_progress" + return ( + <li + key={i} + className={cn( + "flex items-start gap-2 py-0.5 rounded transition-colors", + isRunning && "bg-sky-50/50 -mx-1 px-1", + )} + > + <TodoStatusIcon status={todo.status} /> + <span + className={cn( + "text-[12px] transition-all", + isCompleted && "line-through text-gray-400", + isRunning && "text-sky-700 font-medium", + !isCompleted && !isRunning && "text-gray-600", + )} + > + {todo.content} + </span> + </li> + ) + })} + </ul> + </div> + ) +} diff --git a/web/src/components/chat/TokenUsagePie.tsx b/web/src/components/chat/TokenUsagePie.tsx new file mode 100644 index 00000000..256f580c --- /dev/null +++ b/web/src/components/chat/TokenUsagePie.tsx @@ -0,0 +1,65 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface TokenUsagePieProps { + used: number; + total: number; +} + +export default function TokenUsagePie({ used, total }: TokenUsagePieProps) { + if (!total || total <= 0) return null; + + const percentage = Math.min(100, (used / total) * 100); + const radius = 10; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (percentage / 100) * circumference; + + const getColor = () => { + if (percentage < 50) return "var(--color-blue-500)"; + if (percentage < 75) return "var(--color-amber-500)"; + return "var(--color-red-500)"; + }; + + return ( + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-1.5 text-xs text-gray-500"> + <svg + width="24" + height="24" + viewBox="0 0 24 24" + className="-rotate-90 transform" + > + <circle + cx="12" + cy="12" + r={radius} + fill="none" + stroke="currentColor" + strokeWidth="2" + className="text-gray-300" + /> + <circle + cx="12" + cy="12" + r={radius} + fill="none" + stroke={getColor()} + strokeWidth="2" + strokeDasharray={circumference} + strokeDashoffset={offset} + strokeLinecap="round" + /> + </svg> + <span>{percentage.toFixed(0)}%</span> + </div> + </TooltipTrigger> + <TooltipContent> + <div className="text-xs"> + <p> + {used.toLocaleString()} / {total.toLocaleString()} tokens + </p> + </div> + </TooltipContent> + </Tooltip> + ); +} diff --git a/web/src/components/chat/ToolRenderer.tsx b/web/src/components/chat/ToolRenderer.tsx new file mode 100644 index 00000000..32a179f4 --- /dev/null +++ b/web/src/components/chat/ToolRenderer.tsx @@ -0,0 +1,834 @@ +import { useState, useEffect, useMemo } from "react"; +import { + CheckIcon, + ChevronDownIcon, + LoaderIcon, + XCircleIcon, + PencilIcon, + TerminalIcon, + SearchIcon, + FileTextIcon, + GlobeIcon, + WrenchIcon, + ExternalLink, +} from "lucide-react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import hljs from "highlight.js"; +import "highlight.js/styles/github.css"; +import { useIsMobile } from "@/lib/useIsMobile"; +import TodoRenderer from "./TodoRenderer"; +import AgentRenderer from "./AgentRenderer"; +import PermissionPrompt from "./PermissionPrompt"; +import { useLoopRuntimeExtra } from "@/useLoopRuntime"; +import type { TaskState } from "@/useLoopRuntime"; + +type ToolStatus = "running" | "complete" | "incomplete" | "requires-action"; + +interface ToolRendererProps { + toolName: string; + args: Record<string, unknown>; + result?: string; + status?: ToolStatus; + elapsedSeconds?: number; + taskState?: TaskState; + toolCallId?: string; +} + +/* ─── Elapsed timer helpers ─── */ + +function formatElapsed(totalSeconds: number): string { + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`; +} + +function useElapsedTimer(isRunning: boolean, sdkSeconds?: number) { + const [local, setLocal] = useState(0); + + useEffect(() => { + if (!isRunning) { + setLocal(0); + return; + } + const start = Date.now(); + const timer = setInterval(() => { + setLocal(Math.floor((Date.now() - start) / 1000)); + }, 1000); + return () => clearInterval(timer); + }, [isRunning]); + + if (sdkSeconds !== undefined && sdkSeconds > 0) return sdkSeconds; + return local; +} + +/* ─── Status badge config ─── */ + +const STATUS_CONFIG: Record<ToolStatus, { label: string; className: string }> = { + running: { label: "Running", className: "bg-sky-100 text-sky-700" }, + complete: { label: "Done", className: "bg-emerald-100 text-emerald-700" }, + incomplete: { label: "Error", className: "bg-red-100 text-red-700" }, + "requires-action": { label: "Action needed", className: "bg-amber-100 text-amber-700" }, +}; + +/* ─── Tool icon & category ─── */ + +interface ToolMeta { + category: string; + icon: React.ElementType; + borderClass: string; +} + +function getToolMeta(toolName: string): ToolMeta { + const name = toolName || ""; + if (["Edit", "Write", "ApplyPatch"].includes(name)) { + return { category: "edit", icon: PencilIcon, borderClass: "border-l-amber-400" }; + } + if (name === "Bash") { + return { category: "bash", icon: TerminalIcon, borderClass: "border-l-gray-400" }; + } + if (["Grep", "Glob"].includes(name)) { + return { category: "search", icon: SearchIcon, borderClass: "border-l-blue-400" }; + } + if (name === "Read") { + return { category: "read", icon: FileTextIcon, borderClass: "border-l-emerald-400" }; + } + if (["WebSearch", "WebFetch"].includes(name)) { + return { category: "web", icon: GlobeIcon, borderClass: "border-l-purple-400" }; + } + if (name === "TodoWrite") { + return { category: "todo", icon: CheckIcon, borderClass: "border-l-violet-400" }; + } + if (["Agent", "Task"].includes(name)) { + return { category: "agent", icon: PencilIcon, borderClass: "border-l-purple-400" }; + } + return { category: "default", icon: WrenchIcon, borderClass: "border-l-gray-300" }; +} + +/* ─── Path shortening ─── */ + +function shortPath(p: string): string { + // Strip absolute prefix down to the meaningful relative part + const idx = p.indexOf("workdir/") + if (idx !== -1) return p.slice(idx) + const ctxIdx = p.indexOf("context/") + if (ctxIdx !== -1) return p.slice(ctxIdx) + // If it's an absolute /loopat/loops/<id>/... path, strip to last two segments + const loopMatch = p.match(/\/loops\/[^/]+\/(.+)/) + if (loopMatch) return loopMatch[1] + return p +} + +/* ─── Extract summary from args ─── */ + +function getSummary(toolName: string, args: Record<string, unknown>): string { + const filePath = shortPath((args.file_path as string) || (args.filePath as string) || ""); + switch (toolName) { + case "Bash": + return (args.command as string) || (args.description as string) || ""; + case "Edit": { + const base = filePath || ""; + const oldLines = ((args.old_string as string) ?? "").split("\n").length; + const newLines = ((args.new_string as string) ?? "").split("\n").length; + const delta = newLines - oldLines; + const deltaStr = delta > 0 ? `+${delta}` : delta < 0 ? `${delta}` : `±${oldLines}`; + return base ? `${base} (${deltaStr} lines)` : ""; + } + case "Write": { + const content = (args.content as string) ?? ""; + return filePath ? `${filePath} (${content.length.toLocaleString()} chars)` : ""; + } + case "ApplyPatch": + return filePath || ""; + case "Grep": + case "Glob": + return (args.pattern as string) || ""; + case "Read": + return filePath; + case "WebSearch": + case "WebFetch": + return (args.query as string) || (args.url as string) || ""; + case "TodoWrite": + return (args.description as string) || ""; + case "Agent": + case "Task": + return (args.description as string) || (args.subagent_type as string) || ""; + default: + return ""; + } +} + +/* ─── Diff renderer with line numbers ─── */ + +interface DiffHunk { + header: string + lines: DiffLine[] +} + +interface DiffLine { + type: "add" | "del" | "ctx" + oldNum: number | null + newNum: number | null + text: string +} + +function parseDiff(raw: string): { filePath: string; hunks: DiffHunk[] } | null { + const lines = raw.split("\n") + let filePath = "" + const hunks: DiffHunk[] = [] + let currentHunk: DiffHunk | null = null + let oldNum = 0 + let newNum = 0 + let hasDiffMarkers = false + + for (const line of lines) { + if (/^--- /.test(line)) { + filePath = line.replace(/^--- /, "").replace(/\t.*$/, "") + continue + } + if (/^\+\+\+ /.test(line)) { + const to = line.replace(/^\+\+\+ /, "").replace(/\t.*$/, "") + if (!filePath || filePath === "/dev/null") filePath = to + if (to !== "/dev/null" && filePath !== to) filePath = `${filePath} → ${to}` + continue + } + + const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/) + if (hunkMatch) { + hasDiffMarkers = true + if (currentHunk) hunks.push(currentHunk) + oldNum = parseInt(hunkMatch[1], 10) + newNum = parseInt(hunkMatch[3], 10) + currentHunk = { header: line, lines: [] } + continue + } + + if (!currentHunk) { + if (/^[+-]/.test(line)) hasDiffMarkers = true + continue + } + + if (line.startsWith("+")) { + currentHunk.lines.push({ type: "add", oldNum: null, newNum: newNum, text: line.slice(1) }) + newNum++ + hasDiffMarkers = true + } else if (line.startsWith("-")) { + currentHunk.lines.push({ type: "del", oldNum: oldNum, newNum: null, text: line.slice(1) }) + oldNum++ + hasDiffMarkers = true + } else if (line.startsWith(" ")) { + currentHunk.lines.push({ type: "ctx", oldNum: oldNum, newNum: newNum, text: line.slice(1) }) + oldNum++ + newNum++ + } else { + currentHunk.lines.push({ type: "ctx", oldNum: oldNum, newNum: newNum, text: line }) + oldNum++ + newNum++ + } + } + if (currentHunk) hunks.push(currentHunk) + + return hasDiffMarkers ? { filePath, hunks } : null +} + +function highlightLine(text: string, lang?: string): string | null { + if (!lang) return null + try { + const r = hljs.highlight(text, { language: lang, ignoreIllegals: true }) + return r.value + } catch { return null } +} + +function useHighlightedLines(lines: string[], lang?: string): (string | null)[] { + return useMemo(() => lines.map(l => highlightLine(l, lang)), [lines, lang]) +} + +function DiffView({ text, maxLines, lang }: { text: string; maxLines?: number; lang?: string }) { + const parsed = parseDiff(text) + if (!parsed) return null + + let totalLines = 0 + let truncated = false + const displayHunks = parsed.hunks.map((hunk) => { + if (maxLines !== undefined && totalLines >= maxLines) { + truncated = true + return { ...hunk, lines: [] as DiffLine[] } + } + const remaining = maxLines !== undefined ? maxLines - totalLines : Infinity + const lines = hunk.lines.slice(0, remaining) + totalLines += lines.length + if (maxLines !== undefined && lines.length < hunk.lines.length) truncated = true + return { ...hunk, lines } + }) + + const allLines = displayHunks.flatMap(h => h.lines.map(l => l.text)) + const highlighted = useHighlightedLines(allLines, lang) + let lineIdx = 0 + + const total = parsed.hunks.reduce((s, h) => s + h.lines.length, 0) + + return ( + <div className="overflow-hidden rounded-md font-mono text-xs leading-5" style={{ border: `1px solid var(--cm-code-border)`, backgroundColor: "var(--cm-code-bg)", color: "var(--cm-text)" }}> + {parsed.filePath && ( + <div className="flex items-center gap-2 px-3 py-1.5 text-[10px] truncate" style={{ borderBottom: `1px solid var(--cm-border)`, color: "var(--cm-gutter)" }}> + <FileTextIcon className="h-3 w-3 shrink-0" style={{ color: "var(--cm-gutter)" }} /> + <span>{shortPath(parsed.filePath)}</span> + </div> + )} + {displayHunks.map((hunk, hi) => ( + <div key={hi}> + <div className="px-3 py-0.5 font-medium text-[10px]" style={{ backgroundColor: "var(--cm-diff-hdr-bg)", color: "var(--cm-diff-hdr-text)", borderBottom: `1px solid var(--cm-border)` }}> + {hunk.header} + </div> + {hunk.lines.map((ln, li) => { + const isAdd = ln.type === "add" + const isDel = ln.type === "del" + const lineNum = ln.newNum ?? ln.oldNum ?? "" + const hl = highlighted[lineIdx++] + return ( + <div + key={li} + className="flex" + style={{ backgroundColor: isAdd ? "var(--cm-diff-add-bg)" : isDel ? "var(--cm-diff-del-bg)" : "transparent" }} + > + <span className="w-10 shrink-0 text-right pr-2 py-px text-[9px] select-none border-r" style={{ + color: isAdd ? "var(--cm-diff-add-text)" : isDel ? "var(--cm-diff-del-text)" : "var(--cm-gutter)", + borderColor: "var(--cm-border)", + }}> + {lineNum} + </span> + <span className={`pl-2 py-px whitespace-pre-wrap break-all flex-1 ${isAdd ? "diff-line-add" : isDel ? "diff-line-del" : "diff-line-ctx"}`}> + <span className="select-none text-[9px] mr-1 opacity-50"> + {isAdd ? "+" : isDel ? "−" : " "} + </span> + {hl ? ( + <span dangerouslySetInnerHTML={{ __html: hl }} /> + ) : ( + ln.text + )} + </span> + </div> + ) + })} + </div> + ))} + {truncated && ( + <div className="px-3 py-1.5 text-[10px] italic" style={{ borderTop: `1px solid var(--cm-border)`, color: "var(--cm-gutter)" }}> + ... {total - (maxLines ?? total)} more lines + </div> + )} + </div> + ) +} + +/** Compact diff preview for collapsed state — line-number-free for space */ +function DiffPreview({ text, maxLines = 8, lang }: { text: string; maxLines?: number; lang?: string }) { + const parsed = parseDiff(text) + if (!parsed) return null + + const flat: { type: "hdr" | "ctx" | "add" | "del"; text: string }[] = [] + for (const h of parsed.hunks) { + flat.push({ type: "hdr", text: h.header }) + for (const l of h.lines) flat.push({ type: l.type, text: l.text }) + } + + const lines = flat.slice(0, maxLines) + const truncated = flat.length > maxLines + const codeLines = lines.filter(l => l.type !== "hdr") + const highlighted = useHighlightedLines(codeLines.map(l => l.text), lang) + let hlIdx = 0 + + return ( + <div className="overflow-hidden rounded font-mono text-[10px] leading-relaxed opacity-80" style={{ border: `1px solid var(--cm-code-border)`, backgroundColor: "var(--cm-code-bg)" }}> + {parsed.filePath && ( + <div className="px-2 py-0.5 truncate" style={{ borderBottom: `1px solid var(--cm-border)`, color: "var(--cm-gutter)" }}> + {shortPath(parsed.filePath)} + </div> + )} + {lines.map((line, i) => { + const isCode = line.type !== "hdr" + const hl = isCode ? highlighted[hlIdx++] : null + const prefix = line.type === "add" ? "+" : line.type === "del" ? "−" : " " + return ( + <div + key={i} + className={`px-2 py-px whitespace-pre-wrap break-all ${line.type === "add" ? "diff-line-add" : line.type === "del" ? "diff-line-del" : line.type === "ctx" ? "diff-line-ctx" : ""}`} + style={{ + backgroundColor: line.type === "add" ? "var(--cm-diff-add-bg)" : line.type === "del" ? "var(--cm-diff-del-bg)" : line.type === "hdr" ? "var(--cm-diff-hdr-bg)" : "transparent", + ...(line.type === "hdr" ? { color: "var(--cm-diff-hdr-text)", fontWeight: 500 } : {}), + }} + > + {isCode ? ( + <> + <span className="select-none opacity-50 mr-0.5">{prefix}</span> + {hl ? <span dangerouslySetInnerHTML={{ __html: hl }} /> : line.text} + </> + ) : line.text} + </div> + ) + })} + {truncated && ( + <div className="px-2 py-0.5 italic" style={{ color: "var(--cm-gutter)" }}>...</div> + )} + </div> + ) +} + +/* ─── Write content block with line numbers ─── */ + +function WriteContentBlock({ content, maxChars, lang }: { content: string; maxChars?: number; lang?: string }) { + const truncated = maxChars && content.length > maxChars + const display = truncated ? content.slice(0, maxChars) : content + const lineCount = content.split("\n").length + + const highlighted = useMemo(() => { + try { + if (lang) { + const r = hljs.highlight(display, { language: lang, ignoreIllegals: true }) + return r.value + } + } catch {} + return null + }, [display, lang]) + + const lines = display.split("\n") + + return ( + <div className="overflow-hidden rounded-md font-mono text-xs leading-5" style={{ border: `1px solid var(--cm-code-border)`, backgroundColor: "var(--cm-code-bg)" }}> + <div className="flex items-center gap-2 px-3 py-1.5 text-[10px]" style={{ borderBottom: `1px solid var(--cm-border)`, color: "var(--cm-gutter)" }}> + <FileTextIcon className="h-3 w-3 shrink-0" /> + {lang ? `${lang} · ` : ""}{lineCount.toLocaleString()} lines · {content.length.toLocaleString()} chars + </div> + <div className="overflow-auto max-h-80"> + {highlighted ? ( + <div className="flex"> + <div className="shrink-0 text-right text-[9px] select-none" style={{ color: "var(--cm-gutter)", borderRight: `1px solid var(--cm-border)`, minWidth: "3rem" }}> + {lines.map((_, i) => ( + <div key={i} className="pr-3 py-px">{i + 1}</div> + ))} + </div> + <pre className="flex-1 pl-2 py-px whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: highlighted }} /> + </div> + ) : ( + lines.map((line, i) => ( + <div key={i} className="flex"> + <span className="w-12 shrink-0 text-right pr-3 py-px text-[9px] select-none border-r" style={{ color: "var(--cm-gutter)", borderColor: "var(--cm-border)" }}> + {i + 1} + </span> + <span className="pl-2 py-px whitespace-pre-wrap break-all" style={{ color: "var(--cm-text)" }}>{line}</span> + </div> + )) + )} + {truncated && ( + <div className="pl-14 py-1 text-[10px] italic" style={{ color: "var(--cm-gutter)" }}> + ... {content.length - (maxChars ?? 0)} more chars + </div> + )} + </div> + </div> + ) +} + +function briefContent(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen) + "…"; +} + +/* ─── Old→New string change block (for Edit without diff result) ─── */ + +/* ─── Line-based diff (LCS) for EditChangeBlock ─── */ + +function lineDiff(oldLines: string[], newLines: string[]): { type: "del" | "add" | "ctx"; oldNum: number | null; newNum: number | null; text: string }[] { + const m = oldLines.length, n = newLines.length + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)) + for (let i = 1; i <= m; i++) + for (let j = 1; j <= n; j++) + dp[i][j] = oldLines[i - 1] === newLines[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]) + + const result: { type: "del" | "add" | "ctx"; oldNum: number | null; newNum: number | null; text: string }[] = [] + let i = m, j = n + const stack: typeof result = [] + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { + stack.push({ type: "ctx", oldNum: i, newNum: j, text: oldLines[i - 1] }) + i--; j-- + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + stack.push({ type: "add", oldNum: null, newNum: j, text: newLines[j - 1] }) + j-- + } else { + stack.push({ type: "del", oldNum: i, newNum: null, text: oldLines[i - 1] }) + i-- + } + } + return stack.reverse() +} + +function EditChangeBlock({ oldStr, newStr, maxLen, lang }: { oldStr: string; newStr: string; maxLen?: number; lang?: string }) { + const oldLines = oldStr.split("\n") + const newLines = newStr.split("\n") + const diff = useMemo(() => lineDiff(oldLines, newLines), [oldStr, newStr]) + const truncated = maxLen !== undefined && diff.length > maxLen + const display = truncated ? diff.slice(0, maxLen) : diff + const highlighted = useHighlightedLines(display.map(d => d.text), lang) + let lineIdx = 0 + + return ( + <div className="overflow-hidden rounded-md font-mono text-xs leading-5" style={{ border: `1px solid var(--cm-code-border)` }}> + <div className="flex items-center gap-2 px-3 py-1 text-[10px]" style={{ borderBottom: `1px solid var(--cm-border)`, color: "var(--cm-gutter)" }}> + {oldLines.length} → {newLines.length} lines · {diff.filter(d => d.type !== "ctx").length} changes + </div> + {display.map((ln, i) => { + const isDel = ln.type === "del" + const isAdd = ln.type === "add" + const lineNum = ln.newNum ?? ln.oldNum ?? "" + const hl = highlighted[lineIdx++] + return ( + <div key={i} className="flex" style={{ + backgroundColor: isDel ? "var(--cm-diff-del-bg)" : isAdd ? "var(--cm-diff-add-bg)" : "transparent", + }}> + <span className="w-10 shrink-0 text-right pr-2 py-px text-[9px] select-none border-r" style={{ + color: isAdd ? "var(--cm-diff-add-text)" : isDel ? "var(--cm-diff-del-text)" : "var(--cm-gutter)", + borderColor: "var(--cm-border)", + }}> + {lineNum} + </span> + <span className={`pl-2 py-px whitespace-pre-wrap break-all flex-1 ${isDel ? "diff-line-del" : isAdd ? "diff-line-add" : "diff-line-ctx"}`}> + <span className="select-none text-[9px] mr-1 opacity-50"> + {isDel ? "−" : isAdd ? "+" : " "} + </span> + {hl ? ( + <span dangerouslySetInnerHTML={{ __html: hl }} /> + ) : ( + ln.text + )} + </span> + </div> + ) + })} + {truncated && ( + <div className="px-3 py-1 text-[10px] italic" style={{ color: "var(--cm-gutter)" }}> + ... {diff.length - (maxLen ?? 0)} more lines + </div> + )} + </div> + ) +} + +/* ─── Code block ─── */ + +function CodeBlock({ text, lang }: { text: string; lang?: string }) { + const lines = text.split("\n") + + const highlighted = useMemo(() => { + try { + if (lang) { + const r = hljs.highlight(text, { language: lang, ignoreIllegals: true }) + return r.value + } else { + const r = hljs.highlightAuto(text) + if (r.language) return r.value + } + } catch {} + return null + }, [text, lang]) + + return ( + <div className="overflow-hidden rounded-md font-mono text-xs leading-5" style={{ border: `1px solid var(--cm-code-border)`, backgroundColor: "var(--cm-code-bg)" }}> + <div className="max-h-80 overflow-auto"> + {highlighted ? ( + <div className="flex"> + <div className="shrink-0 text-right text-[9px] select-none" style={{ color: "var(--cm-gutter)", borderRight: `1px solid var(--cm-border)`, minWidth: "3rem" }}> + {lines.map((_, i) => ( + <div key={i} className="pr-3 py-px">{i + 1}</div> + ))} + </div> + <pre className="flex-1 pl-2 py-px whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: highlighted }} /> + </div> + ) : ( + lines.map((line, i) => ( + <div key={i} className="flex"> + <span className="w-12 shrink-0 text-right pr-3 py-px text-[9px] select-none border-r" style={{ color: "var(--cm-gutter)", borderColor: "var(--cm-border)" }}> + {i + 1} + </span> + <span className="pl-2 py-px whitespace-pre-wrap break-all" style={{ color: "var(--cm-text)" }}>{line}</span> + </div> + )) + )} + </div> + </div> + ) +} + +/* ─── Terminal block ─── */ + +function TerminalBlock({ command, output }: { command: string; output?: string }) { + return ( + <div className="overflow-hidden rounded-md bg-[#0d1117] border border-gray-700 text-xs"> + {command && ( + <div className="flex items-center gap-2 border-b border-gray-700/50 px-3 py-1.5 text-green-400 font-mono"> + <span className="select-none text-gray-500 text-[10px]">$</span> + <span className="whitespace-pre-wrap break-all font-medium">{command}</span> + </div> + )} + {output !== undefined && ( + <pre className="max-h-64 overflow-auto px-3 py-2 text-gray-300 whitespace-pre-wrap font-mono leading-relaxed"> + {output || <span className="text-gray-500 italic">(no output)</span>} + </pre> + )} + </div> + ) +} + +/* ─── Main renderer ─── */ + +export default function ToolRenderer({ + toolName, + args, + result, + status = "complete", + elapsedSeconds, + taskState, + toolCallId, +}: ToolRendererProps) { + const meta = getToolMeta(toolName); + const Icon = meta.icon; + const summary = getSummary(toolName, args); + const [open, setOpen] = useState(false); + const isDone = status === "complete"; + const isRunning = status === "running"; + const isActionNeeded = status === "requires-action"; + const statusCfg = STATUS_CONFIG[status]; + const isMobile = useIsMobile(); + + const { permissionPrompt, answerPermission, openFile } = useLoopRuntimeExtra(); + const needsPermission = isActionNeeded || (permissionPrompt?.toolUseId === toolCallId); + + // Auto-expand when waiting for permission + const effectiveOpen = open || needsPermission; + + // Per-tool elapsed timer (SDK or local fallback) + const elapsed = useElapsedTimer(isRunning, elapsedSeconds); + + const diff = isDone && result ? parseDiff(result) : null; + const hasDiff = diff !== null; + const isBash = toolName === "Bash"; + const isTodo = toolName === "TodoWrite"; + const isAgent = toolName === "Agent" || toolName === "Task"; + const isWrite = toolName === "Write"; + const isEdit = toolName === "Edit" || toolName === "ApplyPatch"; + const hasArgs = Object.keys(args).length > 0; + + const writeContent = (args.content as string) ?? ""; + const editOld = (args.old_string as string) ?? ""; + const editNew = (args.new_string as string) ?? ""; + const editHasChange = isEdit && editOld && editNew; + + const rawFilePath = (args.file_path as string) || (args.filePath as string) || "" + const filePath = shortPath(rawFilePath) + const canOpenInEditor = !!(openFile && filePath && isDone) + + // Detect language for syntax highlighting + const codeLang = useMemo(() => { + const ext = filePath.includes(".") ? filePath.split(".").pop()?.toLowerCase() ?? "" : "" + const map: Record<string, string> = { + js: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript", + py: "python", rs: "rust", go: "go", rb: "ruby", swift: "swift", + java: "java", c: "c", cpp: "cpp", cs: "csharp", kt: "kotlin", + json: "json", yaml: "yaml", yml: "yaml", toml: "ini", xml: "xml", + html: "xml", htm: "xml", svg: "xml", css: "css", scss: "scss", less: "less", + sql: "sql", sh: "bash", bash: "bash", zsh: "bash", fish: "bash", + md: "markdown", dockerfile: "dockerfile", proto: "protobuf", + php: "php", lua: "lua", r: "r", scala: "scala", + } + return map[ext] || (ext && ext.length <= 5 ? ext : "") + }, [filePath]) + + // Parse todos from args + const todos = isTodo + ? (Array.isArray(args.todos) + ? (args.todos as any[]) + : []) + : null; + + // Collapsed preview content for Write/Edit (skip when permission prompt is forcing open) + const collapsedPreview = !effectiveOpen + ? isWrite && writeContent + ? briefContent(writeContent, 200) + : isEdit && (hasDiff || editHasChange) + ? { type: "edit" as const, oldStr: editOld, newStr: editNew, diff } + : null + : null; + + return ( + <Collapsible + open={effectiveOpen} + onOpenChange={setOpen} + className={cn( + "group/tool my-1.5 overflow-hidden rounded-lg border border-gray-100 bg-gray-50/60 border-l-[3px]", + meta.borderClass, + isRunning && "animate-pulse", + )} + > + <CollapsibleTrigger + className="flex w-full flex-col text-left text-sm transition-colors hover:bg-gray-50" + > + {/* Title line */} + <div className="flex w-full items-center gap-1.5 md:gap-2 px-2 md:px-3 py-1.5"> + <Icon className="h-3.5 w-3.5 shrink-0 text-gray-400" /> + <span className="font-medium text-gray-700 text-xs">{toolName}</span> + + {summary && ( + <> + <span className="text-gray-300">·</span> + <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-gray-500"> + {summary} + </span> + </> + )} + + {/* Status badge */} + {needsPermission ? ( + <span className="ml-auto shrink-0 rounded px-1.5 py-px text-[10px] font-medium bg-amber-100 text-amber-700"> + Action needed + </span> + ) : isRunning ? ( + <span className="ml-auto shrink-0 rounded px-1.5 py-px text-[10px] font-medium tabular-nums bg-sky-100 text-sky-700"> + {formatElapsed(elapsed)} + </span> + ) : ( + <span + className={cn( + "ml-auto shrink-0 rounded px-1.5 py-px text-[10px] font-medium", + statusCfg.className, + )} + > + {statusCfg.label} + </span> + )} + + <ChevronDownIcon + className={cn( + "h-3.5 w-3.5 shrink-0 text-gray-300 transition-transform", + open && "rotate-180", + )} + /> + </div> + + {/* Collapsed preview: brief content/diff when not expanded */} + {collapsedPreview && ( + <div className="border-t border-gray-100 px-2 md:px-3 py-1.5"> + {typeof collapsedPreview === "string" ? ( + <pre className="font-mono text-[10px] leading-relaxed text-gray-500 whitespace-pre-wrap break-all line-clamp-3"> + {collapsedPreview} + </pre> + ) : collapsedPreview.type === "edit" ? ( + collapsedPreview.diff && result ? ( + <DiffView text={result} lang={codeLang} maxLines={8} /> + ) : ( + <div className="opacity-70"> + <EditChangeBlock oldStr={collapsedPreview.oldStr} newStr={collapsedPreview.newStr} maxLen={20} lang={codeLang} /> + </div> + ) + ) : null} + </div> + )} + </CollapsibleTrigger> + + <CollapsibleContent + className={cn( + "overflow-hidden", + "data-[state=open]:animate-collapsible-down", + "data-[state=closed]:animate-collapsible-up", + )} + > + <div className={cn("border-t border-gray-100 px-2 md:px-3 py-2", isMobile && "max-h-[60vh] overflow-y-auto")}> + {/* Permission prompt — shown when this tool needs user approval */} + {needsPermission && permissionPrompt && ( + <PermissionPrompt + toolName={permissionPrompt.toolName} + title={permissionPrompt.title} + displayName={permissionPrompt.displayName} + onAllow={() => answerPermission(permissionPrompt.toolUseId, true)} + onDeny={() => answerPermission(permissionPrompt.toolUseId, false)} + /> + )} + + {/* Open in Editor — for tools that reference a file */} + {canOpenInEditor && ( + <button + type="button" + onClick={() => openFile!(filePath)} + className="inline-flex items-center gap-1 mb-2 px-2 py-1 rounded text-[11px] border border-gray-200 bg-white hover:bg-gray-50 text-gray-600 hover:text-gray-900 transition-colors" + > + <ExternalLink className="h-3 w-3" /> + Open in Editor + </button> + )} + + {/* Write — show full content with line numbers */} + {isWrite && isDone && writeContent && ( + <WriteContentBlock content={writeContent} lang={codeLang} /> + )} + + {/* Edit / ApplyPatch — show diff (if result has one) or old→new change */} + {isEdit && isDone && hasDiff && result && ( + <DiffView text={result} lang={codeLang} /> + )} + {isEdit && isDone && !hasDiff && editHasChange && ( + <EditChangeBlock oldStr={editOld} newStr={editNew} lang={codeLang} /> + )} + + {/* Bash — show terminal-style output (only when there's content) */} + {isBash && (summary || result !== undefined) && ( + <TerminalBlock + command={summary} + output={result} + /> + )} + + {/* TodoWrite — checklist */} + {isTodo && todos && todos.length > 0 && ( + <TodoRenderer todos={todos} /> + )} + + {/* Agent / Task — sub-agent display */} + {isAgent && ( + <AgentRenderer + args={args} + result={result} + status={status} + taskState={taskState} + elapsedSeconds={elapsed} + toolCallId={toolCallId} + /> + )} + + {/* Fallback: show result as code, suppress JSON args */} + {!isWrite && !isBash && !isTodo && !isAgent && !(isEdit && (hasDiff || editHasChange)) && result !== undefined && ( + <CodeBlock text={typeof result === "string" ? result : JSON.stringify(result, null, 2)} lang={codeLang} /> + )} + + {/* Running state — show loader when no meaningful content yet (skip when waiting for permission) */} + {isRunning && !needsPermission && !result && !hasArgs && !isAgent && !(isTodo && todos && todos.length > 0) && ( + <div className="flex items-center gap-2 py-1 text-xs text-gray-400"> + <LoaderIcon className="h-3 w-3 animate-spin" /> + Working... + </div> + )} + + {/* Error state */} + {status === "incomplete" && ( + <div className="flex items-center gap-2 text-xs text-red-500"> + <XCircleIcon className="h-3.5 w-3.5" /> + Tool call failed + </div> + )} + </div> + </CollapsibleContent> + </Collapsible> + ); +} diff --git a/web/src/components/chat/UserMessage.tsx b/web/src/components/chat/UserMessage.tsx new file mode 100644 index 00000000..78d94c1a --- /dev/null +++ b/web/src/components/chat/UserMessage.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { + MessagePrimitive, + useAuiState, +} from "@assistant-ui/react"; +import { MarkdownBlock } from "./MarkdownBlock"; +import { ChevronDownIcon, ChevronUpIcon, FileText, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +function extractTime(messageId: string | undefined): string { + if (!messageId) return ""; + const match = messageId.match(/(\d{13})/); + if (match) { + return new Date(parseInt(match[1], 10)).toLocaleTimeString(); + } + return ""; +} + +/** Parse `# File: path\n\`\`\`ext\n...\n\`\`\`\n` blocks */ +function parseFileBlocks(text: string): { path: string; content: string }[] { + const blocks: { path: string; content: string }[] = [] + const re = /(?:^|\n)# File: (.+?)\n```(\w*)\n([\s\S]*?)```\n/g + let m + while ((m = re.exec(text)) !== null) { + blocks.push({ path: m[1], content: m[3] }) + } + return blocks +} + +function stripFileBlocks(text: string): string { + return text.replace(/(?:\n|^)# File: .+?\n```\w*\n[\s\S]*?```\n/g, "").trim() +} + +function FileCard({ filePath, content }: { filePath: string; content: string }) { + const [open, setOpen] = useState(false) + const shortName = filePath.includes("/") ? filePath.slice(filePath.lastIndexOf("/") + 1) : filePath + const ext = filePath.includes(".") ? filePath.split(".").pop() ?? "" : "" + return ( + <div className="inline-flex flex-col overflow-hidden rounded border border-gray-200 bg-gray-50/70 text-xs max-w-max"> + <button onClick={() => setOpen(!open)} className="inline-flex items-center gap-1 px-1.5 py-0.5 text-left hover:bg-gray-100 transition-colors"> + <ChevronRight size={9} className={cn("shrink-0 text-gray-400 transition-transform", open && "rotate-90")} /> + <FileText size={9} className="shrink-0 text-gray-400" /> + <span className="text-gray-500 truncate max-w-[200px]">{shortName}</span> + <span className="text-[9px] text-gray-400 ml-1">{ext}</span> + </button> + {open && ( + <pre className="border-t border-gray-200 px-2 py-1 text-[10px] leading-relaxed text-gray-500 whitespace-pre-wrap break-all max-h-48 overflow-auto font-mono"> + {content} + </pre> + )} + </div> + ) +} + +export default function UserMessage() { + const messageId = useAuiState((s) => s.message.id); + const time = extractTime(messageId); + const [expanded, setExpanded] = useState(false); + const [needsTruncation, setNeedsTruncation] = useState(false); + + const measureRef = (el: HTMLDivElement | null) => { + if (!el) return; + setNeedsTruncation(el.scrollHeight > 72); + }; + + return ( + <MessagePrimitive.Root data-role="user" className="group relative"> + + <div className={cn("relative overflow-hidden rounded-xl border border-gray-300 bg-white px-4 py-2.5 text-sm shadow-sm transition-all", !expanded && needsTruncation && "max-h-[4.5rem]")}> + <div ref={measureRef} className="whitespace-pre-wrap break-words text-gray-800"> + <MessagePrimitive.Parts + components={{ + Text: (props) => { + const raw = (props as any).text ?? "" + const blocks = parseFileBlocks(raw) + const cleaned = stripFileBlocks(raw) + if (blocks.length === 0) return <MarkdownBlock /> + return ( + <> + <div className="flex flex-wrap gap-1 mb-1.5"> + {blocks.map((b, i) => <FileCard key={i} filePath={b.path} content={b.content} />)} + </div> + {cleaned ? <span>{cleaned}</span> : <span className="text-red-500">[empty after strip]</span>} + </> + ) + }, + }} + /> + </div> + {!expanded && needsTruncation && <div className="user-msg-fade rounded-b-xl" />} + </div> + + {needsTruncation && ( + <button onClick={() => setExpanded(!expanded)} className={cn("absolute -bottom-0 right-2 z-10 flex items-center gap-1 rounded-md border border-gray-200 bg-white px-2 py-0.5 text-[10px] text-gray-500 shadow-sm transition-all hover:bg-gray-50 hover:text-gray-700", "opacity-0 group-hover:opacity-100", expanded && "opacity-100")}> + {expanded ? <><ChevronUpIcon className="h-3 w-3" />Show less</> : <><ChevronDownIcon className="h-3 w-3" />Show more</>} + </button> + )} + + {time && <div className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-gray-400"><span>{time}</span></div>} + </MessagePrimitive.Root> + ); +} diff --git a/web/src/components/dialog/AboutDialog.tsx b/web/src/components/dialog/AboutDialog.tsx new file mode 100644 index 00000000..c742c04a --- /dev/null +++ b/web/src/components/dialog/AboutDialog.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { getVersion, getBuildInfo } from "@/api" + +export function AboutDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const [server, setServer] = useState({ branch: "…", commit: "…" }) + const build = getBuildInfo() + + useEffect(() => { + if (open) { + getVersion().then((v) => setServer(v)) + } + }, [open]) + + return ( + <Dialog open={open} onOpenChange={(o) => !o && onClose()}> + <DialogContent className="sm:max-w-sm bg-white"> + <DialogHeader> + <DialogTitle className="text-center"> + <img src="/logo.png" alt="loopat" className="w-full max-w-[240px] mx-auto mb-2" /> + loopat + </DialogTitle> + </DialogHeader> + <div className="space-y-3 text-sm"> + <div className="bg-gray-50 rounded-lg p-3"> + <div className="text-gray-400 text-xs mb-1">Server</div> + <div className="font-mono text-gray-700"> + {server.branch}@{server.commit.slice(0, 7)} + </div> + </div> + <div className="bg-gray-50 rounded-lg p-3"> + <div className="text-gray-400 text-xs mb-1">Build</div> + <div className="font-mono text-gray-700"> + {build.commit.slice(0, 7)} + </div> + <div className="font-mono text-gray-400 text-xs mt-0.5"> + {new Date(build.time).toLocaleString()} + </div> + </div> + </div> + </DialogContent> + </Dialog> + ) +} diff --git a/web/src/components/dialog/AdminDialog.tsx b/web/src/components/dialog/AdminDialog.tsx new file mode 100644 index 00000000..f94e08df --- /dev/null +++ b/web/src/components/dialog/AdminDialog.tsx @@ -0,0 +1,972 @@ +import { useState, useEffect, useCallback } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" +import { Plus, Trash2, Check } from "lucide-react" + +const inputClass = "w-full px-2.5 py-1.5 border border-gray-300 rounded text-[13px] outline-none bg-white focus:border-gray-900 focus:ring-1 focus:ring-gray-900 transition-colors disabled:bg-gray-50 disabled:text-gray-400" +import { + listAdminUsers, + activateAdminUser, + setAdminUserRole, + deleteAdminUser, + getWorkspaceSettings, + updateWorkspaceSettings, + getServeConfig, + setServeConfig, + testProviderConnection, + getAdminPresets, + type AdminUser, + type WorkspaceSettings, + type ModelEntry, + type ProviderPreset, +} from "@/api" + +type Tab = "users" | "workspace" | "serve" + +export function AdminDialog({ + open, + onClose, + currentUserId, +}: { + open: boolean + onClose: () => void + currentUserId: string +}) { + const [tab, setTab] = useState<Tab>("users") + + if (!open) return null + + return ( + <Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}> + <DialogContent + className="max-w-[95vw] sm:max-w-[760px] h-[85vh] sm:h-[80vh] p-0 gap-0 overflow-hidden flex flex-col" + showCloseButton + > + <DialogHeader className="px-4 sm:px-6 pt-4 sm:pt-5 pb-0 shrink-0"> + <DialogTitle>Admin</DialogTitle> + <DialogDescription className="sr-only"> + Workspace administration — manage users and shared configuration. + </DialogDescription> + </DialogHeader> + + <div className="flex gap-0 px-4 sm:px-6 pt-4 border-b border-gray-200 shrink-0"> + <TabButton active={tab === "users"} onClick={() => setTab("users")}>Users</TabButton> + <TabButton active={tab === "workspace"} onClick={() => setTab("workspace")}>Workspace</TabButton> + <TabButton active={tab === "serve"} onClick={() => setTab("serve")}>Workspace Serve</TabButton> + </div> + + <div className="flex-1 min-h-0 overflow-y-auto p-4 sm:p-5"> + {tab === "users" ? ( + <UsersPanel currentUserId={currentUserId} /> + ) : tab === "workspace" ? ( + <WorkspacePanel /> + ) : ( + <ServePanel /> + )} + </div> + </DialogContent> + </Dialog> + ) +} + +function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + <button + type="button" + onClick={onClick} + className={`px-3 sm:px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ + active + ? "border-gray-900 text-gray-900" + : "border-transparent text-gray-500 hover:text-gray-700" + }`} + > + {children} + </button> + ) +} + +export function UsersPanel({ currentUserId }: { currentUserId: string }) { + const [users, setUsers] = useState<AdminUser[]>([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [busyId, setBusyId] = useState<string | null>(null) + + const reload = useCallback(async () => { + setLoading(true) + setError("") + try { + const list = await listAdminUsers() + setUsers(list) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { reload() }, [reload]) + + const adminCount = users.filter((u) => u.role === "admin").length + + async function activate(id: string) { + setBusyId(id); setError("") + try { + const r = await activateAdminUser(id) + if (!r.ok) { setError(r.error ?? "activate failed"); return } + await reload() + } finally { setBusyId(null) } + } + + async function toggleRole(u: AdminUser) { + const next = u.role === "admin" ? "member" : "admin" + setBusyId(u.id); setError("") + try { + const r = await setAdminUserRole(u.id, next) + if (!r.ok) { setError(r.error ?? "role change failed"); return } + await reload() + } finally { setBusyId(null) } + } + + async function remove(u: AdminUser) { + if (!confirm(`Delete user ${u.id}? Entry in users.json will be removed; personal/${u.id}/ on disk is preserved.`)) return + setBusyId(u.id); setError("") + try { + const r = await deleteAdminUser(u.id) + if (!r.ok) { setError(r.error ?? "delete failed"); return } + await reload() + } finally { setBusyId(null) } + } + + if (loading) return <div className="text-sm text-gray-400 py-8 text-center">Loading…</div> + + return ( + <div className="flex flex-col gap-3"> + {error && ( + <div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5"> + {error} + </div> + )} + <div className="overflow-x-auto"> + <table className="w-full text-sm"> + <thead className="text-xs text-gray-500 border-b border-gray-200"> + <tr> + <th className="text-left font-medium py-2 pr-2">User</th> + <th className="text-left font-medium py-2 pr-2">Role</th> + <th className="text-left font-medium py-2 pr-2">Status</th> + <th className="text-left font-medium py-2 pr-2">Created</th> + <th className="text-right font-medium py-2 pl-2">Actions</th> + </tr> + </thead> + <tbody> + {users.map((u) => { + const isMe = u.id === currentUserId + const lastAdmin = u.role === "admin" && adminCount <= 1 + const busy = busyId === u.id + return ( + <tr key={u.id} className="border-b border-gray-100 last:border-b-0"> + <td className="py-2 pr-2"> + <span className="font-medium text-gray-900">{u.id}</span> + {isMe && <span className="ml-1.5 text-[10px] text-gray-400">(you)</span>} + </td> + <td className="py-2 pr-2"> + <RoleBadge role={u.role} /> + </td> + <td className="py-2 pr-2"> + <StatusBadge status={u.status} /> + </td> + <td className="py-2 pr-2 text-xs text-gray-500"> + {u.createdAt.slice(0, 10)} + </td> + <td className="py-2 pl-2 text-right space-x-1"> + {u.status === "pending" && ( + <Button size="xs" onClick={() => activate(u.id)} disabled={busy}> + Activate + </Button> + )} + <Button + size="xs" + variant="outline" + onClick={() => toggleRole(u)} + disabled={busy || lastAdmin} + title={lastAdmin ? "cannot demote the last admin" : undefined} + > + {u.role === "admin" ? "Demote" : "Promote"} + </Button> + <button + type="button" + onClick={() => remove(u)} + disabled={busy || isMe || lastAdmin} + title={ + isMe ? "cannot delete yourself" + : lastAdmin ? "cannot delete the last admin" + : undefined + } + className="text-xs text-gray-500 hover:text-red-500 disabled:text-gray-300 disabled:cursor-not-allowed px-1.5" + > + Delete + </button> + </td> + </tr> + ) + })} + {users.length === 0 && ( + <tr><td colSpan={5} className="text-center py-8 text-gray-400 text-sm">no users</td></tr> + )} + </tbody> + </table> + </div> + </div> + ) +} + +function RoleBadge({ role }: { role: "admin" | "member" }) { + const cls = role === "admin" + ? "bg-violet-50 text-violet-700 border-violet-200" + : "bg-gray-50 text-gray-600 border-gray-200" + return ( + <span className={`inline-block text-[10px] px-1.5 py-0.5 rounded border ${cls}`}>{role}</span> + ) +} + +function StatusBadge({ status }: { status: "active" | "pending" }) { + const cls = status === "active" + ? "bg-emerald-50 text-emerald-700 border-emerald-200" + : "bg-amber-50 text-amber-700 border-amber-200" + return ( + <span className={`inline-block text-[10px] px-1.5 py-0.5 rounded border ${cls}`}>{status}</span> + ) +} + +// ── Workspace settings panel — matches the personal ProvidersSection UI ── + +type WorkspaceDraft = { + default: string + providers: Record<string, { + models: ModelEntry[] + baseUrl: string + maxContextTokens?: number + apiKey: string + keyDirty: boolean + hasKey: boolean + enabled: boolean + }> +} + +export function WorkspacePanel() { + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [err, setErr] = useState<string | null>(null) + const [saved, setSaved] = useState(false) + const [draft, setDraft] = useState<WorkspaceDraft | null>(null) + const [newName, setNewName] = useState("") + const [adding, setAdding] = useState(false) + const [newModelName, setNewModelName] = useState<Record<string, string>>({}) + const [addingModel, setAddingModel] = useState<Record<string, boolean>>({}) + const [editingProvName, setEditingProvName] = useState<string | null>(null) + const [provRenameValue, setProvRenameValue] = useState("") + const [editingModelKey, setEditingModelKey] = useState<string | null>(null) + const [newModelIdValue, setNewModelIdValue] = useState("") + const [testingModel, setTestingModel] = useState<Record<string, string>>({}) + const [testError, setTestError] = useState<Record<string, string>>({}) + const [providerPresets, setProviderPresets] = useState<ProviderPreset[]>([]) + + useEffect(() => { getAdminPresets().then(d => setProviderPresets(d.providerPresets)).catch(() => {}) }, []) + + useEffect(() => { + setLoading(true) + setErr(null) + getWorkspaceSettings().then((w) => { + const next: WorkspaceDraft = { default: w.default ?? "", providers: {} } + for (const [name, prov] of Object.entries(w.providers)) { + next.providers[name] = { + models: (prov as any).models?.map((m: any) => ({ + id: m.id, + enabled: m.enabled !== false, + ...(m.maxContextTokens && m.maxContextTokens > 0 ? { maxContextTokens: m.maxContextTokens } : {}), + })) ?? [], + baseUrl: prov.baseUrl ?? "", + maxContextTokens: (prov as any).maxContextTokens || undefined, + apiKey: "", + keyDirty: false, + hasKey: prov.hasKey ?? false, + enabled: (prov as any).enabled !== false, + } + } + setDraft(next) + }).catch((e) => { + setErr(e?.message ?? "load failed") + }).finally(() => setLoading(false)) + }, []) + + const names = draft ? Object.keys(draft.providers) : [] + + const updateProv = (name: string, patch: Partial<WorkspaceDraft["providers"][string]>) => { + setDraft((d) => { + if (!d || !d.providers[name]) return d + return { ...d, providers: { ...d.providers, [name]: { ...d.providers[name], ...patch } } } + }) + } + + const remove = (name: string) => { + setDraft((d) => { + if (!d) return d + const { [name]: _, ...rest } = d.providers + const clearDefault = d.default === name || d.default.startsWith(`${name}/`) + return { ...d, providers: rest, default: clearDefault ? "" : d.default } + }) + } + + const updateModel = (provName: string, modelId: string, patch: Partial<ModelEntry>) => { + setDraft((d) => { + if (!d || !d.providers[provName]) return d + const models = d.providers[provName].models.map(m => + m.id === modelId ? { ...m, ...patch } : m, + ) + return { ...d, providers: { ...d.providers, [provName]: { ...d.providers[provName], models } } } + }) + } + + const toggleModel = (provName: string, modelId: string) => { + setDraft((d) => { + if (!d || !d.providers[provName]) return d + const models = d.providers[provName].models.map(m => + m.id === modelId ? { ...m, enabled: !m.enabled } : m, + ) + return { ...d, providers: { ...d.providers, [provName]: { ...d.providers[provName], models } } } + }) + } + + const removeModel = (provName: string, modelId: string) => { + setDraft((d) => { + if (!d || !d.providers[provName]) return d + const models = d.providers[provName].models.filter(m => m.id !== modelId) + const clearDefault = d.default === `${provName}/${modelId}` + return { ...d, default: clearDefault ? "" : d.default, providers: { ...d.providers, [provName]: { ...d.providers[provName], models } } } + }) + } + + const addModel = (provName: string) => { + const id = (newModelName[provName] ?? "").trim() + if (!id) return + setDraft((d) => { + if (!d || !d.providers[provName]) return d + if (d.providers[provName].models.some(m => m.id === id)) return d + return { + ...d, + providers: { + ...d.providers, + [provName]: { + ...d.providers[provName], + models: [...d.providers[provName].models, { id, enabled: true }], + }, + }, + } + }) + setNewModelName((p) => ({ ...p, [provName]: "" })) + setAddingModel((p) => ({ ...p, [provName]: false })) + } + + const renameModel = (provName: string, oldId: string) => { + const newId = newModelIdValue.trim() + if (!newId || newId === oldId) { setEditingModelKey(null); return } + setDraft((d) => { + if (!d || !d.providers[provName]) return d + if (d.providers[provName].models.some(m => m.id === newId)) return d + const models = d.providers[provName].models.map(m => + m.id === oldId ? { ...m, id: newId } : m, + ) + const prevDefault = d.default === `${provName}/${oldId}` ? `${provName}/${newId}` : d.default + return { ...d, default: prevDefault, providers: { ...d.providers, [provName]: { ...d.providers[provName], models } } } + }) + setEditingModelKey(null) + } + + const renameProvider = (oldName: string) => { + const newName = provRenameValue.trim() + if (!newName || newName === oldName || newName === "default") { setEditingProvName(null); return } + setDraft((d) => { + if (!d || !d.providers[oldName]) return d + if (d.providers[newName]) return d + const { [oldName]: prov, ...rest } = d.providers + let newDefault = d.default + if (d.default === oldName) { + newDefault = newName + } else if (d.default.startsWith(`${oldName}/`)) { + newDefault = newName + d.default.slice(oldName.length) + } + return { ...d, default: newDefault, providers: { ...rest, [newName]: prov } } + }) + setEditingProvName(null) + } + + const addProvider = () => { + const n = newName.trim() + if (!n) return + if (draft?.providers[n]) { setErr("provider name already exists"); return } + setDraft((d) => { + if (!d) return d + return { ...d, providers: { ...d.providers, [n]: { + models: [], baseUrl: "", apiKey: "", keyDirty: false, hasKey: false, enabled: false, + } } } + }) + setNewName("") + setAdding(false) + setErr(null) + } + + const save = async () => { + if (!draft) return + setSaving(true); setErr(null) + try { + const out: Record<string, any> = {} + for (const [name, p] of Object.entries(draft.providers)) { + const models = p.models + .filter(m => m.id.trim()) + .map(m => ({ + id: m.id.trim(), + ...(m.enabled ? {} : { enabled: false }), + ...(m.maxContextTokens && m.maxContextTokens > 0 ? { maxContextTokens: m.maxContextTokens } : {}), + })) + out[name] = { + models, + baseUrl: p.baseUrl, + enabled: p.enabled, + ...(p.maxContextTokens && p.maxContextTokens > 0 ? { maxContextTokens: p.maxContextTokens } : {}), + } + if (p.keyDirty && p.apiKey.trim()) out[name].apiKey = p.apiKey.trim() + } + const ok = await updateWorkspaceSettings({ providers: out, default: draft.default }) + if (!ok) { setErr("save failed"); return } + setSaved(true) + setTimeout(() => setSaved(false), 2500) + setDraft((d) => { + if (!d) return d + const next = { ...d, providers: { ...d.providers } } + for (const k of Object.keys(next.providers)) { + const wasDirty = next.providers[k].keyDirty + next.providers[k] = { ...next.providers[k], keyDirty: false, apiKey: "", hasKey: wasDirty || next.providers[k].hasKey } + } + return next + }) + } catch (e: any) { + setErr(e?.message ?? "save failed") + } finally { + setSaving(false) + } + } + + if (loading) return <div className="text-[12px] text-gray-400 italic py-12 text-center">loading…</div> + if (!draft) return null + + return ( + <div className="flex flex-col gap-3"> + {names.map((name) => { + const p = draft.providers[name] + const isAddingModel = addingModel[name] ?? false + const hasKey = p.hasKey || p.apiKey.trim() !== "" + return ( + <div key={name} className="bg-white border border-gray-200 rounded-lg overflow-hidden"> + {/* Provider header */} + <div className="flex items-center gap-3 px-4 py-2.5 bg-gray-50/50 border-b border-gray-100"> + <label className="flex items-center gap-2.5 flex-1 min-w-0 select-none"> + <Switch + checked={p.enabled} + onCheckedChange={(v) => hasKey ? updateProv(name, { enabled: v }) : undefined} + disabled={!hasKey} + size="sm" + /> + {editingProvName === name ? ( + <input + autoFocus + value={provRenameValue} + onChange={(e) => setProvRenameValue(e.target.value)} + onBlur={() => renameProvider(name)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) renameProvider(name) + if (e.key === "Escape") setEditingProvName(null) + }} + className={cn(inputClass, "text-[13px] font-semibold flex-1 min-w-0")} + /> + ) : ( + <button + type="button" + onClick={() => { setEditingProvName(name); setProvRenameValue(name) }} + className={`text-[13px] font-semibold truncate text-left hover:underline ${p.enabled ? "text-gray-900" : "text-gray-400"}`} + title="click to rename" + > + {name} + </button> + )} + {!p.enabled && ( + <span className="text-[9px] px-1.5 py-0.5 rounded-full bg-gray-100 text-gray-400 font-medium shrink-0">disabled</span> + )} + </label> + <button + type="button" + onClick={() => remove(name)} + className="text-[11px] text-gray-400 hover:text-red-500 transition-colors shrink-0" + > + remove + </button> + </div> + + {/* Fields */} + <div className="p-4"> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3"> + <Labeled label="Base URL"> + <input + value={p.baseUrl} + onChange={(e) => updateProv(name, { baseUrl: e.target.value })} + placeholder="https://api.example.com" + className={inputClass} + /> + </Labeled> + <Labeled label="Max context tokens"> + <input + type="number" + value={p.maxContextTokens ?? ""} + onChange={(e) => updateProv(name, { maxContextTokens: e.target.value ? Number(e.target.value) : undefined })} + placeholder="auto" + className={inputClass} + /> + </Labeled> + <Labeled label={p.hasKey && !p.keyDirty ? "API Key (set — type to overwrite)" : "API Key"} className="sm:col-span-2"> + <input + type="password" + value={p.apiKey} + onChange={(e) => updateProv(name, { apiKey: e.target.value, keyDirty: true })} + placeholder={p.hasKey && !p.keyDirty ? "•••••• stored" : "API key"} + className={inputClass} + /> + </Labeled> + </div> + + {/* Model list */} + <div className="border-t border-gray-100 pt-3"> + <div className="flex items-center justify-between mb-2"> + <span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider"> + Models ({p.models.length}) + </span> + <button + type="button" + onClick={() => setAddingModel((a) => ({ ...a, [name]: !isAddingModel }))} + className="text-[11px] text-gray-500 hover:text-gray-900 inline-flex items-center gap-1 transition-colors" + > + <Plus size={11} /> add model + </button> + </div> + + {isAddingModel && ( + <div className="flex items-center gap-1.5 mb-2"> + <input + autoFocus + value={newModelName[name] ?? ""} + onChange={(e) => setNewModelName((a) => ({ ...a, [name]: e.target.value }))} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) addModel(name); if (e.key === "Escape") setAddingModel((a) => ({ ...a, [name]: false })) }} + placeholder="model ID (e.g. claude-sonnet-4-20250514)" + className={cn(inputClass, "flex-1 text-[11px]")} + /> + <Button size="xs" onClick={() => addModel(name)}>add</Button> + <Button variant="ghost" size="xs" onClick={() => setAddingModel((a) => ({ ...a, [name]: false }))}>cancel</Button> + </div> + )} + + {p.models.length === 0 && !isAddingModel && ( + <div className="text-[11px] text-gray-400 italic py-2">no models — add one above</div> + )} + <div className="-mx-1"> + {p.models.map((m) => { + const isDefaultModel = draft.default === `${name}/${m.id}` + const editKey = `${name}::${m.id}` + const isEditing = editingModelKey === editKey + const tmState = testingModel[`${name}::${m.id}`] + const tmErr = testError[`${name}::${m.id}`] + return ( + <div key={m.id} className="flex items-center gap-1.5 px-1 py-1.5 rounded group hover:bg-gray-50 transition-colors"> + {/* Default model star */} + <button + type="button" + onClick={() => setDraft((d) => d ? { ...d, default: isDefaultModel ? "" : `${name}/${m.id}` } : d)} + className={`shrink-0 text-[13px] leading-none transition-colors ${isDefaultModel ? "text-amber-500" : "text-gray-200 hover:text-amber-400"}`} + title={isDefaultModel ? "current default model" : "set as default model"} + > + ★ + </button> + <label className="flex items-center shrink-0 cursor-pointer"> + <input + type="checkbox" + checked={m.enabled !== false} + onChange={() => toggleModel(name, m.id)} + className="h-3.5 w-3.5 rounded border-gray-300 text-gray-900 accent-gray-900" + /> + </label> + <div className="flex-1 min-w-0 flex items-center gap-1"> + {isEditing ? ( + <input + autoFocus + value={newModelIdValue} + onChange={(e) => setNewModelIdValue(e.target.value)} + onBlur={() => renameModel(name, m.id)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) renameModel(name, m.id) + if (e.key === "Escape") setEditingModelKey(null) + }} + className={cn(inputClass, "text-[11px] flex-1 min-w-0")} + /> + ) : ( + <code + className={`text-[12px] truncate cursor-pointer hover:bg-gray-100 px-0.5 rounded ${ + m.enabled !== false ? "text-gray-700" : "text-gray-300 line-through" + }`} + onClick={() => { setEditingModelKey(editKey); setNewModelIdValue(m.id) }} + title="click to edit model ID" + > + {m.id} + </code> + )} + {m.enabled === false && ( + <span className="text-[9px] text-gray-300 font-medium shrink-0">off</span> + )} + </div> + <input + type="number" + value={m.maxContextTokens ?? ""} + onChange={(e) => updateModel(name, m.id, { maxContextTokens: e.target.value ? Number(e.target.value) : undefined })} + placeholder="auto" + className={`w-24 px-1.5 py-0.5 border border-gray-200 rounded text-[10px] outline-none focus:border-gray-400 shrink-0 ${m.maxContextTokens ? "" : "opacity-0 group-hover:opacity-100 transition-opacity"}`} + title="max context tokens (empty = auto)" + /> + {/* Test button */} + <button + type="button" + onClick={async () => { + const newKey = p.apiKey.trim() + const tk = `${name}::${m.id}` + if (!newKey && !p.hasKey) { + setTestingModel((t) => ({ ...t, [tk]: "error" })) + setTestError((t) => ({ ...t, [tk]: "enter an API key first" })) + setTimeout(() => { + setTestingModel((t) => { const { [tk]: _, ...rest } = t; return rest }) + setTestError((t) => { const { [tk]: _, ...rest } = t; return rest }) + }, 3000) + return + } + setTestingModel((t) => ({ ...t, [tk]: "testing" })) + setTestError((t) => ({ ...t, [tk]: "" })) + try { + const result = newKey + ? await testProviderConnection(p.baseUrl, newKey, m.id) + : await testProviderConnection(p.baseUrl, "", m.id, name, "workspace") + setTestingModel((t) => ({ ...t, [tk]: result.ok ? "ok" : "error" })) + if (!result.ok) setTestError((t) => ({ ...t, [tk]: result.error ?? "unknown error" })) + } catch (e: any) { + setTestingModel((t) => ({ ...t, [tk]: "error" })) + setTestError((t) => ({ ...t, [tk]: e?.message ?? "connection failed" })) + } + setTimeout(() => { + setTestingModel((t) => { const { [tk]: _, ...rest } = t; return rest }) + setTestError((t) => { const { [tk]: _, ...rest } = t; return rest }) + }, 4000) + }} + disabled={tmState === "testing" || (!p.hasKey && !p.apiKey.trim())} + className={`shrink-0 text-[9px] px-1 py-0 rounded transition-colors ${ + !p.hasKey && !p.apiKey.trim() ? "opacity-0 group-hover:opacity-100 text-gray-300" : + tmState === "ok" ? "bg-emerald-100 text-emerald-700" : + tmState === "error" ? "bg-red-100 text-red-700" : + tmState === "testing" ? "bg-gray-100 text-gray-400 animate-pulse" : + "text-gray-400 hover:text-gray-700 opacity-0 group-hover:opacity-100" + }`} + title={tmErr || (p.apiKey.trim() ? "test connection" : p.hasKey ? "test connection" : "enter an API key first")} + > + {tmState === "ok" ? "OK" : tmState === "error" ? "FAIL" : tmState === "testing" ? "..." : "test"} + </button> + <button + type="button" + onClick={() => removeModel(name, m.id)} + className="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" + title="remove model" + > + <Trash2 size={11} /> + </button> + </div> + ) + })} + </div> + </div> + </div> + </div> + ) + })} + + {/* Preset provider shortcuts */} + <div className="flex flex-wrap items-center gap-1.5"> + {providerPresets.filter((p) => !draft.providers[p.name]).map((p) => ( + <button + key={p.name} + type="button" + onClick={() => { + setDraft((d) => { + if (!d || d.providers[p.name]) return d + return { + ...d, + providers: { + ...d.providers, + [p.name]: { + models: p.models.map((id) => ({ id, enabled: true })), + baseUrl: p.baseUrl, + apiKey: "", + keyDirty: false, + hasKey: false, + enabled: false, + } satisfies WorkspaceDraft["providers"][string], + }, + } + }) + }} + className="px-2 py-0.5 rounded border border-gray-200 bg-white text-[10px] text-gray-500 hover:text-gray-900 hover:border-gray-400 transition-colors" + title={`Add ${p.name} preset`} + > + + {p.name} + </button> + ))} + </div> + + {adding ? ( + <div className="flex items-center gap-2"> + <input + autoFocus + value={newName} + onChange={(e) => setNewName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) addProvider(); if (e.key === "Escape") setAdding(false) }} + placeholder="provider name" + className={cn(inputClass, "flex-1")} + /> + <button onClick={addProvider} className="px-2.5 h-7 rounded bg-gray-900 text-white text-xs hover:bg-gray-700">add</button> + <button onClick={() => { setAdding(false); setNewName("") }} className="text-xs text-gray-400 hover:text-gray-700">cancel</button> + </div> + ) : ( + <button + onClick={() => setAdding(true)} + className="self-start text-xs text-gray-500 hover:text-gray-900 inline-flex items-center gap-1" + > + <Plus size={12} /> add custom provider + </button> + )} + + <div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-gray-100"> + {err && <span className="text-xs text-red-600">{err}</span>} + {saved && !err && <span className="text-xs text-emerald-700 flex items-center gap-1"><Check size={12} /> saved</span>} + <Button size="sm" onClick={save} disabled={saving}> + {saving ? "saving…" : "save providers"} + </Button> + </div> + </div> + ) +} + +function Labeled({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) { + return ( + <label className={`flex flex-col gap-1 ${className ?? ""}`}> + <span className="text-[11px] font-medium text-gray-500">{label}</span> + {children} + </label> + ) +} + +// ── Workspace Serve panel ── + +export function ServePanel() { + // Standard serve + const [serveEnabled, setServeEnabled] = useState(true) + const [domain, setDomainState] = useState("") + const [ip, setServeIp] = useState("") + const [baseUrl, setServeBaseUrl] = useState("") + const [withPort, setServeWithPort] = useState(false) + const [https, setServeHttps] = useState(false) + const [displayPort, setServeDisplayPort] = useState(7788) + // Dynamic port + const [dynEnabled, setDynEnabled] = useState(false) + const [dynDomain, setDynDomain] = useState("") + const [dynPortRange, setDynPortRange] = useState("10000-20000") + const [dynUdpEnabled, setDynUdpEnabled] = useState(false) + const [dynStaticEnabled, setDynStaticEnabled] = useState(false) + // Ephemeral port + const [ephEnabled, setEphEnabled] = useState(false) + const [ephDomain, setEphDomain] = useState("") + // UI + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + const [saved, setSaved] = useState(false) + + useEffect(() => { + getServeConfig().then((d) => { + setServeEnabled(d.serveEnabled) + setDomainState(d.domain) + setServeIp(d.ip) + setServeBaseUrl(d.baseUrl) + setServeWithPort(d.withPort ?? false) + setServeHttps(d.https ?? false) + setServeDisplayPort(d.displayPort ?? 7788) + setDynEnabled(d.serveDynamicEnabled) + setDynDomain(d.serveDynamicDomain) + setDynPortRange(d.serveDynamicPortRange) + setDynUdpEnabled(d.serveDynamicUdpEnabled) + setDynStaticEnabled(d.serveDynamicStaticEnabled) + setEphEnabled(d.serveEphemeralEnabled ?? false) + setEphDomain(d.serveEphemeralDomain ?? "") + }).catch((e) => { + setError(e?.message ?? "load failed") + }) + }, []) + + async function handleSave() { + setSaving(true); setError("") + try { + const ok = await setServeConfig({ + serveEnabled, + domain: domain.trim(), + withPort, + https, + displayPort, + serveDynamicEnabled: dynEnabled, + serveDynamicDomain: dynDomain.trim(), + serveDynamicPortRange: dynPortRange.trim(), + serveDynamicUdpEnabled: dynUdpEnabled, + serveDynamicStaticEnabled: dynStaticEnabled, + serveEphemeralEnabled: ephEnabled, + serveEphemeralDomain: ephDomain.trim(), + }) + if (!ok) { setError("save failed"); return } + setSaved(true) + setTimeout(() => setSaved(false), 2500) + const d = await getServeConfig() + setServeEnabled(d.serveEnabled) + setDomainState(d.domain) + setServeIp(d.ip) + setServeBaseUrl(d.baseUrl) + setServeWithPort(d.withPort) + setServeHttps(d.https) + setServeDisplayPort(d.displayPort) + setDynEnabled(d.serveDynamicEnabled) + setDynDomain(d.serveDynamicDomain) + setDynPortRange(d.serveDynamicPortRange) + setDynUdpEnabled(d.serveDynamicUdpEnabled) + setDynStaticEnabled(d.serveDynamicStaticEnabled) + setEphEnabled(d.serveEphemeralEnabled ?? false) + setEphDomain(d.serveEphemeralDomain ?? "") + } catch (e: any) { + setError(e?.message ?? "save failed") + } finally { + setSaving(false) + } + } + + return ( + <div className="flex flex-col gap-6 min-h-full"> + {/* ── Standard Serve ── */} + <section> + <div className="flex items-center justify-between mb-3"> + <h3 className="text-sm font-semibold text-gray-800">Standard Serve</h3> + <Switch checked={serveEnabled} onCheckedChange={setServeEnabled} size="sm" /> + </div> + <p className="text-xs text-gray-400 mb-3"> + Subdomain-based sharing via <code className="bg-gray-50 px-1 rounded">alias.domain</code> URLs. + </p> + + {serveEnabled && ( + <div className="flex flex-col gap-3 pl-1"> + <div> + <label className="block text-xs font-medium text-gray-600 mb-1">Domain Suffix</label> + <input type="text" value={domain} onChange={(e) => setDomainState(e.target.value)} placeholder="nip.io" className={inputClass} /> + <p className="text-[11px] text-gray-400 mt-0.5"> + URL: <code className="bg-gray-50 px-1 rounded">&lt;alias&gt;{baseUrl}</code> + </p> + </div> + <div className="flex items-center gap-4"> + <label className="flex items-center gap-2 text-xs text-gray-700"> + <Switch checked={https} onCheckedChange={setServeHttps} size="sm" /> HTTPS + </label> + <label className="flex items-center gap-2 text-xs text-gray-700"> + <Switch checked={withPort} onCheckedChange={setServeWithPort} size="sm" /> Show port in URL + </label> + </div> + {withPort && ( + <div> + <label className="block text-xs font-medium text-gray-600 mb-1">Display Port</label> + <input type="number" value={displayPort} onChange={(e) => setServeDisplayPort(parseInt(e.target.value, 10) || 7788)} placeholder="7788" className={inputClass} /> + </div> + )} + </div> + )} + </section> + + {/* ── Dynamic Port ── */} + <section className="border-t border-gray-200 pt-5"> + <div className="flex items-center justify-between mb-3"> + <h3 className="text-sm font-semibold text-gray-800">Dynamic Port</h3> + <Switch checked={dynEnabled} onCheckedChange={setDynEnabled} size="sm" /> + </div> + <p className="text-xs text-gray-400 mb-3"> + Direct TCP/UDP port forwarding — no domain needed. Access via <code className="bg-gray-50 px-1 rounded">host:port</code>. + </p> + + {dynEnabled && ( + <div className="flex flex-col gap-3 pl-1"> + <div> + <label className="block text-xs font-medium text-gray-600 mb-1">Domain / IP (optional)</label> + <input type="text" value={dynDomain} onChange={(e) => setDynDomain(e.target.value)} placeholder={ip || "auto-detect IP"} className={inputClass} /> + <p className="text-[11px] text-gray-400 mt-0.5"> + Leave empty to auto-detect. Access URL: <code className="bg-gray-50 px-1 rounded">{(dynDomain || ip || "&lt;ip&gt;")}:&lt;external-port&gt;</code> + </p> + </div> + <div> + <label className="block text-xs font-medium text-gray-600 mb-1">Port Range</label> + <input type="text" value={dynPortRange} onChange={(e) => setDynPortRange(e.target.value)} placeholder="10000-20000" className={inputClass} /> + </div> + <div className="flex items-center gap-4"> + <label className="flex items-center gap-2 text-xs text-gray-700"> + <Switch checked={dynUdpEnabled} onCheckedChange={setDynUdpEnabled} size="sm" /> Enable UDP + </label> + <label className="flex items-center gap-2 text-xs text-gray-700"> + <Switch checked={dynStaticEnabled} onCheckedChange={setDynStaticEnabled} size="sm" /> Enable static file serving + </label> + </div> + </div> + )} + </section> + + {/* ── Ephemeral Port ── */} + <section className="border-t border-gray-200 pt-5"> + <div className="flex items-center justify-between mb-3"> + <h3 className="text-sm font-semibold text-gray-800">Ephemeral Port</h3> + <Switch checked={ephEnabled} onCheckedChange={setEphEnabled} size="sm" /> + </div> + <p className="text-xs text-gray-400 mb-3"> + Per-loop random host port via <code className="bg-gray-50 px-1 rounded">podman -p :inner</code>. + The kernel picks an unused port on each loop container start — the URL changes after every restart. + Recommended when you don't need a stable URL and want zero port-conflict risk. + </p> + + {ephEnabled && ( + <div className="flex flex-col gap-3 pl-1"> + <div> + <label className="block text-xs font-medium text-gray-600 mb-1">Domain / IP (optional)</label> + <input type="text" value={ephDomain} onChange={(e) => setEphDomain(e.target.value)} placeholder={ip || "auto-detect IP"} className={inputClass} /> + <p className="text-[11px] text-gray-400 mt-0.5"> + Leave empty to auto-detect. Access URL: <code className="bg-gray-50 px-1 rounded">{(ephDomain || ip || "&lt;ip&gt;")}:&lt;random&gt;</code> + </p> + </div> + </div> + )} + </section> + + <div className="flex items-center justify-end gap-3 pt-2 border-t border-gray-200"> + {error && <span className="text-xs text-red-500">{error}</span>} + {saved && !error && <span className="text-xs text-emerald-700 flex items-center gap-1"><Check size={12} /> saved</span>} + <Button onClick={handleSave} disabled={saving}> + {saving ? "Saving…" : "Save"} + </Button> + </div> + </div> + ) +} diff --git a/web/src/components/dialog/NewLoopDialog.tsx b/web/src/components/dialog/NewLoopDialog.tsx new file mode 100644 index 00000000..98e7e750 --- /dev/null +++ b/web/src/components/dialog/NewLoopDialog.tsx @@ -0,0 +1,311 @@ +/** + * New Loop dialog. Post-2026-05 profile model: pick zero or more profiles + * from the workspace (`context/profiles/<name>/`). `base` is implicit and + * shown as "always on". Empty selection still works (base + personal only). + * + * See docs/composition.md for the model. + */ +import { useEffect, useRef, useState, type FormEvent } from "react" +import { getDefaultProfiles, getLoopStats, listProfiles, getContextRepos, listVaults, type LoopStats, type ProfileEntry, type ContextRepoSpec } from "../../api" + +export function NewLoopDialog({ + onClose, + onCreate, + initialTitle, +}: { + onClose: () => void + onCreate: (opts: { title: string; repo?: string; profiles?: string[]; vault?: string }) => Promise<string> | string + initialTitle?: string +}) { + const [title, setTitle] = useState(initialTitle ?? "") + const [repo, setRepo] = useState("") + const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set()) + const [defaultProfileNames, setDefaultProfileNames] = useState<string[]>([]) + const [vault, setVault] = useState("default") + const [repos, setRepos] = useState<ContextRepoSpec[]>([]) + const [profiles, setProfiles] = useState<ProfileEntry[]>([]) + const [vaults, setVaults] = useState<string[]>([]) + const [stats, setStats] = useState<LoopStats | null>(null) + const [statsLoading, setStatsLoading] = useState(false) + const [busy, setBusy] = useState(false) + const inputRef = useRef<HTMLInputElement>(null) + + useEffect(() => { + getContextRepos().then((r) => setRepos(r.repos)) + // Pre-check the user's default_profiles from personal config (diff style: + // dialog opens with the user's typical setup, they add/remove from there). + Promise.all([listProfiles(), getDefaultProfiles()]).then(([allProfiles, defaults]) => { + setProfiles(allProfiles) + setDefaultProfileNames(defaults) + // Only pre-check defaults that actually exist as profiles in the workspace + const available = new Set(allProfiles.map((p) => p.name)) + setSelectedProfiles(new Set(defaults.filter((n) => available.has(n) && n !== "base"))) + }) + listVaults().then((vs) => { + setVaults(vs) + if (vs.includes("default")) setVault("default") + else if (vs.length > 0) setVault(vs[0]) + }) + inputRef.current?.focus() + }, []) + + function resetToDefaults() { + const available = new Set(profiles.map((p) => p.name)) + setSelectedProfiles(new Set(defaultProfileNames.filter((n) => available.has(n) && n !== "base"))) + } + + // Re-fetch loop stats when the selection changes (debounced so toggling + // multiple checkboxes in quick succession only fires once). + useEffect(() => { + setStatsLoading(true) + const t = setTimeout(() => { + getLoopStats([...selectedProfiles]) + .then(setStats) + .finally(() => setStatsLoading(false)) + }, 120) + return () => clearTimeout(t) + }, [selectedProfiles]) + + const isDirtyFromDefaults = (() => { + const defaults = new Set(defaultProfileNames.filter((n) => n !== "base")) + if (defaults.size !== selectedProfiles.size) return true + for (const d of defaults) if (!selectedProfiles.has(d)) return true + return false + })() + + // base is implicit (always-on per server). Show it but make it non-toggleable. + const baseEntry = profiles.find((p) => p.name === "base") + const nonBase = profiles.filter((p) => p.name !== "base") + + function toggleProfile(name: string) { + setSelectedProfiles((prev) => { + const next = new Set(prev) + if (next.has(name)) next.delete(name) + else next.add(name) + return next + }) + } + + const submit = async (e: FormEvent) => { + e.preventDefault() + if (busy) return + setBusy(true) + try { + await onCreate({ + title: title.trim() || "untitled", + repo: repo || undefined, + profiles: selectedProfiles.size > 0 ? [...selectedProfiles] : undefined, + vault: vault || undefined, + }) + } finally { + setBusy(false) + } + } + + return ( + <div + className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center" + onClick={onClose} + > + <div + className="w-full max-w-[480px] mx-4 bg-white rounded-md shadow-xl border border-gray-200 p-4 md:p-5 max-h-[85vh] overflow-y-auto" + onClick={(e) => e.stopPropagation()} + > + <div className="text-base font-semibold text-gray-900 mb-4">New loop</div> + <form onSubmit={submit} className="flex flex-col gap-4"> + <DialogField label="Repo" hint="Sets the workdir. Optional — leave (none) for an empty workdir."> + <select + value={repo} + onChange={(e) => setRepo(e.target.value)} + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded outline-none focus:border-gray-500 bg-white" + > + <option value="">(none — empty workdir)</option> + {repos.map((r) => ( + <option key={r.name} value={r.name}> + {r.name} + {r.git ? ` · ${r.git}` : ""} + </option> + ))} + </select> + {repos.length === 0 && ( + <div className="text-[11px] text-gray-400 mt-1"> + No repos in the roster. Add them on the Context → Repos page. + </div> + )} + </DialogField> + + <DialogField + label="Profiles" + hint="Each profile contributes plugins + a CLAUDE.md fragment. Empty = base + personal only." + > + <div className="border border-gray-300 rounded p-2 max-h-44 overflow-y-auto bg-white flex flex-col gap-1"> + {baseEntry && ( + <label className="flex items-start gap-2 px-1.5 py-1 rounded text-sm text-gray-500 cursor-not-allowed bg-gray-50"> + <input type="checkbox" checked disabled className="mt-0.5" /> + <span className="flex-1"> + <span className="font-medium">base</span> + <span className="ml-1 text-[11px] text-gray-400">(always on)</span> + {baseEntry.description && ( + <span className="block text-[11px] text-gray-400 mt-0.5">{baseEntry.description}</span> + )} + </span> + </label> + )} + {nonBase.map((p) => { + const checked = selectedProfiles.has(p.name) + const isDefault = defaultProfileNames.includes(p.name) + return ( + <label + key={p.name} + className={`flex items-start gap-2 px-1.5 py-1 rounded text-sm cursor-pointer hover:bg-gray-50 ${ + checked ? "bg-blue-50" : "" + }`} + > + <input + type="checkbox" + checked={checked} + onChange={() => toggleProfile(p.name)} + className="mt-0.5" + /> + <span className="flex-1"> + <span className="font-medium text-gray-900">{p.name}</span> + {isDefault && ( + <span + className="ml-1.5 px-1 py-px text-[10px] rounded bg-gray-200 text-gray-600" + title="In your default_profiles (personal config)" + > + default + </span> + )} + {p.description && ( + <span className="block text-[11px] text-gray-500 mt-0.5">{p.description}</span> + )} + </span> + </label> + ) + })} + {nonBase.length === 0 && !baseEntry && ( + <div className="text-[11px] text-gray-400 p-1.5"> + No profiles yet. Add them under{" "} + <code className="bg-gray-100 px-1 rounded">knowledge/.loopat/profiles/&lt;name&gt;/.claude/</code>{" "} + in your knowledge repo. + </div> + )} + </div> + <div className="flex items-center justify-between mt-1"> + <span className="text-[11px] text-gray-500"> + {selectedProfiles.size > 0 + ? `Selected: ${[...selectedProfiles].join(", ")}` + : "No profiles selected (base + personal only)"} + </span> + {isDirtyFromDefaults && defaultProfileNames.length > 0 && ( + <button + type="button" + onClick={resetToDefaults} + className="text-[11px] text-gray-500 hover:text-gray-900 underline" + title="Restore default_profiles from your personal config" + > + reset to defaults + </button> + )} + </div> + + {/* Loop preview: total contributions across team + selected profiles. + Includes plugin internals (skills/agents/MCPs from each enabled plugin's dir). */} + <div className="mt-1.5 px-1.5 py-1 text-[11px] text-gray-600 bg-gray-50 border border-gray-200 rounded"> + {statsLoading && !stats ? ( + <span className="text-gray-400">computing…</span> + ) : stats ? ( + <span title="Totals across team + selected profiles + their plugins (deduped by name)"> + <span className={stats.plugins > 0 ? "" : "text-gray-400"}>{stats.plugins} plugins</span> + <span className="mx-1 text-gray-300">·</span> + <span className={stats.skills > 0 ? "" : "text-gray-400"}>{stats.skills} skills</span> + <span className="mx-1 text-gray-300">·</span> + <span className={stats.agents > 0 ? "" : "text-gray-400"}>{stats.agents} agents</span> + <span className="mx-1 text-gray-300">·</span> + <span className={stats.hooks > 0 ? "" : "text-gray-400"}>{stats.hooks} hooks</span> + <span className="mx-1 text-gray-300">·</span> + <span className={stats.mcpServers > 0 ? "" : "text-gray-400"}>{stats.mcpServers} MCP servers</span> + <span className="mx-1 text-gray-300">·</span> + <span className={stats.toolchain > 0 ? "" : "text-gray-400"} title="mise.toml [tools] entries, deduped across team + selected profiles">{stats.toolchain} toolchain</span> + {statsLoading && <span className="ml-1.5 text-gray-400">·</span>} + {statsLoading && <span className="ml-1 text-gray-400">updating…</span>} + </span> + ) : ( + <span className="text-gray-400">—</span> + )} + </div> + </DialogField> + + <DialogField label="Vault" hint="Which credential set to inject. Only this vault is visible inside the loop."> + <select + value={vault} + onChange={(e) => setVault(e.target.value)} + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded outline-none focus:border-gray-500 bg-white" + > + {vaults.length === 0 && <option value="default">default</option>} + {vaults.map((v) => ( + <option key={v} value={v}> + {v} + </option> + ))} + </select> + {vaults.length === 0 && ( + <div className="text-[11px] text-gray-400 mt-1"> + No vaults yet — using default. Create more under{" "} + <code className="bg-gray-100 px-1 rounded">personal/.loopat/vaults/&lt;name&gt;/</code>{" "} + to isolate prod / test credentials. + </div> + )} + </DialogField> + + <DialogField label="Name" hint="Optional — defaults to 'untitled'."> + <input + ref={inputRef} + type="text" + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="refactor-gateway" + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded outline-none focus:border-gray-500" + /> + </DialogField> + + <div className="flex justify-end gap-2 mt-2 pt-3 border-t border-gray-100"> + <button + type="button" + onClick={onClose} + className="px-3 h-8 text-sm rounded text-gray-700 hover:bg-gray-100" + > + cancel + </button> + <button + type="submit" + disabled={busy} + className="px-3 h-8 text-sm rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50" + > + {busy ? "creating…" : "create"} + </button> + </div> + </form> + </div> + </div> + ) +} + +function DialogField({ + label, + hint, + children, +}: { + label: string + hint?: string + children: React.ReactNode +}) { + return ( + <label className="flex flex-col gap-1"> + <span className="text-xs text-gray-700 font-medium">{label}</span> + {children} + {hint && <span className="text-[11px] text-gray-400">{hint}</span>} + </label> + ) +} diff --git a/web/src/components/dialog/PersonalRepoPanel.tsx b/web/src/components/dialog/PersonalRepoPanel.tsx new file mode 100644 index 00000000..2a72dd9c --- /dev/null +++ b/web/src/components/dialog/PersonalRepoPanel.tsx @@ -0,0 +1,1374 @@ +import { useEffect, useState } from "react" +import { + deletePersonalVault, + exportPersonalCryptKey, + getPersonalStatus, + importPersonal, + setupPersonalGithub, + listPersonalRepos, + pullPersonalVault, + pushPersonalVault, + type PersonalStatus, +} from "@/api" +import { ArrowUp, ArrowDown, AlertTriangle, Check, X } from "lucide-react" + +/** + * Robust clipboard copy. navigator.clipboard is undefined in non-secure + * contexts (e.g. dev:host accessed over http://<ip>:port), which silently + * broke the "copy" buttons. Fall back to a hidden textarea + execCommand, + * which works there too. + */ +async function copyText(text: string): Promise<boolean> { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text) + return true + } + } catch {} + try { + const ta = document.createElement("textarea") + ta.value = text + ta.style.position = "fixed" + ta.style.left = "-9999px" + document.body.appendChild(ta) + ta.focus() + ta.select() + const ok = document.execCommand("copy") + document.body.removeChild(ta) + return ok + } catch { + return false + } +} + +/** + * Personal-repo deploy-key flow, rendered as a settings panel. + * + * UX model: + * - Default: user pastes deploy key into their (empty) GitHub repo, pastes + * the repo URL here, hits Continue. Server clones, runs git-crypt init, + * pushes the scaffold, hands the freshly-generated key back to us. We + * show that key once, force the user to acknowledge they've backed it up, + * and then we're done. User never touches the git-crypt CLI. + * - Recovery: a collapsed "I already have a git-crypt key" section. Pasting + * a base64 key there flips to BYOK — server clones and runs + * `git-crypt unlock` instead of `init`. Used for re-importing the same + * repo onto a new host, or when host-secrets/ is lost. + * + * Hard guarantee from the server: import refuses unless the repo is a + * "clean slate" — no git-crypt config and no tracked secrets. Anything else + * the user has to fix outside loopat (rotate + fresh repo). + */ +export function PersonalRepoPanel({ onDone }: { onDone?: () => void } = {}) { + const [status, setStatus] = useState<PersonalStatus | null>(null) + const [loading, setLoading] = useState(true) + const [repoUrl, setRepoUrl] = useState("") + const [recoveryOpen, setRecoveryOpen] = useState(false) + const [cryptKey, setCryptKey] = useState("") + const [error, setError] = useState<string | null>(null) + const [notClean, setNotClean] = useState(false) + const [exposedFiles, setExposedFiles] = useState<string[]>([]) + const [busy, setBusy] = useState(false) + const [ghToken, setGhToken] = useState("") + const [ghRepoName, setGhRepoName] = useState("") + const [ghCryptKey, setGhCryptKey] = useState("") + const [ghNeedsCryptKey, setGhNeedsCryptKey] = useState(false) + const [ghRepos, setGhRepos] = useState<{ name: string; path: string }[]>([]) + const [ghBusy, setGhBusy] = useState(false) + const [ghError, setGhError] = useState<string | null>(null) + const [copiedPub, setCopiedPub] = useState(false) + const [backupKey, setBackupKey] = useState<string | null>(null) + const [backupCopied, setBackupCopied] = useState(false) + const [backupAck, setBackupAck] = useState(false) + const [step, setStep] = useState<"token" | "repo" | "confirm">("token") + const [ghLogin, setGhLogin] = useState("") + + useEffect(() => { + setError(null) + setNotClean(false) + setExposedFiles([]) + setCryptKey("") + setRecoveryOpen(false) + setBackupKey(null) + setBackupAck(false) + setLoading(true) + getPersonalStatus() + .then((s) => { + setStatus(s) + setRepoUrl(s?.personalRepo ?? "") + setGhRepoName(s?.gitHost?.defaultRepo ?? "loopat-personal") + }) + .finally(() => setLoading(false)) + }, []) + + const copyPub = async () => { + if (!status?.publicKey) return + if (await copyText(status.publicKey)) { + setCopiedPub(true) + setTimeout(() => setCopiedPub(false), 1500) + } + } + + const copyBackup = async () => { + if (!backupKey) return + if (await copyText(backupKey)) { + setBackupCopied(true) + setTimeout(() => setBackupCopied(false), 1500) + } + } + + const submit = async () => { + if (busy) return + setError(null) + setNotClean(false) + setExposedFiles([]) + setBusy(true) + try { + const url = repoUrl.trim() || undefined + const key = recoveryOpen && cryptKey.trim() ? cryptKey.trim() : undefined + const r = await importPersonal(url, key) + if (!r.ok) { + setError(r.error ?? "import failed") + if (r.notClean) setNotClean(true) + if (r.secretsExposed) setExposedFiles(r.exposedFiles ?? []) + return + } + if (r.autoInitialized && r.cryptKey) { + // Force the backup acknowledgement screen — user can't dismiss this + // until they tick "I've saved it" + setBackupKey(r.cryptKey) + } else { + // Recovery path: nothing new to reveal, just close + const fresh = await getPersonalStatus() + setStatus(fresh) + } + } finally { + setBusy(false) + } + } + + // GitHub token path: loopat creates the repo, registers the deploy key, and + // clones + git-crypts it — no manual repo creation or deploy-key pasting. + const submitGithub = async () => { + if (ghBusy) return + setGhError(null) + setGhBusy(true) + try { + const r = await setupPersonalGithub(ghToken.trim(), ghRepoName.trim() || undefined, ghCryptKey.trim() || undefined) + if (!r.ok) { + if (r.needsCryptKey) { + setGhNeedsCryptKey(true) + setGhError("This repo already has a .loopat vault — paste its git-crypt key to unlock it.") + } else { + setGhError(r.error ?? "setup failed") + } + return + } + if (r.autoInitialized && r.cryptKey) { + setBackupKey(r.cryptKey) + } else { + const fresh = await getPersonalStatus() + setStatus(fresh) + } + } finally { + setGhBusy(false) + } + } + + // Wizard nav: token step → fetch the user's repos → repo-picker step. + const goToRepo = async () => { + if (ghBusy || !ghToken.trim()) return + setGhError(null) + setGhBusy(true) + try { + const res = await listPersonalRepos(ghToken.trim()) + if (!res.ok) { + // bad token → stay on the token step and show the error, instead of + // advancing to a misleading empty repo picker + setGhError(res.error ?? "invalid token") + return + } + setGhRepos(res.repos) + if (res.login) setGhLogin(res.login) + setStep("repo") + } finally { + setGhBusy(false) + } + } + + const ghProvider = status?.gitHost?.provider ?? "github" + const ghProviderLabel = ghProvider === "github" ? "GitHub" : ghProvider + const ghRepoExists = ghRepos.some((r) => r.name === ghRepoName.trim()) + const ghRepoWebUrl = + status?.gitHost?.baseUrl && ghLogin && ghRepoName.trim() + ? `${status.gitHost.baseUrl.replace(/\/+$/, "")}/${ghLogin}/${ghRepoName.trim()}` + : null + + if (loading) { + return <div className="text-sm text-gray-400 py-8 text-center">loading…</div> + } + if (!status) { + return <div className="text-sm text-red-600">failed to load status</div> + } + + // ── Backup screen (auto-init succeeded; force ack before closing) ── + if (backupKey) { + return ( + <div className="flex flex-col gap-3"> + <div className="flex flex-col items-center text-center gap-2 py-1"> + <div className="flex items-center justify-center w-10 h-10 rounded-full bg-emerald-100"> + <Check size={20} className="text-emerald-600" /> + </div> + <div className="text-sm font-semibold text-gray-900">Your personal repo is ready 🎉</div> + </div> + <div className="text-sm font-semibold text-gray-900">One last thing — back up your git-crypt key</div> + <div className="text-xs text-gray-600 leading-relaxed"> + The symmetric key below decrypts everything under{" "} + <code className="text-[11px] bg-gray-100 px-1 rounded">.loopat/vaults/</code>. It's already + stored on this host, but <b>save your own copy</b> — if this host dies and you don't have + it, your secrets are unrecoverable. + </div> + <div className="relative"> + <textarea + readOnly + value={backupKey} + rows={4} + className="w-full text-[11px] font-mono text-gray-800 bg-gray-50 border border-gray-200 rounded p-2 outline-none resize-none break-all" + onClick={(e) => (e.target as HTMLTextAreaElement).select()} + /> + <button + type="button" + onClick={copyBackup} + className="absolute top-1.5 right-1.5 px-2 py-0.5 text-[11px] rounded bg-white border border-gray-300 text-gray-700 hover:bg-gray-100" + > + {backupCopied ? "copied" : "copy"} + </button> + </div> + <div className="text-[11px] text-gray-500 leading-relaxed bg-gray-50 border border-gray-200 rounded p-2"> + <b>Suggested:</b> stash this in your password manager (1Password / Bitwarden / Apple + Keychain). To later restore on a new host, paste this same value into the + Recovery field on the new host's Personal repo settings. + </div> + <div className="text-[11px] text-emerald-700 leading-relaxed bg-emerald-50 border border-emerald-200 rounded p-2"> + <b>Next:</b> if your providers need API keys, add them in{" "} + <b>Settings → Providers</b> to start chatting. + </div> + <label className="flex items-center gap-2 text-xs text-gray-700"> + <input + type="checkbox" + checked={backupAck} + onChange={(e) => setBackupAck(e.target.checked)} + className="accent-gray-900" + /> + I've saved this key somewhere safe. + </label> + <button + type="button" + onClick={async () => { + if (!backupAck) return + // Refresh our own status first so `imported` flips true and we land + // on ImportedPanel — otherwise we'd fall back to the stale wizard + // (still on step "confirm") because onDone only refreshes the parent. + const fresh = await getPersonalStatus() + setStatus(fresh) + setBackupKey(null) + if (onDone) onDone() + }} + disabled={!backupAck} + className="self-end px-3 h-8 text-sm rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-40" + > + Done + </button> + </div> + ) + } + + if (status.imported) { + return <ImportedPanel status={status} /> + } + + if (!status.publicKey) { + return ( + <div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-2 leading-relaxed"> + No deploy key available — the server is probably missing{" "} + <code className="text-[11px] bg-white px-1 rounded">openssh-client</code>. + Install it and reopen this panel. + </div> + ) + } + + const steps = ["token", "repo", "confirm"] as const + const stepIdx = steps.indexOf(step) + + return ( + <div className="flex flex-col gap-3"> + {/* wizard progress */} + <div className="flex items-center gap-2 text-[11px] mb-1"> + {[ + { k: "token", label: "Token" }, + { k: "repo", label: "Repository" }, + { k: "confirm", label: "Confirm" }, + ].map((s, i) => ( + <div key={s.k} className="flex items-center gap-2"> + <span + className={`flex items-center gap-1.5 ${ + step === s.k ? "text-gray-900 font-medium" : i < stepIdx ? "text-emerald-600" : "text-gray-400" + }`} + > + <span + className={`inline-flex items-center justify-center w-4 h-4 rounded-full text-[9px] ${ + step === s.k + ? "bg-gray-900 text-white" + : i < stepIdx + ? "bg-emerald-500 text-white" + : "bg-gray-200 text-gray-500" + }`} + > + {i < stepIdx ? "✓" : i + 1} + </span> + {s.label} + </span> + {i < 2 && <span className="text-gray-300">→</span>} + </div> + ))} + </div> + + {/* ── Step 1: token ── */} + {step === "token" && ( + <div className="flex flex-col gap-3"> + <div className="text-[11px] text-gray-500 leading-relaxed"> + Paste your {ghProviderLabel} token. Loopat will list your repos so you can + pick one (or create a new one), then set everything up with git-crypt — + no manual repo creation or deploy-key pasting. + </div> + <input + type="password" + value={ghToken} + onChange={(e) => setGhToken(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + void goToRepo() + } + }} + placeholder={`${ghProviderLabel} personal access / private token`} + autoComplete="off" + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded outline-none focus:border-gray-500" + /> + {status.gitHost?.tokenHelp && + (/^https?:\/\//.test(status.gitHost.tokenHelp) ? ( + <a + href={status.gitHost.tokenHelp} + target="_blank" + rel="noreferrer" + className="text-[11px] text-blue-600 hover:underline w-fit" + > + Where do I get a token? ↗ + </a> + ) : ( + <div className="text-[11px] text-gray-400 leading-relaxed">{status.gitHost.tokenHelp}</div> + ))} + {ghError && ( + <div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5">{ghError}</div> + )} + <button + type="button" + onClick={goToRepo} + disabled={ghBusy || !ghToken.trim()} + className="px-3 h-9 text-sm rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50" + > + {ghBusy ? "loading repos…" : "Next"} + </button> + + {ghProvider === "github" && ( + <> + <div className="text-[11px] text-gray-400 text-center">— or set up a deploy key manually —</div> + + <div className="text-xs text-gray-600 leading-relaxed"> + Two steps: + <ol className="list-decimal pl-5 mt-1 space-y-0.5 text-gray-600"> + <li> + Create a <b>fresh, empty</b> private GitHub repo (no README, no + <code className="text-[11px] bg-gray-100 px-1 rounded">.loopat/</code>). + </li> + <li> + Paste the public key below into that repo's{" "} + <i>Settings → Deploy keys → Add deploy key</i>, check{" "} + <b>Allow write access</b>, then put the repo URL here and Continue. + </li> + </ol> + <div className="mt-2 text-[11px] text-gray-500"> + Loopat will run <code className="bg-gray-100 px-1 rounded">git-crypt init</code>{" "} + inside your repo for you and push the encrypted scaffold. You don't need + git-crypt installed locally. + </div> + </div> + + <div className="relative"> + <textarea + readOnly + value={status.publicKey} + rows={3} + className="w-full text-[11px] font-mono text-gray-800 bg-gray-50 border border-gray-200 rounded p-2 outline-none resize-none" + onClick={(e) => (e.target as HTMLTextAreaElement).select()} + /> + <button + type="button" + onClick={copyPub} + className="absolute top-1.5 right-1.5 px-2 py-0.5 text-[11px] rounded bg-white border border-gray-300 text-gray-700 hover:bg-gray-100" + > + {copiedPub ? "copied" : "copy"} + </button> + </div> + + <label className="flex flex-col gap-1"> + <span className="text-xs text-gray-700 font-medium">Repo URL</span> + <input + type="text" + value={repoUrl} + onChange={(e) => setRepoUrl(e.target.value)} + placeholder="git@github.com:you/loopat-personal.git" + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded outline-none focus:border-gray-500" + /> + </label> + + {/* Recovery (BYOK) — collapsed by default */} + <div className="border border-gray-200 rounded"> + <button + type="button" + onClick={() => setRecoveryOpen((v) => !v)} + className="w-full flex justify-between items-center px-2.5 py-1.5 text-[11px] text-gray-600 hover:bg-gray-50" + > + <span>Recovery: I already have a git-crypt key for this repo</span> + <span className="text-gray-400">{recoveryOpen ? "▾" : "▸"}</span> + </button> + {recoveryOpen && ( + <div className="px-2.5 pb-2.5 text-[11px] text-gray-600 leading-relaxed border-t border-gray-200"> + <p className="mt-2"> + Use this if you're re-importing an existing loopat repo onto a new + host (or this host lost its host-secrets). The repo must already + have git-crypt configured. + </p> + <textarea + value={cryptKey} + onChange={(e) => setCryptKey(e.target.value)} + rows={3} + placeholder="base64 git-crypt key" + className="mt-2 w-full px-2 py-1.5 text-[11px] font-mono border border-gray-300 rounded outline-none focus:border-gray-500 resize-none" + /> + <p className="mt-1 text-gray-500"> + Paste the value you saved from "Back up your git-crypt key" on + another host (or the base64 of{" "} + <code className="bg-gray-100 px-1 rounded">git-crypt export-key</code>{" "} + if you originally set the repo up by hand). + </p> + </div> + )} + </div> + + {exposedFiles.length > 0 && ( + <div className="text-xs bg-red-50 border border-red-300 rounded p-2.5 leading-relaxed"> + <div className="font-semibold text-red-700 mb-1"> + ⚠️ Your secrets are exposed + </div> + <div className="text-red-700 mb-1.5"> + The following files appear in git as <b>plaintext</b>. Anyone with repo + access can already read them: + </div> + <ul className="font-mono text-[10.5px] text-red-800 bg-white/60 rounded p-1.5 max-h-24 overflow-auto"> + {exposedFiles.map((f) => ( + <li key={f}>{f}</li> + ))} + </ul> + <ol className="list-decimal pl-4 text-red-700 space-y-0.5 mt-2"> + <li> + <b>Rotate</b> the leaked secrets immediately — the old ones are burned. + </li> + <li>Create a fresh empty repo and point loopat there instead.</li> + </ol> + </div> + )} + + {notClean && exposedFiles.length === 0 && ( + <div className="text-xs bg-amber-50 border border-amber-300 rounded p-2.5 leading-relaxed text-amber-800"> + <div className="font-semibold mb-0.5">This repo isn't a clean slate</div> + <div>{error}</div> + <div className="mt-1.5 text-amber-700"> + Two options: point at a fresh empty repo, or — if it's your own + previous loopat repo — expand <i>Recovery</i> above and paste the + crypt key. + </div> + </div> + )} + + {error && !notClean && exposedFiles.length === 0 && ( + <div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5"> + {error} + </div> + )} + + <div className="flex gap-2 mt-1"> + <button + type="button" + onClick={submit} + disabled={busy || !repoUrl.trim()} + className="flex-1 px-3 h-9 text-sm rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50" + > + {busy + ? recoveryOpen && cryptKey.trim() + ? "unlocking…" + : "initializing…" + : "Continue"} + </button> + </div> + </> + )} + </div> + )} + + {/* ── Step 2: pick a repository ── */} + {step === "repo" && ( + <div className="flex flex-col gap-3"> + <div className="text-[11px] text-gray-500 leading-relaxed"> + Pick one of your repos, or type a new name to create one. Repos with + "personal" in the name are listed first. + </div> + <div className="flex flex-col gap-1 max-h-52 overflow-auto border border-gray-200 rounded p-1"> + {ghRepos.length === 0 && ( + <div className="text-[11px] text-gray-400 px-2 py-4 text-center"> + no existing repos found — type a name below to create one + </div> + )} + {ghRepos.map((r) => { + const sel = ghRepoName.trim() === r.name + return ( + <button + key={r.path} + type="button" + onClick={() => setGhRepoName(r.name)} + className={`flex items-center justify-between gap-2 px-2 py-1.5 rounded text-left text-xs ${ + sel ? "bg-gray-900 text-white" : "hover:bg-gray-100 text-gray-700" + }`} + > + <span className="flex items-center gap-1.5 min-w-0"> + {sel && <Check size={12} className="shrink-0" />} + <span className="truncate font-medium">{r.name}</span> + {r.name.includes("personal") && ( + <span + className={`text-[9px] px-1 rounded ${ + sel ? "bg-white/20" : "bg-emerald-100 text-emerald-700" + }`} + > + personal + </span> + )} + </span> + <span className={`text-[10px] truncate ${sel ? "text-white/60" : "text-gray-400"}`}> + {r.path} + </span> + </button> + ) + })} + </div> + <label className="flex flex-col gap-1"> + <span className="text-[11px] text-gray-500">or type a new repo name</span> + <div className="flex items-center gap-2"> + <input + type="text" + value={ghRepoName} + onChange={(e) => setGhRepoName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && ghRepoName.trim()) { + e.preventDefault() + setGhError(null) + setStep("confirm") + } + }} + placeholder="loopat-personal" + className="flex-1 px-2 py-1.5 text-sm border border-gray-300 rounded outline-none focus:border-gray-500" + /> + <span + className={`text-[11px] whitespace-nowrap ${ + ghRepoExists ? "text-gray-500" : "text-emerald-600" + }`} + > + {ghRepoName.trim() ? (ghRepoExists ? "use existing" : "will create") : ""} + </span> + </div> + </label> + <div className="flex gap-2 mt-1"> + <button + type="button" + onClick={() => { + setStep("token") + setGhError(null) + }} + className="px-3 h-9 text-sm rounded border border-gray-300 text-gray-700 hover:bg-gray-50" + > + Back + </button> + <button + type="button" + onClick={() => { + setGhError(null) + setStep("confirm") + }} + disabled={!ghRepoName.trim()} + className="flex-1 px-3 h-9 text-sm rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50" + > + Next + </button> + </div> + </div> + )} + + {/* ── Step 3: confirm what's about to happen ── */} + {step === "confirm" && ( + <div className="flex flex-col gap-3"> + <div className="border border-gray-200 rounded p-3 text-xs text-gray-700 leading-relaxed flex flex-col gap-2"> + {ghRepoExists ? ( + <> + <div className="font-semibold text-gray-900">Use existing repo</div> + <div> + Loopat will use your existing repo{" "} + <code className="text-[11px] bg-gray-100 px-1 rounded">{ghRepoName.trim()}</code> and + create a git-crypt-encrypted{" "} + <code className="text-[11px] bg-gray-100 px-1 rounded">.loopat/</code> vault inside it. + The repo should be a clean slate — if it already has a{" "} + <code className="text-[11px] bg-gray-100 px-1 rounded">.loopat</code> vault you'll be + asked for its git-crypt key. + </div> + </> + ) : ( + <> + <div className="font-semibold text-gray-900">Create new repo</div> + <div> + Loopat will create a new private {ghProviderLabel} repo named{" "} + <code className="text-[11px] bg-gray-100 px-1 rounded">{ghRepoName.trim()}</code>, then + initialize a git-crypt-encrypted{" "} + <code className="text-[11px] bg-gray-100 px-1 rounded">.loopat/</code> vault and push it. + Your secrets stay encrypted in git. + </div> + </> + )} + <div className="text-[11px] text-gray-500"> + You'll get a one-time git-crypt key to back up right after. + </div> + {ghRepoWebUrl && ( + <a + href={ghRepoWebUrl} + target="_blank" + rel="noreferrer" + className="text-[11px] text-blue-600 hover:underline break-all w-fit" + > + {ghRepoWebUrl} ↗ + </a> + )} + </div> + {ghNeedsCryptKey && ( + <textarea + value={ghCryptKey} + onChange={(e) => setGhCryptKey(e.target.value)} + rows={3} + placeholder="git-crypt key — this repo already has a .loopat vault, paste its key to unlock" + className="w-full px-2 py-1.5 text-[11px] font-mono border border-gray-300 rounded outline-none focus:border-gray-500 resize-none" + /> + )} + {ghError && ( + <div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5">{ghError}</div> + )} + <div className="flex gap-2 mt-1"> + <button + type="button" + onClick={() => { + setStep("repo") + setGhError(null) + }} + disabled={ghBusy} + className="px-3 h-9 text-sm rounded border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:opacity-50" + > + Back + </button> + <button + type="button" + onClick={submitGithub} + disabled={ghBusy} + className="flex-1 px-3 h-9 text-sm rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50" + > + {ghBusy ? "setting up…" : ghRepoExists ? "Use this repo" : "Create & set up"} + </button> + </div> + </div> + )} + </div> + ) +} + +/** + * Imported state — summary + two destructive-ish actions, both gated behind + * a password re-prompt (session cookie alone is not enough): + * + * - "Show / export git-crypt key" — reveals the key for off-host backup + * or for the Recovery flow on another host. + * - "Delete personal vault" — wipes personal/<user>/ and host-secrets/ + * git-crypt.key. Tries to sync to remote first; if sync fails, requires + * three independent acknowledgements that data will be lost. + */ +function ImportedPanel({ status }: { status: PersonalStatus }) { + type Action = null | "export" | "delete" | "pull" | "push" | "showkey" + const [action, setAction] = useState<Action>(null) + const [pullResult, setPullResult] = useState<{ ok: boolean; error?: string; conflict?: boolean; files?: string[]; needsStash?: boolean } | null>(null) + const [pushResult, setPushResult] = useState<{ ok: boolean; error?: string; conflict?: boolean; files?: string[]; needsPull?: boolean } | null>(null) + + const handlePull = async () => { + setAction("pull") + setPullResult(null) + const r = await pullPersonalVault() + setPullResult(r) + } + + const handlePush = async () => { + setAction("push") + setPushResult(null) + const r = await pushPersonalVault() + setPushResult(r) + } + + return ( + <div className="flex flex-col gap-3"> + <div className="text-sm text-gray-700"> + Imported{" "} + <code className="text-[11px] bg-gray-100 px-1 py-0.5 rounded"> + {status.personalRepo ?? "(unknown remote)"} + </code> + . + </div> + <div className="text-[11px] text-gray-400 leading-relaxed"> + Sync your personal vault with the remote repo. + </div> + + {action === "export" ? ( + <ExportKeyFlow onDone={() => setAction(null)} /> + ) : action === "delete" ? ( + <DeleteVaultFlow status={status} onDone={() => setAction(null)} /> + ) : action === "pull" ? ( + <PullPushResultFlow + type="pull" + result={pullResult} + onDone={() => setAction(null)} + onRetry={handlePull} + /> + ) : action === "push" ? ( + <PullPushResultFlow + type="push" + result={pushResult} + onDone={() => setAction(null)} + onRetry={handlePush} + /> + ) : action === "showkey" ? ( + <ShowPublicKeyFlow vaultKeys={status.vaultKeys ?? []} onDone={() => setAction(null)} /> + ) : ( + <div className="flex flex-col gap-2"> + {/* Pull/Push buttons */} + <div className="flex gap-2"> + <button + type="button" + onClick={handlePull} + className="flex-1 flex items-center justify-center gap-1.5 px-2.5 py-1.5 text-[11px] text-gray-700 border border-gray-200 rounded hover:bg-gray-50" + > + <ArrowDown size={12} /> + Pull + </button> + <button + type="button" + onClick={handlePush} + className="flex-1 flex items-center justify-center gap-1.5 px-2.5 py-1.5 text-[11px] text-gray-700 border border-gray-200 rounded hover:bg-gray-50" + > + <ArrowUp size={12} /> + Push + </button> + </div> + {(status.vaultKeys?.length ?? 0) > 0 && ( + <button + type="button" + onClick={() => setAction("showkey")} + className="w-full text-left px-2.5 py-1.5 text-[11px] text-gray-700 border border-gray-200 rounded hover:bg-gray-50" + > + Show vault SSH keys (for team repos) + </button> + )} + <button + type="button" + onClick={() => setAction("export")} + className="w-full text-left px-2.5 py-1.5 text-[11px] text-gray-700 border border-gray-200 rounded hover:bg-gray-50" + > + Show / export git-crypt key (requires password) + </button> + <button + type="button" + onClick={() => setAction("delete")} + className="w-full text-left px-2.5 py-1.5 text-[11px] text-red-700 border border-red-200 rounded hover:bg-red-50" + > + Delete personal vault (requires password) + </button> + </div> + )} + </div> + ) +} + +function ForcePullButton({ onRetry }: { onRetry: () => void }) { + const [confirming, setConfirming] = useState(false) + const [forcePulling, setForcePulling] = useState(false) + + const handleForcePull = async () => { + setForcePulling(true) + const r = await pullPersonalVault({ force: true }) + setForcePulling(false) + setConfirming(false) + // Replace the parent's result with the force pull result + if (r.ok) { + onRetry() // Let parent refresh — the successful pull result will show + } + } + + if (forcePulling) { + return ( + <button disabled className="flex-1 px-3 h-8 text-sm rounded bg-red-700 text-white opacity-50"> + Force pulling… + </button> + ) + } + + if (confirming) { + return ( + <div className="flex gap-2"> + <button + onClick={handleForcePull} + className="flex-1 px-3 h-8 text-sm rounded bg-red-700 text-white hover:bg-red-800" + > + Confirm — discard all local changes + </button> + <button + onClick={() => setConfirming(false)} + className="px-3 h-8 text-sm rounded border border-gray-200 text-gray-600 hover:bg-gray-100" + > + Cancel + </button> + </div> + ) + } + + return ( + <div className="flex gap-2"> + <button + onClick={() => setConfirming(true)} + className="flex-1 px-3 h-8 text-sm rounded bg-amber-600 text-white hover:bg-amber-700" + > + Discard changes & force pull + </button> + <button + onClick={onRetry} + className="px-3 h-8 text-sm rounded bg-red-700 text-white hover:bg-red-800" + > + Retry + </button> + </div> + ) +} + +function PullPushResultFlow({ + type, + result, + onDone, + onRetry, +}: { + type: "pull" | "push" + result: { ok: boolean; error?: string; conflict?: boolean; files?: string[]; needsStash?: boolean; needsPull?: boolean } | null + onDone: () => void + onRetry: () => void +}) { + const [showConflicts, setShowConflicts] = useState(false) + + if (!result) { + return ( + <div className="flex flex-col gap-2 border border-gray-200 rounded p-2.5"> + <div className="text-xs font-semibold text-gray-900"> + {type === "pull" ? "Pulling from remote..." : "Pushing to remote..."} + </div> + </div> + ) + } + + if (result.ok) { + return ( + <div className="flex flex-col gap-2 border border-emerald-200 bg-emerald-50 rounded p-2.5"> + <div className="flex items-center gap-2 text-sm font-semibold text-emerald-800"> + <Check size={14} /> + {type === "pull" ? "Pulled successfully" : "Pushed successfully"} + </div> + <div className="text-xs text-emerald-700 leading-relaxed"> + {type === "pull" + ? "Your local vault is now up to date with the remote." + : "Your changes have been pushed to the remote."} + </div> + <button + type="button" + onClick={onDone} + className="self-end px-3 h-8 text-sm rounded bg-emerald-700 text-white hover:bg-emerald-800" + > + Done + </button> + </div> + ) + } + + // Held-back conflict (rebase couldn't auto-merge) — can happen on pull or push. + const hasConflict = (result.conflict ?? false) && (result.files?.length ?? 0) > 0 + const needsPull = type === "push" && result.needsPull + + return ( + <div className="flex flex-col gap-2 border border-red-200 bg-red-50 rounded p-2.5"> + <div className="flex items-center gap-2 text-sm font-semibold text-red-800"> + <AlertTriangle size={14} /> + {hasConflict ? "Held back — conflicts with remote" : type === "pull" ? "Pull failed" : "Push failed"} + </div> + + {hasConflict && ( + <> + <div className="text-xs text-red-800 leading-relaxed"> + Your change conflicts with the remote, so it wasn't merged — but your + local edit is <b>kept, not lost</b>. Either take the remote (discard + this edit) or resolve it by hand / in a loop. + </div> + <button + type="button" + onClick={() => setShowConflicts(!showConflicts)} + className="text-left text-[11px] text-red-700 underline" + > + {showConflicts ? "Hide" : "Show"} conflicting files ({result.files!.length}) + </button> + {showConflicts && ( + <ul className="font-mono text-[10.5px] text-red-900 bg-white/60 rounded p-1.5 max-h-24 overflow-auto"> + {result.files!.map((f) => ( + <li key={f}>{f}</li> + ))} + </ul> + )} + </> + )} + + {needsPull && !hasConflict && ( + <div className="text-xs text-red-800 leading-relaxed"> + Remote moved again while pushing. Just try again. + </div> + )} + + {!hasConflict && !needsPull && ( + <div className="text-xs text-red-800 leading-relaxed"> + {result.error} + </div> + )} + + <div className="flex gap-2 mt-1"> + {hasConflict ? ( + // "take remote" — force pull discards the local edit and re-aligns to origin + <ForcePullButton onRetry={onRetry} /> + ) : ( + <button + type="button" + onClick={onRetry} + className="flex-1 px-3 h-8 text-sm rounded bg-red-700 text-white hover:bg-red-800" + > + Retry + </button> + )} + <button + type="button" + onClick={onDone} + className="px-3 h-8 text-sm rounded border border-red-300 text-red-700 bg-white hover:bg-red-50" + > + Close + </button> + </div> + </div> + ) +} + +/** Reveal the personal repo's SSH *public* (deploy) key for re-pasting into the + * git host. The public key is non-secret — no password gate, unlike the + * git-crypt private key export. */ +function ShowPublicKeyFlow({ vaultKeys, onDone }: { vaultKeys: { vault: string; publicKey: string }[]; onDone: () => void }) { + const [copied, setCopied] = useState<string | null>(null) + const copy = async (vault: string, pub: string) => { + if (await copyText(pub)) { + setCopied(vault) + setTimeout(() => setCopied(null), 1500) + } + } + return ( + <div className="flex flex-col gap-2 border border-gray-200 rounded p-2.5"> + <div className="text-xs font-semibold text-gray-900">Vault SSH keys — for team repos</div> + <div className="text-[11px] text-gray-500 leading-relaxed"> + Register these on the git host that hosts your <b>team</b> repos (knowledge / notes / + repos). A loop authenticates with its vault's key — one per vault. Public keys, safe to + share. (The deploy key is separate — it's for your <i>personal</i> repo only.) + </div> + {vaultKeys.length === 0 && <div className="text-[11px] text-gray-400">No vault keys yet.</div>} + {vaultKeys.map((vk) => ( + <div key={vk.vault} className="flex flex-col gap-1"> + <div className="text-[11px] text-gray-600 font-medium">vault: {vk.vault}</div> + <div className="relative"> + <textarea + readOnly + value={vk.publicKey} + rows={3} + className="w-full text-[11px] font-mono text-gray-800 bg-gray-50 border border-gray-200 rounded p-2 outline-none resize-none break-all" + onClick={(e) => (e.target as HTMLTextAreaElement).select()} + /> + <button + type="button" + onClick={() => copy(vk.vault, vk.publicKey)} + className="absolute top-1.5 right-1.5 px-2 py-0.5 text-[11px] rounded bg-white border border-gray-300 text-gray-700 hover:bg-gray-100" + > + {copied === vk.vault ? "copied" : "copy"} + </button> + </div> + </div> + ))} + <button + type="button" + onClick={onDone} + className="self-end px-3 h-8 text-sm rounded border border-gray-300 text-gray-700 hover:bg-gray-50" + > + Close + </button> + </div> + ) +} + +function ExportKeyFlow({ onDone }: { onDone: () => void }) { + const [password, setPassword] = useState("") + const [busy, setBusy] = useState(false) + const [error, setError] = useState<string | null>(null) + const [cryptKey, setCryptKey] = useState<string | null>(null) + const [copied, setCopied] = useState(false) + + const submit = async () => { + if (busy || !password) return + setError(null) + setBusy(true) + try { + const r = await exportPersonalCryptKey(password) + if (!r.ok) setError(r.error) + else setCryptKey(r.cryptKey) + } finally { + setBusy(false) + } + } + + const copy = async () => { + if (!cryptKey) return + try { + await navigator.clipboard.writeText(cryptKey) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch {} + } + + if (cryptKey) { + return ( + <div className="flex flex-col gap-2 border border-gray-200 rounded p-2.5"> + <div className="text-xs font-semibold text-gray-900">git-crypt key</div> + <div className="relative"> + <textarea + readOnly + value={cryptKey} + rows={4} + className="w-full text-[11px] font-mono text-gray-800 bg-gray-50 border border-gray-200 rounded p-2 outline-none resize-none break-all" + onClick={(e) => (e.target as HTMLTextAreaElement).select()} + /> + <button + type="button" + onClick={copy} + className="absolute top-1.5 right-1.5 px-2 py-0.5 text-[11px] rounded bg-white border border-gray-300 text-gray-700 hover:bg-gray-100" + > + {copied ? "copied" : "copy"} + </button> + </div> + <div className="text-[11px] text-gray-500 leading-relaxed"> + Treat it like a password — anyone with this key can decrypt everything + under <code className="bg-gray-100 px-1 rounded">.loopat/vaults/</code>. + </div> + <button + type="button" + onClick={onDone} + className="self-end px-3 h-8 text-sm rounded border border-gray-300 text-gray-700 hover:bg-gray-50" + > + Close + </button> + </div> + ) + } + + return ( + <div className="flex flex-col gap-2 border border-gray-200 rounded p-2.5"> + <div className="text-xs font-semibold text-gray-900"> + Confirm password to reveal the key + </div> + <input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) submit() + }} + autoFocus + autoComplete="current-password" + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded outline-none focus:border-gray-500" + /> + {error && ( + <div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5"> + {error} + </div> + )} + <div className="flex gap-2"> + <button + type="button" + onClick={submit} + disabled={busy || !password} + className="flex-1 px-3 h-8 text-sm rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50" + > + {busy ? "verifying…" : "Reveal"} + </button> + <button + type="button" + onClick={onDone} + disabled={busy} + className="px-3 h-8 text-sm rounded border border-gray-300 text-gray-700 hover:bg-gray-50" + > + Cancel + </button> + </div> + </div> + ) +} + +type DataLossState = { + syncError: string + uncommitted?: number + unpushed?: number + hasRemote?: boolean +} + +function DeleteVaultFlow({ + status, + onDone, +}: { + status: PersonalStatus + onDone: () => void +}) { + const [password, setPassword] = useState("") + const [busy, setBusy] = useState(false) + const [error, setError] = useState<string | null>(null) + const [dataLoss, setDataLoss] = useState<DataLossState | null>(null) + const [ack1, setAck1] = useState(false) + const [ack2, setAck2] = useState(false) + const [ack3, setAck3] = useState(false) + const [done, setDone] = useState<{ synced: boolean; dataLost: boolean } | null>(null) + + const reload = () => { + // Refresh top-level state by closing this flow; parent will re-fetch + // /api/personal/status when the panel remounts. + onDone() + setTimeout(() => window.location.reload(), 50) + } + + const submit = async (force: boolean) => { + if (busy || !password) return + setError(null) + setBusy(true) + try { + const r = await deletePersonalVault(password, force) + if (r.ok) { + setDone({ synced: r.synced ?? false, dataLost: r.dataLost ?? false }) + return + } + if (r.wrongPassword) { + setError(r.error ?? "wrong password") + return + } + if (r.syncFailed) { + setDataLoss({ + syncError: (r as any).syncError ?? r.error ?? "unknown sync error", + uncommitted: r.uncommitted, + unpushed: r.unpushed, + hasRemote: r.hasRemote, + }) + return + } + setError(r.error ?? "delete failed") + } finally { + setBusy(false) + } + } + + if (done) { + return ( + <div className="flex flex-col gap-2 border border-gray-200 rounded p-2.5"> + <div className="text-sm font-semibold text-gray-900">Vault deleted</div> + <div className="text-xs text-gray-600 leading-relaxed"> + {done.dataLost ? ( + <span className="text-red-700"> + Unsynced changes were discarded. The vault is gone. + </span> + ) : done.synced ? ( + "Synced to remote and deleted. You can re-import any time." + ) : ( + "Vault deleted. You can re-import any time." + )} + </div> + <button + type="button" + onClick={reload} + className="self-end px-3 h-8 text-sm rounded bg-gray-900 text-white hover:bg-gray-700" + > + Done + </button> + </div> + ) + } + + if (dataLoss) { + const allAcked = ack1 && ack2 && ack3 + return ( + <div className="flex flex-col gap-2 border border-red-300 bg-red-50 rounded p-2.5"> + <div className="text-sm font-semibold text-red-800"> + ⚠️ Sync failed — data will be lost + </div> + <div className="text-xs text-red-800 leading-relaxed"> + Loopat couldn't push your local changes to the remote. + {(dataLoss.uncommitted ?? 0) > 0 && ( + <span> {dataLoss.uncommitted} uncommitted file(s).</span> + )} + {(dataLoss.unpushed ?? 0) > 0 && ( + <span> {dataLoss.unpushed} unpushed commit(s).</span> + )} + {dataLoss.hasRemote === false && ( + <span> No remote is configured on this vault.</span> + )} + </div> + <pre className="text-[10.5px] text-red-900 bg-white/60 border border-red-200 rounded p-1.5 overflow-auto max-h-24"> + {dataLoss.syncError} + </pre> + <div className="text-xs text-red-800 leading-relaxed"> + Continuing now will permanently delete the unsynced changes from this + host. To keep them, cancel and resolve the sync issue first (network, + deploy-key write access, branch protection). + </div> + <div className="flex flex-col gap-1 mt-1 text-xs text-red-900"> + <label className="flex items-start gap-2"> + <input + type="checkbox" + checked={ack1} + onChange={(e) => setAck1(e.target.checked)} + className="mt-0.5 accent-red-700" + /> + <span>I understand the unsynced changes will be deleted.</span> + </label> + <label className="flex items-start gap-2"> + <input + type="checkbox" + checked={ack2} + onChange={(e) => setAck2(e.target.checked)} + className="mt-0.5 accent-red-700" + /> + <span>I cannot recover them from this host afterwards.</span> + </label> + <label className="flex items-start gap-2"> + <input + type="checkbox" + checked={ack3} + onChange={(e) => setAck3(e.target.checked)} + className="mt-0.5 accent-red-700" + /> + <span>I still want to delete the vault now.</span> + </label> + </div> + {error && ( + <div className="text-xs text-red-700 bg-white border border-red-200 rounded px-2 py-1.5"> + {error} + </div> + )} + <div className="flex gap-2 mt-1"> + <button + type="button" + onClick={() => submit(true)} + disabled={busy || !allAcked} + className="flex-1 px-3 h-8 text-sm rounded bg-red-700 text-white hover:bg-red-800 disabled:opacity-40" + > + {busy ? "deleting…" : "Delete anyway"} + </button> + <button + type="button" + onClick={onDone} + disabled={busy} + className="px-3 h-8 text-sm rounded border border-red-300 text-red-700 bg-white hover:bg-red-50" + > + Cancel + </button> + </div> + </div> + ) + } + + return ( + <div className="flex flex-col gap-2 border border-red-200 rounded p-2.5"> + <div className="text-xs font-semibold text-red-800"> + Delete personal vault + </div> + <div className="text-[11px] text-gray-600 leading-relaxed"> + Removes <code className="bg-gray-100 px-1 rounded">personal/{status.userId}/</code>{" "} + on this host and forgets the git-crypt key. Loopat will try to push any + unsynced changes to{" "} + <code className="bg-gray-100 px-1 rounded">{status.personalRepo ?? "remote"}</code>{" "} + first. Your GitHub repo itself is not touched. + </div> + <input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) submit(false) + }} + placeholder="confirm password" + autoFocus + autoComplete="current-password" + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded outline-none focus:border-gray-500" + /> + {error && ( + <div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5"> + {error} + </div> + )} + <div className="flex gap-2"> + <button + type="button" + onClick={() => submit(false)} + disabled={busy || !password} + className="flex-1 px-3 h-8 text-sm rounded bg-red-700 text-white hover:bg-red-800 disabled:opacity-50" + > + {busy ? "working…" : "Sync & delete"} + </button> + <button + type="button" + onClick={onDone} + disabled={busy} + className="px-3 h-8 text-sm rounded border border-gray-300 text-gray-700 hover:bg-gray-50" + > + Cancel + </button> + </div> + </div> + ) +} diff --git a/web/src/components/kanban/CardDetailDialog.tsx b/web/src/components/kanban/CardDetailDialog.tsx new file mode 100644 index 00000000..f3cafeba --- /dev/null +++ b/web/src/components/kanban/CardDetailDialog.tsx @@ -0,0 +1,210 @@ +import { useState } from "react" +import { useNavigate } from "react-router-dom" +import { useWorkspace } from "../../ctx" +import { RefreshCw } from "lucide-react" +import { + toggleKanbanCard, + updateKanbanCardBlock, + deleteKanbanCard, + assignKanbanDriver, + type KanbanCard, +} from "../../api" +import { TopicChip } from "../TopicChip" + +type SubtaskItem = { text: string; done: boolean } + +function buildCardBlock(title: string, done: boolean, priority: string, assignee: string, due: string, description: string, subtasks: SubtaskItem[], topics: string[]): string { + const ch = done ? "x" : " " + const topicSuffix = topics.length > 0 ? " " + topics.map((t) => `#${t}`).join(" ") : "" + const lines = [`- [${ch}] ${title}${topicSuffix}`] + if (priority) lines.push(` > priority: ${priority}`) + if (assignee) lines.push(` > assignee: ${assignee}`) + if (due) lines.push(` > due: ${due}`) + if (description) { + for (const dl of description.split("\n")) lines.push(` ${dl}`) + } + for (const st of subtasks) { + const sc = st.done ? "x" : " " + lines.push(` - [${sc}] ${st.text}`) + } + return lines.join("\n") +} + +export function CardDetailDialog({ + board, card, colFilename, onClose, onSaved, onDeleted, +}: { + board: string; card: KanbanCard; colFilename: string; onClose: () => void; onSaved: () => void; onDeleted: () => void +}) { + const ws = useWorkspace() + const navigate = useNavigate() + const loggedIn = !!ws.currentUser + + const [title, setTitle] = useState(card.text) + const [done, setDone] = useState(card.done) + const [priority, setPriority] = useState(card.priority ?? "") + const [assignee, setAssignee] = useState(card.assignee ?? "") + const [due, setDue] = useState(card.due ?? "") + const [desc, setDesc] = useState(card.description) + const [subtasks, setSubtasks] = useState<SubtaskItem[]>(card.subtasks.map((s) => ({ ...s }))) + const [topics, setTopics] = useState<string[]>(card.topics ?? []) + const [newTopic, setNewTopic] = useState("") + const [addingTag, setAddingTag] = useState(false) + const [saving, setSaving] = useState(false) + const [deleting, setDeleting] = useState(false) + const [assigning, setAssigning] = useState(false) + + function toggleSub(i: number) { setSubtasks((p) => p.map((s, j) => j === i ? { ...s, done: !s.done } : s)) } + function setSubText(i: number, t: string) { setSubtasks((p) => p.map((s, j) => j === i ? { ...s, text: t } : s)) } + function removeSub(i: number) { setSubtasks((p) => p.filter((_, j) => j !== i)) } + function addSub() { setSubtasks((p) => [...p, { text: "", done: false }]) } + + function addTopic() { + const t = newTopic.trim().replace(/^#/, "") + if (t && !topics.includes(t)) { setTopics([...topics, t]); setNewTopic("") } + } + function removeTopic(t: string) { setTopics(topics.filter((x) => x !== t)) } + + async function handleSave() { + setSaving(true) + const block = buildCardBlock(title.trim() || card.text, done, priority, assignee.trim(), due.trim(), desc.trim(), subtasks, topics) + await updateKanbanCardBlock(board, colFilename, card.cid, block) + setSaving(false) + onSaved() + onClose() + } + + async function handleToggleDone() { + await toggleKanbanCard(board, colFilename, card.cid) + setDone((v) => !v) + } + + async function handleDelete() { + if (!confirm("Delete this card?")) return + setDeleting(true) + await deleteKanbanCard(board, colFilename, card.cid) + setDeleting(false) + onDeleted() + } + + async function handleAssignDriver() { + setAssigning(true) + const r = await assignKanbanDriver(board, colFilename, card.cid) + setAssigning(false) + if (r.ok) { onSaved() } + else { alert("No associated loop on this card") } + } + + function handleCreateLoop() { + onClose() + ws.setNewLoopDialogOpen(true, card.text, { board, filename: colFilename, cid: card.cid }) + } + + return ( + <div className="fixed inset-0 z-40 bg-black/30 flex items-center justify-center" onClick={onClose}> + <div className="bg-white rounded-md shadow-xl border border-gray-200 w-full max-w-lg max-h-[90vh] overflow-y-auto mx-4" onClick={(e) => e.stopPropagation()}> + + {/* header: title + done + close */} + <div className="flex items-start gap-2 px-4 py-3 border-b border-gray-100"> + <button type="button" onClick={handleToggleDone} + className={`shrink-0 mt-0.5 w-4 h-4 rounded border flex items-center justify-center text-xs hover:border-gray-500 ${done ? "bg-emerald-50 border-emerald-400 text-emerald-600" : "border-gray-300"}`}> + {done ? "✓" : ""} + </button> + <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} + className={`flex-1 text-[14px] font-medium border-0 outline-none bg-transparent ${done ? "text-gray-400 line-through" : "text-gray-900"}`} /> + <button onClick={onClose} className="text-gray-400 hover:text-gray-700 text-sm shrink-0">✕</button> + </div> + + <div className="px-4 py-3 space-y-3"> + + {/* subtasks */} + <div className="space-y-1.5"> + <div className="text-[10px] text-gray-400 uppercase tracking-wider">Subtasks</div> + {subtasks.map((st, i) => ( + <div key={i} className="flex items-center gap-2"> + <button type="button" onClick={() => toggleSub(i)} + className={`shrink-0 w-4 h-4 rounded border flex items-center justify-center text-[10px] hover:border-gray-500 ${st.done ? "bg-emerald-50 border-emerald-400 text-emerald-600" : "border-gray-300"}`}> + {st.done ? "✓" : ""} + </button> + <input type="text" value={st.text} onChange={(e) => setSubText(i, e.target.value)} + className={`flex-1 text-[12px] border border-gray-200 rounded px-1.5 py-0.5 outline-none focus:border-gray-400 ${st.done ? "text-gray-400 line-through" : "text-gray-700"}`} /> + <button type="button" onClick={() => removeSub(i)} className="text-gray-400 hover:text-red-500 text-sm shrink-0">×</button> + </div> + ))} + <button type="button" onClick={addSub} className="flex items-center gap-1 text-[11px] text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded px-2 py-0.5 -ml-1 transition-colors">+ Add subtask</button> + </div> + + {/* tags */} + <div className="space-y-1.5"> + <div className="text-[10px] text-gray-400 uppercase tracking-wider">Tags</div> + <div className="flex items-center gap-1 flex-wrap"> + {topics.map((t) => ( + <TopicChip key={t} name={t} onClick={() => navigate(`/topic/${encodeURIComponent(t)}`)} onEdit={() => removeTopic(t)} /> + ))} + {addingTag ? ( + <span className="inline-flex items-center gap-1"> + <input type="text" value={newTopic} onChange={(e) => setNewTopic(e.target.value)} autoFocus + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) { e.preventDefault(); addTopic(); setAddingTag(false) }; if (e.key === "Escape") { setAddingTag(false); setNewTopic("") } }} + onBlur={() => { setAddingTag(false); setNewTopic("") }} + className="w-20 text-[11px] border border-gray-300 rounded px-1.5 py-0.5 outline-none focus:border-gray-400" placeholder="tag" /> + <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => { addTopic(); setAddingTag(false) }} + className="text-[11px] text-gray-500 hover:text-gray-700">Add</button> + </span> + ) : ( + <button type="button" onClick={() => setAddingTag(true)} + className="flex items-center gap-1 text-[11px] text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded px-2 py-0.5 transition-colors"> + + Add tag + </button> + )} + </div> + </div> + + {/* divider */} + <div className="border-t border-gray-100" /> + + {/* form fields */} + <div className="grid grid-cols-2 gap-3"> + <Field label="Priority"> + <select value={priority} onChange={(e) => setPriority(e.target.value)} + className="w-full text-[12px] border border-gray-300 rounded px-2 py-1 outline-none focus:border-gray-500"> + <option value="">---</option><option value="P0">P0</option><option value="P1">P1</option><option value="P2">P2</option><option value="P3">P3</option> + </select> + </Field> + <Field label="Assignee"> + <input type="text" value={assignee} onChange={(e) => setAssignee(e.target.value)} + className="w-full text-[12px] border border-gray-300 rounded px-2 py-1 outline-none focus:border-gray-500" placeholder="alice, bob" /> + </Field> + <Field label="Due"> + <input type="text" value={due} onChange={(e) => setDue(e.target.value)} + className="w-full text-[12px] border border-gray-300 rounded px-2 py-1 outline-none focus:border-gray-500" placeholder="2026-05-20" /> + </Field> + </div> + <Field label="Description"> + <textarea value={desc} onChange={(e) => setDesc(e.target.value)} rows={3} + className="w-full text-[12px] border border-gray-300 rounded px-2 py-1.5 outline-none focus:border-gray-500 resize-none" placeholder="Notes…" /> + </Field> + + {card.loopId && ( + <div className="text-[11px] text-gray-400">Loop: <button onClick={() => { + const id = card.loopId! + const full = ws.loops.find((l) => l.id === id || l.id.startsWith(id))?.id ?? id + navigate(`/loop/${full}`) + }} className="text-blue-700 hover:underline font-mono">{card.loopId.slice(0, 8)}</button></div> + )} + </div> + + {/* actions */} + <div className="flex items-center gap-2 px-4 py-3 border-t border-gray-100"> + {loggedIn && <button type="button" disabled={assigning} onClick={handleAssignDriver} className="px-2.5 h-7 rounded text-[11px] border border-gray-200 hover:bg-gray-100 text-gray-700 disabled:opacity-50">{assigning ? <RefreshCw size={12} className="animate-spin" /> : "Assign Driver"}</button>} + {loggedIn && <button type="button" onClick={handleCreateLoop} className="px-2.5 h-7 rounded text-[11px] border border-gray-200 hover:bg-gray-100 text-gray-700">Create Loop</button>} + <div className="flex-1" /> + {loggedIn && <button type="button" disabled={saving} onClick={handleSave} className="px-3 h-7 rounded text-[11px] bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50">{saving ? <RefreshCw size={12} className="animate-spin" /> : "Save"}</button>} + {loggedIn && <button type="button" disabled={deleting} onClick={handleDelete} className="px-2.5 h-7 rounded text-[11px] text-red-600 hover:bg-red-50 disabled:opacity-50">{deleting ? <RefreshCw size={12} className="animate-spin" /> : "Delete"}</button>} + </div> + </div> + </div> + ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return <label className="flex flex-col gap-1"><span className="text-[10px] text-gray-400 uppercase tracking-wider">{label}</span>{children}</label> +} diff --git a/web/src/components/kanban/KanbanBoard.tsx b/web/src/components/kanban/KanbanBoard.tsx new file mode 100644 index 00000000..41b7915d --- /dev/null +++ b/web/src/components/kanban/KanbanBoard.tsx @@ -0,0 +1,350 @@ +import { useEffect, useState, useCallback, useRef, forwardRef, useImperativeHandle } from "react" +import { + DndContext, + DragOverlay, + closestCenter, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, + type DragOverEvent, +} from "@dnd-kit/core" +import { SortableContext, arrayMove, verticalListSortingStrategy, horizontalListSortingStrategy } from "@dnd-kit/sortable" +import { listKanbanColumns, getKanbanConfig, saveKanbanColumnOrder, toggleKanbanCard, moveKanbanCard, reorderKanbanCards, createKanbanColumn, type KanbanCard, type KanbanColumn } from "../../api" +import { KanbanColumn as KanbanColumnView } from "./KanbanColumn" +import { KanbanCardStatic } from "./KanbanCardView" +import { useKanbanWebSocket } from "../../useKanbanWebSocket" + +export type KanbanBoardHandle = { refresh: () => void } + +export const KanbanBoard = forwardRef<KanbanBoardHandle, { + board: string + onCardClick: (card: KanbanCard, filename: string) => void + onCardArchive: (card: KanbanCard, colFilename: string) => void + showArchived: boolean +}>(function KanbanBoard({ + board, + onCardClick, + onCardArchive, + showArchived, +}, ref) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }) + ) + const [columns, setColumns] = useState<KanbanColumn[]>([]) + const [orderedFiles, setOrderedFiles] = useState<string[]>([]) + const [colConfig, setColConfig] = useState<{ file: string; color?: string }[]>([]) + const [loading, setLoading] = useState(true) + const [activeCard, setActiveCard] = useState<KanbanCard | null>(null) + const [activeColumn, setActiveColumn] = useState<KanbanColumn | null>(null) + const [newColOpen, setNewColOpen] = useState(false) + const [newColName, setNewColName] = useState("") + const [dropColIndex, setDropColIndex] = useState<number | null>(null) + const isDraggingColRef = useRef(false) + + const refresh = useCallback(() => { + Promise.all([listKanbanColumns(board), getKanbanConfig(board)]).then(([cols, cfg]) => { + // sort: config order first, then remaining columns + const sorted = [...cols].sort((a, b) => { + const ai = cfg.findIndex((c) => c.file === a.filename) + const bi = cfg.findIndex((c) => c.file === b.filename) + if (ai >= 0 && bi >= 0) return ai - bi + if (ai >= 0) return -1 + if (bi >= 0) return 1 + return a.title.localeCompare(b.title) + }) + setColumns(sorted) + setOrderedFiles(sorted.map((c) => c.filename)) + setColConfig(cfg) + setLoading(false) + }) + }, [board]) + + useImperativeHandle(ref, () => ({ refresh }), [refresh]) + + const connected = useKanbanWebSocket(refresh) + + useEffect(() => { refresh() }, [refresh]) + + function findCard(cid: string): [string, number] | null { + for (const col of columns) { + const idx = col.cards.findIndex((c) => c.cid === cid) + if (idx >= 0) return [col.filename, idx] + } + return null + } + + function handleDragStart(event: DragStartEvent) { + const cid = event.active.id as string + if (cid.startsWith("col:")) { + isDraggingColRef.current = true + const col = columns.find((c) => c.filename === cid.slice(4)) + if (col) setActiveColumn(col) + } else { + isDraggingColRef.current = false + const card = event.active.data.current?.card as KanbanCard | undefined + if (card) setActiveCard(card) + } + } + + function resolveColFile(overId: string): string | null { + // Direct column droppable + if (overId.startsWith("col:")) return overId.slice(4) + if (visibleColumns.some((c) => c.filename === overId)) return overId + // Card droppable — find which column it belongs to + for (const col of visibleColumns) { + if (col.cards.some((c) => c.cid === overId)) return col.filename + } + return null + } + + function handleDragOver(event: DragOverEvent) { + if (!isDraggingColRef.current) { + setDropColIndex(null) + return + } + const { active, over, collisions } = event + if (!over) { setDropColIndex(null); return } + + const overFile = resolveColFile(over.id as string) + if (!overFile) { setDropColIndex(null); return } + const overColIdx = visibleColumns.findIndex((c) => c.filename === overFile) + if (overColIdx < 0) { setDropColIndex(null); return } + + const activeFile = resolveColFile(active.id as string) + const activeColIdx = activeFile ? visibleColumns.findIndex((c) => c.filename === activeFile) : -1 + if (activeColIdx < 0) { setDropColIndex(null); return } + if (activeColIdx === overColIdx) { setDropColIndex(null); return } + + // Use the first collision from closestCenter to determine left/right + const firstCollision = collisions?.[0] + if (!firstCollision) { setDropColIndex(null); return } + + const activeCenter = active.rect.current.translated + ? active.rect.current.translated.left + (active.rect.current.translated.width ?? 0) / 2 + : 0 + const collisionCenter = firstCollision.data?.droppableRect + ? firstCollision.data.droppableRect.left + firstCollision.data.droppableRect.width / 2 + : 0 + + const idx = activeCenter < collisionCenter ? overColIdx : overColIdx + 1 + setDropColIndex(idx >= 0 && idx <= visibleColumns.length ? idx : null) + } + + function handleDragEnd(event: DragEndEvent) { + setActiveCard(null) + setActiveColumn(null) + setDropColIndex(null) + isDraggingColRef.current = false + const { active, over } = event + if (!over) return + + const activeId = active.id as string + + // ── column reorder ── + if (activeId.startsWith("col:")) { + const fromFile = activeId.slice(4) + const toFile = resolveColFile(over.id as string) + if (!toFile || fromFile === toFile) return + + const oldIdx = orderedFiles.indexOf(fromFile) + const overColIdx = orderedFiles.indexOf(toFile) + if (oldIdx < 0 || overColIdx < 0) return + + // Same center comparison as handleDragOver + const firstCollision = event.collisions?.[0] + const activeCenter = active.rect.current.translated + ? active.rect.current.translated.left + (active.rect.current.translated.width ?? 0) / 2 + : 0 + const collisionCenter = firstCollision?.data?.droppableRect + ? firstCollision.data.droppableRect.left + firstCollision.data.droppableRect.width / 2 + : 0 + // arrayMove's "to" is the final index — matches the indicator position + const targetIdx = activeCenter < collisionCenter ? overColIdx : overColIdx + 1 + const clampedIdx = Math.max(0, Math.min(targetIdx, orderedFiles.length - 1)) + if (clampedIdx === oldIdx) return + + const newOrder = arrayMove([...orderedFiles], oldIdx, clampedIdx) + setOrderedFiles(newOrder) + + setColumns((prev) => { + const copy = [...prev] + copy.sort((a, b) => { + const ai = newOrder.indexOf(a.filename) + const bi = newOrder.indexOf(b.filename) + if (ai >= 0 && bi >= 0) return ai - bi + if (ai >= 0) return -1 + if (bi >= 0) return 1 + return 0 + }) + return copy + }) + saveKanbanColumnOrder(board, newOrder) + return + } + + // ── card drag ── + const cid = activeId + const from = findCard(cid) + if (!from) return + const [fromFile, srcIdx] = from + + let toFile: string | null = null + let toIndex: number | undefined + + if (columns.some((c) => c.filename === over.id) || (over.id as string).startsWith("col:")) { + toFile = (over.id as string).startsWith("col:") ? (over.id as string).slice(4) : over.id as string + } else { + const target = findCard(over.id as string) + if (target) { toFile = target[0]; toIndex = target[1] } + } + if (!toFile) return + + setColumns((prev) => { + const next = prev.map((col) => ({ ...col, cards: [...col.cards] })) + const srcCol = next.find((c) => c.filename === fromFile)! + const dstCol = next.find((c) => c.filename === toFile)! + const si = srcCol.cards.findIndex((c) => c.cid === cid) + if (si < 0) return next + if (fromFile === toFile) { + const ti = toIndex ?? dstCol.cards.length - 1 + if (si !== ti) dstCol.cards = arrayMove(dstCol.cards, si, ti) + } else { + const [moved] = srcCol.cards.splice(si, 1) + if (toIndex !== undefined) dstCol.cards.splice(toIndex, 0, moved) + else dstCol.cards.push(moved) + } + return next + }) + + if (fromFile !== toFile) { + moveKanbanCard(board, fromFile, cid, toFile, toIndex) + } else if (fromFile === toFile && toIndex !== undefined && srcIdx !== toIndex) { + const col = columns.find((c) => c.filename === fromFile) + if (col) { + const cards = [...col.cards] + const [moved] = cards.splice(srcIdx, 1) + const ti = toIndex > srcIdx ? toIndex - 1 : toIndex + cards.splice(ti, 0, moved) + reorderKanbanCards(board, fromFile, cards.map((c) => c.cid)) + } + } + } + + function handleToggle(colFilename: string, card: KanbanCard) { + const next = !card.done + setColumns((prev) => prev.map((col) => + col.filename === colFilename + ? { ...col, cards: col.cards.map((c) => (c.cid === card.cid ? { ...c, done: next } : c)) } + : col + )) + toggleKanbanCard(board, colFilename, card.cid) + } + + async function handleCreateColumn() { + const name = newColName.trim() + if (!name) return + const ok = await createKanbanColumn(board, name) + if (ok) { setNewColOpen(false); setNewColName(""); refresh() } + } + + // Filter out archived.md unless showArchived is toggled + const visibleColumns = columns.filter((c) => showArchived || c.filename !== "archived.md") + const hasArchived = columns.some((c) => c.filename === "archived.md") + + if (loading) return <div className="flex items-center justify-center h-full"><div className="text-[12px] text-gray-400 italic">loading…</div></div> + + const totalCards = columns.reduce((s, c) => s + c.cards.length, 0) + if (totalCards === 0 && columns.length === 0) { + return ( + <div className="flex items-center justify-center h-full"> + <div className="flex flex-col items-center gap-3 text-gray-500"> + <div className="text-[13px]">no kanban columns in notes/focus/boards/{board}/</div> + {newColOpen ? ( + <div className="w-56 bg-gray-50 rounded-lg p-3 space-y-2"> + <input type="text" value={newColName} onChange={(e) => setNewColName(e.target.value)} autoFocus + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) handleCreateColumn() }} + className="w-full text-[13px] border border-gray-300 rounded px-2 py-1.5 outline-none focus:border-gray-500" placeholder="Column name" /> + <div className="flex items-center gap-1.5"> + <button onClick={handleCreateColumn} disabled={!newColName.trim()} className="px-2.5 h-7 rounded text-[11px] bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-40">Create</button> + <button onClick={() => { setNewColOpen(false); setNewColName("") }} className="px-2.5 h-7 rounded text-[11px] text-gray-500 hover:bg-gray-200">✕</button> + </div> + </div> + ) : ( + <button onClick={() => setNewColOpen(true)} className="px-3 h-8 rounded text-sm bg-gray-900 text-white hover:bg-gray-700">+ Create first column</button> + )} + </div> + </div> + ) + } + + return ( + <DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}> + <SortableContext items={visibleColumns.map((c) => `col:${c.filename}`)} strategy={horizontalListSortingStrategy}> + <div className="h-full flex gap-3 px-3 py-3 overflow-x-auto"> + {visibleColumns.map((col, idx) => { + const cfg = colConfig.find((c) => c.file === col.filename) + return [ + dropColIndex === idx && ( + <div key={`indicator-${col.filename}`} className="w-0.5 shrink-0 bg-blue-500 rounded-full self-stretch" /> + ), + <SortableContext key={col.filename} items={col.cards.map((c) => c.cid)} strategy={verticalListSortingStrategy}> + <KanbanColumnView + board={board} + column={{ id: col.filename, label: col.title }} + cards={col.cards} + onCardClick={(card) => onCardClick(card, col.filename)} + onCardToggle={(card) => handleToggle(col.filename, card)} + onCardArchive={(card) => onCardArchive(card, col.filename)} + onCardSaved={refresh} + onColumnSaved={refresh} + color={cfg?.color} + /> + </SortableContext>, + ];})} + {dropColIndex === visibleColumns.length && ( + <div key="indicator-end" className="w-0.5 shrink-0 bg-blue-500 rounded-full self-stretch" /> + )} + {newColOpen ? ( + <div className="w-56 shrink-0 bg-gray-50 rounded-lg p-3 space-y-2 h-fit"> + <input type="text" value={newColName} onChange={(e) => setNewColName(e.target.value)} autoFocus + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) handleCreateColumn() }} + className="w-full text-[13px] border border-gray-300 rounded px-2 py-1.5 outline-none focus:border-gray-500" placeholder="Column name" /> + <div className="flex items-center gap-1.5"> + <button onClick={handleCreateColumn} disabled={!newColName.trim()} className="px-2.5 h-7 rounded text-[11px] bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-40">Create</button> + <button onClick={() => { setNewColOpen(false); setNewColName("") }} className="px-2.5 h-7 rounded text-[11px] text-gray-500 hover:bg-gray-200">✕</button> + </div> + </div> + ) : ( + <button onClick={() => setNewColOpen(true)} className="w-56 shrink-0 h-fit rounded-lg border border-dashed border-gray-300 bg-gray-50 hover:bg-gray-100 py-3 text-[12px] text-gray-400">+ New Column</button> + )} + </div> + </SortableContext> + <DragOverlay> + {activeCard ? ( + <div className="rotate-2 w-64"><KanbanCardStatic card={activeCard} /></div> + ) : activeColumn ? ( + <div className="w-64 shrink-0 flex flex-col rounded-lg bg-gray-50 shadow-xl opacity-90 min-h-[120px]"> + <div className="flex items-center gap-2 px-2 py-2"> + <span className="text-gray-400 text-[10px] shrink-0">⋮⋮</span> + <span className="text-[11px] uppercase tracking-wider text-gray-500 font-medium">{activeColumn.title}</span> + <span className="text-[10px] font-mono text-gray-400 bg-gray-200 px-1.5 py-0.5 rounded-full">{activeColumn.cards.length}</span> + </div> + <div className="flex-1 px-1 pb-1 flex flex-col gap-1.5"> + {activeColumn.cards.length === 0 ? ( + <div className="text-[11px] text-gray-300 italic px-2 py-4 text-center">drop here</div> + ) : ( + activeColumn.cards.map((card) => ( + <KanbanCardStatic key={card.cid} card={card} /> + )) + )} + </div> + </div> + ) : null} + </DragOverlay> + </DndContext> + ) +}) diff --git a/web/src/components/kanban/KanbanCardView.tsx b/web/src/components/kanban/KanbanCardView.tsx new file mode 100644 index 00000000..2f594e91 --- /dev/null +++ b/web/src/components/kanban/KanbanCardView.tsx @@ -0,0 +1,160 @@ +import { useState } from "react" +import { useSortable } from "@dnd-kit/sortable" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import { updateKanbanCard, type KanbanCard } from "../../api" +import { TopicChip } from "../TopicChip" +import { useNavigate } from "react-router-dom" + +function InlineMarkdown({ text }: { text: string }) { + return ( + <ReactMarkdown + remarkPlugins={[remarkGfm]} + components={{ + p: ({ children }) => <>{children}</>, + strong: ({ children }) => <strong className="font-bold">{children}</strong>, + em: ({ children }) => <em className="italic">{children}</em>, + a: ({ href, children }) => ( + <a + href={href} + target="_blank" + rel="noreferrer" + onClick={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + className="text-blue-700 hover:underline" + > + {children} + </a> + ), + code: ({ children }) => ( + <code className="bg-gray-100 text-gray-800 rounded px-1 font-mono text-[11px]">{children}</code> + ), + }} + > + {text} + </ReactMarkdown> + ) +} + +function getAssignees(card: KanbanCard): string[] { + if (!card.assignee) return [] + return card.assignee.split(/[,;,;]+/).map((s) => s.trim()).filter(Boolean) +} + +const PRIO_CLASS: Record<string, string> = { + P0: "bg-red-50 text-red-700 border-red-200", + P1: "bg-orange-50 text-orange-700 border-orange-200", +} + +export function KanbanCardStatic({ card }: { card: KanbanCard }) { + const pct = card.subtasks.length > 0 + ? Math.round(card.subtasks.filter((s) => s.done).length / card.subtasks.length * 100) + : 0 + const assignees = getAssignees(card) + return ( + <div className={`text-left rounded-lg border bg-white px-3 py-2.5 w-full shadow-lg ${card.done ? "opacity-60" : "border-gray-300"}`}> + <div className="flex items-start gap-2"> + <span className={`shrink-0 mt-0.5 w-4 h-4 rounded border border-gray-300 flex items-center justify-center text-xs ${card.done ? "bg-emerald-50 border-emerald-400" : ""}`}>{card.done ? "✓" : ""}</span> + <h4 className={`text-[13px] font-medium flex-1 min-w-0 ${card.done ? "text-gray-400 line-through" : "text-gray-900"}`}><InlineMarkdown text={card.text} /></h4> + {card.priority && <span className={`text-[10px] font-mono px-1.5 py-0.5 rounded shrink-0 border ${PRIO_CLASS[card.priority] ?? "bg-gray-50 text-gray-600 border-gray-200"}`}>{card.priority.toUpperCase()}</span>} + </div> + {(assignees.length > 0 || card.due) && ( + <div className="mt-1.5 flex items-center gap-2 text-[10px]"> + {assignees.map((a, i) => <span key={i} className="w-5 h-5 rounded-full bg-gray-200 text-gray-600 text-[9px] flex items-center justify-center font-medium" title={a}>{a.slice(0, 2).toUpperCase()}</span>)} + {card.due && <span className="text-gray-400 ml-auto">{card.due}</span>} + </div> + )} + {card.topics.length > 0 && <div className="mt-1.5 flex items-center gap-1 flex-wrap">{card.topics.map((t) => <TopicChip key={t} name={t} onClick={() => {}} />)}</div>} + {card.subtasks.length > 0 && ( + <div className="mt-2 flex items-center gap-2"> + <div className="flex-1 h-1 bg-gray-100 rounded-full overflow-hidden"><div className="h-full bg-emerald-500" style={{ width: `${pct}%` }} /></div> + <span className="text-[10px] font-mono text-gray-500 shrink-0">{card.subtasks.filter((s) => s.done).length}/{card.subtasks.length}</span> + </div> + )} + </div> + ) +} + +export function KanbanCardView({ + board, card, colFilename, onClick, onToggle, onArchive, +}: { + board: string; card: KanbanCard; colFilename: string; onClick: () => void; onToggle: () => void; onArchive?: () => void +}) { + const navigate = useNavigate() + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: card.cid, data: { card, colFilename }, + }) + const hasTransform = transform && (transform.x !== 0 || transform.y !== 0) + const style = hasTransform ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, transition } : undefined + + const [editingTitle, setEditingTitle] = useState(false) + const [titleText, setTitleText] = useState(card.text) + + async function saveTitle() { + const t = titleText.trim() + if (!t || t === card.text) { setEditingTitle(false); return } + await updateKanbanCard(board, colFilename, card.cid, { text: t }) + setEditingTitle(false) + } + + const pct = card.subtasks.length > 0 + ? Math.round(card.subtasks.filter((s) => s.done).length / card.subtasks.length * 100) + : 0 + const assignees = getAssignees(card) + + return ( + <div className="relative group/card"> + <div ref={setNodeRef} {...listeners} {...attributes} style={style} onClick={editingTitle ? undefined : onClick} + role="button" tabIndex={0} + className={`text-left rounded-lg border bg-white px-3 py-2.5 transition-all w-full cursor-grab active:cursor-grabbing + ${isDragging ? "opacity-30 shadow-lg" : "border-gray-200 hover:border-gray-400 hover:shadow-sm"} ${card.done ? "opacity-60" : ""}`}> + <div className="flex items-start gap-2"> + <span className="shrink-0 mt-0.5 w-4 h-4 rounded border border-gray-300 flex items-center justify-center cursor-pointer hover:border-gray-500" + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); onToggle() }}> + {card.done && <span className="text-emerald-600 text-xs">✓</span>} + </span> + {editingTitle ? ( + <input type="text" value={titleText} onChange={(e) => setTitleText(e.target.value)} autoFocus + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) { e.preventDefault(); saveTitle() }; if (e.key === "Escape") { setEditingTitle(false); setTitleText(card.text) } }} + onBlur={saveTitle} + onPointerDown={(e) => e.stopPropagation()} + className="flex-1 min-w-0 text-[13px] font-medium border border-gray-300 rounded px-1 py-0.5 outline-none focus:border-gray-500" /> + ) : ( + <h4 className={`text-[13px] font-medium flex-1 min-w-0 cursor-text ${card.done ? "text-gray-400 line-through" : "text-gray-900"}`} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => { e.stopPropagation(); setTitleText(card.text); setEditingTitle(true) }}> + <InlineMarkdown text={card.text} /> + </h4> + )} + {card.priority && <span className={`text-[10px] font-mono px-1.5 py-0.5 rounded shrink-0 border ${PRIO_CLASS[card.priority] ?? "bg-gray-50 text-gray-600 border-gray-200"}`}>{card.priority.toUpperCase()}</span>} + </div> + {(assignees.length > 0 || card.due) && ( + <div className="mt-1.5 flex items-center gap-2 text-[10px]"> + <div className="flex items-center gap-0.5">{assignees.map((a, i) => <span key={i} className="w-5 h-5 rounded-full bg-gray-200 text-gray-600 text-[9px] flex items-center justify-center font-medium" title={a}>{a.slice(0, 2).toUpperCase()}</span>)}</div> + {card.due && <span className="text-gray-400 ml-auto">{card.due}</span>} + </div> + )} + {card.topics.length > 0 && ( + <div className="mt-1.5 flex items-center gap-1 flex-wrap" onClick={(e) => e.stopPropagation()}> + {card.topics.map((t) => (<TopicChip key={t} name={t} onClick={() => navigate(`/topic/${encodeURIComponent(t)}`)} onEdit={onClick} />))} + </div> + )} + {card.subtasks.length > 0 && ( + <div className="mt-2 flex items-center gap-2"> + <div className="flex-1 h-1 bg-gray-100 rounded-full overflow-hidden"><div className="h-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} /></div> + <span className="text-[10px] font-mono text-gray-500 shrink-0">{card.subtasks.filter((s) => s.done).length}/{card.subtasks.length}</span> + </div> + )} + {card.loopId && <div className="mt-1.5"><span className="text-[10px] font-mono text-gray-400 bg-gray-100 px-1 py-0.5 rounded">L:{card.loopId.slice(0, 6)}</span></div>} + </div> + {/* hover buttons */} + <button type="button" onClick={(e) => { e.stopPropagation(); onClick() }} + className="absolute top-2 right-2 z-10 w-5 h-5 rounded-full bg-white border border-gray-300 text-[10px] text-gray-400 hover:text-gray-700 hover:border-gray-500 opacity-100 sm:opacity-0 sm:group-hover/card:opacity-100 transition-opacity flex items-center justify-center shadow-sm" title="Edit card">✎</button> + {card.done && onArchive && ( + <button type="button" onClick={(e) => { e.stopPropagation(); onArchive() }} + className="absolute top-2 right-9 z-10 w-5 h-5 rounded-full bg-white border border-gray-300 text-[10px] text-gray-400 hover:text-orange-600 hover:border-orange-400 opacity-100 sm:opacity-0 sm:group-hover/card:opacity-100 transition-opacity flex items-center justify-center shadow-sm" title="Archive card">◧</button> + )} + </div> + ) +} diff --git a/web/src/components/kanban/KanbanColumn.tsx b/web/src/components/kanban/KanbanColumn.tsx new file mode 100644 index 00000000..9af508c7 --- /dev/null +++ b/web/src/components/kanban/KanbanColumn.tsx @@ -0,0 +1,162 @@ +import { useState } from "react" +import { useDroppable } from "@dnd-kit/core" +import { useSortable } from "@dnd-kit/sortable" +import { addKanbanCard, renameKanbanColumn, deleteKanbanColumn, setKanbanColumnColor, moveKanbanCard, type KanbanCard } from "../../api" +import { KanbanCardView } from "./KanbanCardView" + +const COLORS = ["", "#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899"] + +export function KanbanColumn({ + board, column, cards, onCardClick, onCardToggle, onCardArchive, onCardSaved, onColumnSaved, color, +}: { + board: string + column: { id: string; label: string } + cards: KanbanCard[] + onCardClick: (card: KanbanCard) => void + onCardToggle: (card: KanbanCard) => void + onCardArchive: (card: KanbanCard) => void + onCardSaved: () => void + onColumnSaved: () => void + color?: string +}) { + const { setNodeRef: setDropRef, isOver } = useDroppable({ id: column.id }) + const { attributes: hAttrs, listeners: hListeners, setNodeRef: setSortableRef, transform: colTransform, transition: colTransition, isDragging: headerDragging } = + useSortable({ id: `col:${column.id}`, data: { columnId: column.id } }) + const colStyle = colTransform && (colTransform.x !== 0 || colTransform.y !== 0) + ? { transform: `translate3d(${colTransform.x}px, ${colTransform.y}px, 0)`, transition: colTransition } : undefined + + const [adding, setAdding] = useState(false) + const [newText, setNewText] = useState("") + const [saving, setSaving] = useState(false) + const [showEdit, setShowEdit] = useState(false) + + // column edit dialog + const [editingCol, setEditingCol] = useState(false) + const [colTitle, setColTitle] = useState(column.label) + const [colColor, setColColor] = useState(color ?? "") + const [deleting, setDeleting] = useState(false) + + async function handleAdd() { + const text = newText.trim() + if (!text || saving) return + setSaving(true) + const r = await addKanbanCard(board, column.id, { text }) + setSaving(false) + if (r.cid) { setNewText(""); setAdding(false); onCardSaved() } + } + + async function handleRename() { + const name = colTitle.trim() + if (!name || name === column.label) { setEditingCol(false); return } + const newFile = name.toLowerCase().replace(/[^a-z0-9一-鿿]+/g, "-").replace(/^-|-$/g, "") + ".md" + await renameKanbanColumn(board, column.id, newFile) + setEditingCol(false); onColumnSaved() + } + + async function handleColorChange(c: string) { + setColColor(c) + await setKanbanColumnColor(board, column.id, c) + onColumnSaved() + } + + async function handleDelete() { + if (!confirm(`Delete column "${column.label}" and all its cards?`)) return + setDeleting(true) + // archive all cards first + for (const card of cards) { + await moveKanbanCard(board, column.id, card.cid, "archived.md") + } + await deleteKanbanColumn(board, column.id) + setDeleting(false); setEditingCol(false); onColumnSaved() + } + + return ( + <div ref={(node) => { setSortableRef(node); setDropRef(node) }} {...hAttrs} style={colStyle} + onMouseEnter={() => setShowEdit(true)} onMouseLeave={() => setShowEdit(false)} + onTouchStart={() => setShowEdit(true)} + className={`w-64 shrink-0 h-full flex flex-col rounded-lg transition-colors ${isOver ? "bg-gray-100" : "bg-gray-50"}`}> + + {/* header */} + <div className={`flex items-center gap-1.5 px-2 py-2 shrink-0 select-none rounded-t-lg ${headerDragging ? "opacity-30" : ""}`}> + <span {...hListeners} className="text-gray-400 text-[10px] shrink-0 cursor-grab active:cursor-grabbing hover:text-gray-600">⋮⋮</span> + <span className="text-[11px] uppercase tracking-wider text-gray-500 font-medium flex-1 min-w-0 truncate">{column.label}</span> + <span className="text-[10px] font-mono text-gray-400 bg-gray-200 px-1.5 py-0.5 rounded-full shrink-0">{cards.length}</span> + {showEdit && ( + <> + <button onClick={(e) => { e.stopPropagation(); setEditingCol(true); setColTitle(column.label); setColColor(color ?? "") }} + className="text-[10px] text-gray-400 hover:text-gray-700" title="Edit column">✎</button> + <button onClick={(e) => { e.stopPropagation(); handleDelete() }} + className="text-[10px] text-gray-400 hover:text-red-500" title="Delete column">×</button> + </> + )} + </div> + + {/* color bar */} + {color && <div className="h-0.5 shrink-0 mx-2 rounded" style={{ backgroundColor: color }} />} + + {/* cards */} + <div ref={setDropRef} className={`flex-1 min-h-[100px] overflow-y-auto px-1 pb-1 flex flex-col gap-1.5 ${isOver ? "bg-gray-200/50 rounded-b-lg" : ""}`}> + {cards.length === 0 && !adding ? ( + <div className="text-[11px] text-gray-300 italic px-2 py-4 text-center">drop here</div> + ) : ( + cards.map((card) => ( + <KanbanCardView key={card.cid} board={board} card={card} colFilename={column.id} + onClick={() => onCardClick(card)} onToggle={() => onCardToggle(card)} + onArchive={() => onCardArchive(card)} /> + )) + )} + {adding && ( + <div className="rounded-lg border border-gray-300 bg-white px-2 py-1.5"> + <input type="text" value={newText} onChange={(e) => setNewText(e.target.value)} autoFocus + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) { e.preventDefault(); handleAdd() }; if (e.key === "Escape") { setAdding(false); setNewText("") } }} + onBlur={() => { if (!newText.trim()) { setAdding(false); setNewText("") } }} + className="w-full text-[13px] border-0 outline-none bg-transparent text-gray-900 placeholder:text-gray-400" placeholder="Card title…" /> + <div className="flex items-center gap-1.5 mt-1.5"> + <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={handleAdd} disabled={!newText.trim() || saving} + className="px-2 h-6 rounded text-[11px] bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-40">{saving ? "…" : "Add"}</button> + <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => { setAdding(false); setNewText("") }} + className="text-gray-400 hover:text-gray-600 text-sm">✕</button> + </div> + </div> + )} + </div> + {!adding && ( + <button type="button" onClick={() => setAdding(true)} + className="mx-1 mb-1 shrink-0 rounded-lg border border-dashed border-gray-300 hover:border-gray-400 text-[11px] text-gray-400 hover:text-gray-600 py-2 transition-colors"> + + Add card + </button> + )} + + {/* column edit dialog */} + {editingCol && ( + <div className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center" onClick={() => setEditingCol(false)}> + <div className="bg-white rounded-md shadow-xl border border-gray-200 w-full max-w-xs mx-4" onClick={(e) => e.stopPropagation()}> + <div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100"> + <span className="text-sm font-medium text-gray-900">Edit Column</span> + <div className="flex-1" /> + <button onClick={() => setEditingCol(false)} className="text-gray-400 hover:text-gray-700">✕</button> + </div> + <div className="px-4 py-3 space-y-3"> + <label className="flex flex-col gap-1"><span className="text-[10px] text-gray-400 uppercase tracking-wider">Title</span> + <input type="text" value={colTitle} onChange={(e) => setColTitle(e.target.value)} autoFocus + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) handleRename() }} + className="w-full text-[13px] border border-gray-300 rounded px-2 py-1.5 outline-none focus:border-gray-500" /> + </label> + <label className="flex flex-col gap-1"><span className="text-[10px] text-gray-400 uppercase tracking-wider">Color</span> + <div className="flex gap-1.5"> + {COLORS.map((c) => ( + <button key={c} onClick={() => handleColorChange(c)} + className={`w-6 h-6 rounded-full border-2 ${colColor === c ? "border-gray-900" : "border-gray-200"} ${c ? "" : "bg-white"}`} + style={c ? { backgroundColor: c } : undefined} title={c || "none"} /> + ))} + </div> + </label> + <button onClick={handleDelete} disabled={deleting} + className="w-full text-[12px] text-red-500 hover:text-red-700 text-left">{deleting ? "Deleting…" : "Delete column"}</button> + </div> + </div> + </div> + )} + </div> + ) +} diff --git a/web/src/components/markdown/CodeEditor.tsx b/web/src/components/markdown/CodeEditor.tsx new file mode 100644 index 00000000..9b96bdc9 --- /dev/null +++ b/web/src/components/markdown/CodeEditor.tsx @@ -0,0 +1,220 @@ +/** + * CodeMirror 6 wrapper. Picks language extension by file extension. + * Theme: One Dark palette via CSS variables — auto-switches with .dark class. + */ +import { useEffect, useRef } from "react" +import { EditorView, basicSetup } from "codemirror" +import { EditorState, Compartment } from "@codemirror/state" +import { StreamLanguage, syntaxHighlighting, HighlightStyle } from "@codemirror/language" +import { tags } from "@lezer/highlight" +import { python } from "@codemirror/lang-python" +import { markdown } from "@codemirror/lang-markdown" +import { javascript } from "@codemirror/lang-javascript" +import { json, jsonParseLinter } from "@codemirror/lang-json" +import { linter } from "@codemirror/lint" + +// Legacy StreamLanguage modes +import { toml } from "@codemirror/legacy-modes/mode/toml" +import { yaml } from "@codemirror/legacy-modes/mode/yaml" +import { shell } from "@codemirror/legacy-modes/mode/shell" +import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile" +import { ruby } from "@codemirror/legacy-modes/mode/ruby" +import { go } from "@codemirror/legacy-modes/mode/go" +import { swift } from "@codemirror/legacy-modes/mode/swift" +import { sass } from "@codemirror/legacy-modes/mode/sass" +import { protobuf } from "@codemirror/legacy-modes/mode/protobuf" +import { css } from "@codemirror/legacy-modes/mode/css" +import { xml } from "@codemirror/legacy-modes/mode/xml" +import { sql } from "@codemirror/legacy-modes/mode/sql" +import { rust } from "@codemirror/legacy-modes/mode/rust" +import { c } from "@codemirror/legacy-modes/mode/clike" + +const L = StreamLanguage.define + +const extMap: Record<string, () => any> = { + py: python, + pyi: python, + md: markdown, + mdc: markdown, + toml: () => L(toml), + json: () => [json(), linter(jsonParseLinter())], + ts: () => javascript({ typescript: true }), + tsx: () => javascript({ typescript: true }), + js: () => javascript(), + jsx: () => javascript(), + mjs: () => javascript(), + cjs: () => javascript(), + css: () => L(css as any), + scss: () => L(sass as any), + sass: () => L(sass as any), + less: () => L(sass as any), + xml: () => L(xml as any), + svg: () => L(xml as any), + html: () => L(xml as any), + htm: () => L(xml as any), + sql: () => L(sql as any), + rs: () => L(rust as any), + yaml: () => L(yaml as any), + yml: () => L(yaml as any), + sh: () => L(shell as any), + bash: () => L(shell as any), + zsh: () => L(shell as any), + fish: () => L(shell as any), + rb: () => L(ruby as any), + go: () => L(go as any), + swift: () => L(swift as any), + proto: () => L(protobuf as any), + env: () => L(shell as any), + gitignore: () => L(shell as any), + editorconfig: () => L(toml as any), + c: () => L(c as any), + h: () => L(c as any), + cpp: () => L(c as any), + hpp: () => L(c as any), + cc: () => L(c as any), + cs: () => L(c as any), + java: () => L(c as any), + scala: () => L(c as any), + kt: () => L(c as any), + kts: () => L(c as any), + nginx: () => L(shell as any), + conf: () => L(shell as any), +} + +const langExt = (path: string) => { + const ext = path.includes(".") ? path.split(".").pop()?.toLowerCase() ?? "" : path.toLowerCase() + if (ext === "dockerfile" || path.toLowerCase().endsWith("dockerfile")) return L(dockerFile) + if (ext === "makefile" || path.toLowerCase().endsWith("makefile")) return L(shell) + const fn = extMap[ext] + return fn ? fn() : [] +} + +const syntaxHighlight = syntaxHighlighting( + HighlightStyle.define([ + { tag: tags.keyword, color: "var(--cm-keyword)" }, + { tag: tags.string, color: "var(--cm-string)" }, + { tag: tags.number, color: "var(--cm-number)" }, + { tag: tags.comment, color: "var(--cm-comment)", fontStyle: "italic" }, + { tag: tags.function(tags.variableName), color: "var(--cm-def)" }, + { tag: tags.definition(tags.variableName), color: "var(--cm-def)" }, + { tag: tags.typeName, color: "var(--cm-typeName)" }, + { tag: tags.propertyName, color: "var(--cm-propertyName)" }, + { tag: tags.operator, color: "var(--cm-operator)" }, + { tag: tags.atom, color: "var(--cm-constant)" }, + { tag: tags.bool, color: "var(--cm-constant)" }, + { tag: tags.null, color: "var(--cm-constant)" }, + { tag: tags.variableName, color: "var(--cm-variableName)" }, + { tag: tags.labelName, color: "var(--cm-labelName)" }, + { tag: tags.tagName, color: "var(--cm-tag)" }, + { tag: tags.attributeName, color: "var(--cm-attribute)" }, + { tag: tags.link, color: "var(--cm-link)", textDecoration: "underline" }, + { tag: tags.regexp, color: "var(--cm-regexp)" }, + { tag: tags.escape, color: "var(--cm-constant)" }, + { tag: tags.special(tags.string), color: "var(--cm-regexp)" }, + { tag: tags.bracket, color: "var(--cm-bracket)" }, + { tag: tags.heading, color: "var(--cm-def)", fontWeight: "bold" }, + { tag: tags.emphasis, fontStyle: "italic" }, + { tag: tags.strong, fontWeight: "bold" }, + { tag: tags.invalid, color: "var(--cm-variableName)" }, + ]) +) + +const baseTheme = EditorView.theme({ + "&": { fontSize: "13px", height: "100%", backgroundColor: "var(--cm-bg)" }, + ".cm-scroller": { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", + lineHeight: "1.55", + }, + ".cm-content": { padding: "10px 0", color: "var(--cm-text)", caretColor: "var(--cm-cursor)" }, + ".cm-gutters": { + backgroundColor: "var(--cm-bg)", + borderRight: "1px solid var(--cm-border)", + color: "var(--cm-gutter)", + }, + ".cm-activeLine": { backgroundColor: "var(--cm-activeLine)" }, + ".cm-activeLineGutter": { backgroundColor: "transparent" }, + ".cm-cursor": { borderLeftColor: "var(--cm-cursor)" }, + ".cm-selectionBackground, ::selection": { backgroundColor: "var(--cm-selection)" }, + ".cm-focused": { outline: "none" }, + ".cm-matchingBracket": { backgroundColor: "var(--cm-matchBracket)" }, + ".cm-nonmatchingBracket": { color: "var(--cm-error)" }, +}, {dark: false}) + +export function CodeEditor({ + path, + value, + onChange, + wordWrap = true, + onSelectionChange, +}: { + path: string + value: string + onChange: (v: string) => void + wordWrap?: boolean + onSelectionChange?: (sel: { from: number; to: number } | null) => void +}) { + const hostRef = useRef<HTMLDivElement>(null) + const viewRef = useRef<EditorView | null>(null) + const langCompartment = useRef(new Compartment()) + const wrapCompartment = useRef(new Compartment()) + const onChangeRef = useRef(onChange) + onChangeRef.current = onChange + const onSelectionRef = useRef(onSelectionChange) + onSelectionRef.current = onSelectionChange + + useEffect(() => { + if (!hostRef.current) return + const state = EditorState.create({ + doc: value, + extensions: [ + basicSetup, + syntaxHighlight, + baseTheme, + wrapCompartment.current.of(wordWrap ? EditorView.lineWrapping : []), + langCompartment.current.of(langExt(path)), + EditorView.updateListener.of((u) => { + if (u.docChanged) onChangeRef.current(u.state.doc.toString()) + // Report selection changes + if (u.selectionSet || u.docChanged) { + const sel = u.state.selection.main + const fromLine = u.state.doc.lineAt(sel.from).number + const toLine = u.state.doc.lineAt(sel.to).number + const hasSelection = sel.from !== sel.to + onSelectionRef.current?.(hasSelection ? { from: fromLine, to: toLine } : null) + } + }), + ], + }) + const view = new EditorView({ state, parent: hostRef.current }) + viewRef.current = view + return () => { + view.destroy() + viewRef.current = null + } + // re-create editor only when path changes (language switches) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [path]) + + // Toggle word wrap without recreating editor + useEffect(() => { + const view = viewRef.current + if (!view) return + view.dispatch({ + effects: wrapCompartment.current.reconfigure(wordWrap ? EditorView.lineWrapping : []), + }) + }, [wordWrap]) + + // sync external `value` changes into editor (without losing cursor when same) + useEffect(() => { + const view = viewRef.current + if (!view) return + const cur = view.state.doc.toString() + if (cur !== value) { + view.dispatch({ + changes: { from: 0, to: cur.length, insert: value }, + }) + } + }, [value]) + + return <div ref={hostRef} className="absolute inset-0 overflow-auto" /> +} diff --git a/web/src/components/markdown/Markdown.tsx b/web/src/components/markdown/Markdown.tsx new file mode 100644 index 00000000..24000bf4 --- /dev/null +++ b/web/src/components/markdown/Markdown.tsx @@ -0,0 +1,71 @@ +/** + * Plain markdown renderer using react-markdown + remark-gfm + rehype-highlight. + * Adds wikilink support: [[Target]] and [[Target|Display]] become clickable + * links that call `onWikilink(target)` instead of navigating. + */ +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import rehypeHighlight from "rehype-highlight" +import "highlight.js/styles/github.css" +import { remarkWikilink } from "./wikilink" + +export function Markdown({ + text, + onWikilink, + onTopicClick, +}: { + text: string + onWikilink?: (target: string) => void + onTopicClick?: (name: string) => void +}) { + return ( + <div className="prose-loopat"> + <ReactMarkdown + remarkPlugins={[remarkGfm, remarkWikilink]} + rehypePlugins={[rehypeHighlight]} + components={{ + a: ({ href, children, ...rest }) => { + if (typeof href === "string" && href.startsWith("wikilink:") && onWikilink) { + const target = href.slice("wikilink:".length) + return ( + <a + href="#" + onClick={(e) => { + e.preventDefault() + onWikilink(target) + }} + className="text-blue-700 underline-offset-2 hover:underline" + {...rest} + > + {children} + </a> + ) + } + if (typeof href === "string" && href.startsWith("topic:") && onTopicClick) { + const name = href.slice("topic:".length) + return ( + <button + type="button" + onClick={(e) => { + e.preventDefault() + onTopicClick(name) + }} + className="inline-flex items-center rounded-full bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 transition-colors font-mono px-1.5 py-0.5 text-[10px] mx-0.5 align-baseline" + > + {children} + </button> + ) + } + return ( + <a href={href} target="_blank" rel="noreferrer" className="text-blue-700 hover:underline" {...rest}> + {children} + </a> + ) + }, + }} + > + {text} + </ReactMarkdown> + </div> + ) +} diff --git a/web/src/components/markdown/MilkdownEditor.tsx b/web/src/components/markdown/MilkdownEditor.tsx new file mode 100644 index 00000000..ded24c82 --- /dev/null +++ b/web/src/components/markdown/MilkdownEditor.tsx @@ -0,0 +1,130 @@ +import { useRef } from "react" +import { Editor, rootCtx, defaultValueCtx } from "@milkdown/core" +import { MilkdownProvider, Milkdown, useEditor } from "@milkdown/react" +import { commonmark } from "@milkdown/preset-commonmark" +import { gfm } from "@milkdown/preset-gfm" +import { listener, listenerCtx } from "@milkdown/plugin-listener" + +const proseMirrorStyles = ` +.milkdown-editor .ProseMirror { + min-height: 100%; + outline: none; + padding: 16px 24px; + font-size: 14px; + line-height: 1.7; + color: #374151; +} +.milkdown-editor .ProseMirror p { + margin: 0.5em 0; +} +.milkdown-editor .ProseMirror h1 { + font-size: 1.75em; + font-weight: 700; + margin: 0.6em 0 0.3em; + line-height: 1.3; +} +.milkdown-editor .ProseMirror h2 { + font-size: 1.4em; + font-weight: 600; + margin: 0.6em 0 0.3em; + line-height: 1.35; +} +.milkdown-editor .ProseMirror h3 { + font-size: 1.15em; + font-weight: 600; + margin: 0.5em 0 0.25em; +} +.milkdown-editor .ProseMirror ul, .milkdown-editor .ProseMirror ol { + padding-left: 1.5em; + margin: 0.4em 0; +} +.milkdown-editor .ProseMirror li { + margin: 0.2em 0; +} +.milkdown-editor .ProseMirror code { + background: #f3f4f6; + border-radius: 3px; + padding: 1px 4px; + font-family: ui-monospace, monospace; + font-size: 0.9em; +} +.milkdown-editor .ProseMirror pre { + background: #1f2937; + color: #e5e7eb; + border-radius: 6px; + padding: 12px 16px; + overflow-x: auto; + margin: 0.6em 0; +} +.milkdown-editor .ProseMirror pre code { + background: none; + padding: 0; + font-size: 13px; +} +.milkdown-editor .ProseMirror blockquote { + border-left: 3px solid #d1d5db; + margin: 0.5em 0; + padding: 0.2em 0 0.2em 1em; + color: #6b7280; +} +.milkdown-editor .ProseMirror hr { + border: none; + border-top: 1px solid #e5e7eb; + margin: 1.5em 0; +} +.milkdown-editor .ProseMirror a { + color: #2563eb; + text-decoration: underline; +} +.milkdown-editor .ProseMirror img { + max-width: 100%; + height: auto; + border-radius: 4px; +} +.milkdown-editor .ProseMirror table { + border-collapse: collapse; + width: 100%; + margin: 0.5em 0; +} +.milkdown-editor .ProseMirror th, .milkdown-editor .ProseMirror td { + border: 1px solid #d1d5db; + padding: 6px 10px; + text-align: left; +} +.milkdown-editor .ProseMirror th { + background: #f9fafb; + font-weight: 600; +} +` + +function MilkdownEditorInner({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const onChangeRef = useRef(onChange) + onChangeRef.current = onChange + + useEditor((container) => { + return Editor.make() + .config((ctx) => { + ctx.set(rootCtx, container) + ctx.set(defaultValueCtx, value) + ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, _prev) => { + onChangeRef.current(markdown) + }) + }) + .use(commonmark) + .use(gfm) + .use(listener) + }, []) + + return <Milkdown /> +} + +export function MilkdownEditor({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( + <MilkdownProvider> + <style>{proseMirrorStyles}</style> + <div className="milkdown-editor h-full w-full overflow-auto [&_[data-milkdown-root]>div]:h-full"> + <MilkdownEditorInner value={value} onChange={onChange} /> + </div> + </MilkdownProvider> + ) +} diff --git a/web/src/components/markdown/wikilink.ts b/web/src/components/markdown/wikilink.ts new file mode 100644 index 00000000..b80606c1 --- /dev/null +++ b/web/src/components/markdown/wikilink.ts @@ -0,0 +1,71 @@ +/** + * remark plugin: convert `[[Target]]` and `[[Target|Display]]` into a special + * link node that downstream rendering can pick up. We emit a regular `link` + * mdast node with `url = "wikilink:<target>"` so react-markdown's `a` + * renderer sees it and we handle clicks ourselves. + */ +import type { Plugin } from "unified" +import type { Root, Text, Link, Parent } from "mdast" + +const PATTERN = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g + +export const remarkWikilink: Plugin<[], Root> = () => { + return (tree) => { + visit(tree, "text", (node, index, parent) => { + if (!parent || index === undefined) return + const value = (node as Text).value + if (!value.includes("[[")) return + PATTERN.lastIndex = 0 + let m: RegExpExecArray | null + let last = 0 + const nodes: any[] = [] + while ((m = PATTERN.exec(value)) !== null) { + if (m.index > last) { + nodes.push({ type: "text", value: value.slice(last, m.index) }) + } + const target = m[1].trim() + const display = (m[2] ?? target).trim() + const link: Link = { + type: "link", + url: `wikilink:${target}`, + children: [{ type: "text", value: display }], + } + nodes.push(link) + last = m.index + m[0].length + } + if (last === 0) return + if (last < value.length) { + nodes.push({ type: "text", value: value.slice(last) }) + } + ;(parent as Parent).children.splice(index, 1, ...nodes) + return ["skip", index + nodes.length] + }) + } +} + +// minimal mdast visitor (avoid pulling unist-util-visit dep just for this) +function visit( + tree: any, + type: string, + cb: (node: any, index: number | undefined, parent: any | undefined) => any, +) { + function walk(node: any, index: number | undefined, parent: any | undefined): any { + if (node.type === type) { + const r = cb(node, index, parent) + if (Array.isArray(r) && r[0] === "skip") return r + } + if (Array.isArray(node.children)) { + let i = 0 + while (i < node.children.length) { + const r = walk(node.children[i], i, node) + if (Array.isArray(r) && r[0] === "skip") { + i = r[1] as number + continue + } + i++ + } + } + return undefined + } + walk(tree, undefined, undefined) +} diff --git a/web/src/components/settings/A2ASection.tsx b/web/src/components/settings/A2ASection.tsx new file mode 100644 index 00000000..d1b1861c --- /dev/null +++ b/web/src/components/settings/A2ASection.tsx @@ -0,0 +1,173 @@ +/** + * A2A (Agent-to-Agent) settings: this account exposed as an A2A agent. + * Edit the agent card (name/description), pick the default profiles + vault + * that A2A-created loops use, and (re)generate the service key. + */ +import { useEffect, useState } from "react" +import { getA2A, saveA2A, regenA2AKey, type A2ASettings } from "../../api" + +function CopyRow({ label, value }: { label: string; value: string }) { + const [copied, setCopied] = useState(false) + return ( + <div className="flex items-center gap-2"> + <span className="text-[11px] text-gray-500 w-20 shrink-0">{label}</span> + <code className="flex-1 min-w-0 truncate bg-white border border-gray-200 rounded px-2 py-1 text-[10px] font-mono">{value}</code> + <button + onClick={() => { navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 1500) }} + className="text-[11px] text-gray-500 hover:text-gray-800 shrink-0" + > + {copied ? "copied" : "copy"} + </button> + </div> + ) +} + +export function A2ASection() { + const [a2a, setA2A] = useState<A2ASettings | null>(null) + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [profiles, setProfiles] = useState<string[]>([]) + const [vault, setVault] = useState("") + const [saving, setSaving] = useState(false) + const [savedFlash, setSavedFlash] = useState(false) + const [newKey, setNewKey] = useState<string | null>(null) + const [keyBusy, setKeyBusy] = useState(false) + + const load = async () => { + const r = await getA2A() + if (!r) return + setA2A(r) + setName(r.card.name) + setDescription(r.card.description) + setProfiles(r.profiles) + setVault(r.vault) + } + useEffect(() => { load() }, []) + + const toggleProfile = (p: string) => + setProfiles((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p])) + + const save = async () => { + setSaving(true) + const ok = await saveA2A({ card: { name, description }, profiles, vault }) + setSaving(false) + if (ok) { setSavedFlash(true); setTimeout(() => setSavedFlash(false), 1500); load() } + } + + const regenKey = async () => { + setKeyBusy(true) + const token = await regenA2AKey() + setKeyBusy(false) + if (token) { setNewKey(token); load() } + } + + if (!a2a) return null + + return ( + <div className="rounded-lg border border-gray-200 bg-white px-4 py-3 space-y-3"> + <div className="text-[13px] font-medium text-gray-800">A2A (Agent-to-Agent)</div> + <p className="text-[11px] text-gray-500 leading-relaxed"> + This account is exposed as a standard A2A agent. Register its Agent Card URL with any A2A orchestrator. + </p> + + <div className="space-y-1.5"> + <CopyRow label="Agent Card" value={a2a.cardUrl} /> + <CopyRow label="Endpoint" value={a2a.endpoint} /> + </div> + + {/* Card editor */} + <div className="border-t border-gray-100 pt-3 space-y-2"> + <div className="text-[11px] font-medium text-gray-600">Agent card</div> + <input + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="Agent name (default: Loopat Agent)" + className="w-full h-8 px-2 rounded border border-gray-300 text-[12px] focus:outline-none focus:border-gray-500" + /> + <textarea + value={description} + onChange={(e) => setDescription(e.target.value)} + placeholder="Description shown to orchestrators (what this agent does)" + rows={2} + className="w-full px-2 py-1 rounded border border-gray-300 text-[12px] resize-none focus:outline-none focus:border-gray-500" + /> + </div> + + {/* Default profiles + vault */} + <div className="border-t border-gray-100 pt-3 space-y-2"> + <div className="text-[11px] font-medium text-gray-600">Loops created via A2A use</div> + <div> + <div className="text-[11px] text-gray-500 mb-1">Profiles</div> + {a2a.availableProfiles.length === 0 ? ( + <div className="text-[11px] text-gray-400 italic">no profiles in your knowledge repo</div> + ) : ( + <div className="flex flex-wrap gap-1.5"> + {a2a.availableProfiles.map((p) => ( + <button + key={p} + onClick={() => toggleProfile(p)} + className={`text-[11px] px-2 h-6 rounded border ${ + profiles.includes(p) + ? "bg-gray-900 text-white border-gray-900" + : "bg-white text-gray-600 border-gray-300 hover:bg-gray-50" + }`} + > + {p} + </button> + ))} + </div> + )} + </div> + <div className="flex items-center gap-2"> + <span className="text-[11px] text-gray-500">Vault</span> + <select + value={vault} + onChange={(e) => setVault(e.target.value)} + className="h-7 px-2 rounded border border-gray-300 text-[12px] focus:outline-none focus:border-gray-500" + > + <option value="">default</option> + {a2a.availableVaults.filter((v) => v !== "default").map((v) => ( + <option key={v} value={v}>{v}</option> + ))} + </select> + </div> + </div> + + <div className="flex items-center gap-3 pt-1"> + <button + onClick={save} + disabled={saving} + className="text-[12px] px-3 h-8 rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-40" + > + {saving ? "Saving…" : "Save"} + </button> + {savedFlash && <span className="text-[11px] text-green-700">saved</span>} + </div> + + {/* Key */} + <div className="border-t border-gray-100 pt-3 space-y-2"> + <div className="flex items-center justify-between"> + <div className="text-[11px] font-medium text-gray-600"> + Service key {a2a.hasKey ? <span className="text-green-700">· set</span> : <span className="text-amber-700">· none</span>} + </div> + <button + onClick={regenKey} + disabled={keyBusy} + className="text-[11px] px-2 h-6 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:opacity-40" + > + {keyBusy ? "…" : a2a.hasKey ? "regenerate" : "generate"} + </button> + </div> + <p className="text-[11px] text-gray-500 leading-relaxed"> + The orchestrator presents this token (<code className="bg-gray-100 px-1 rounded text-[10px]">Authorization: Bearer …</code>) to call your agent. Calls run as you, with your providers/keys. + </p> + {newKey && ( + <div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 space-y-1"> + <div className="text-[11px] text-amber-800">Copy now — shown once:</div> + <CopyRow label="Key" value={newKey} /> + </div> + )} + </div> + </div> + ) +} diff --git a/web/src/components/settings/ClaudeConfigPanel.tsx b/web/src/components/settings/ClaudeConfigPanel.tsx new file mode 100644 index 00000000..0b19a971 --- /dev/null +++ b/web/src/components/settings/ClaudeConfigPanel.tsx @@ -0,0 +1,803 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { useNavigate, useSearchParams } from "react-router-dom" +import { useWorkspace } from "@/ctx" +import { getTiers, saveTierSettings, createProfile, deleteProfile, type TierInfo, type TiersResponse } from "@/api" +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip" +import { McpServerEditor, mcpServersFromJson } from "./McpServerEditor" +import { PluginToggleList } from "./PluginToggleList" +import { ChevronDown, ChevronRight, Lock, RefreshCw, Check, Layers, AlertCircle, Globe, User, Blocks, FolderGit2, FileCode2, Plus, Store, Trash2 } from "lucide-react" + +// ── tier metadata ── + +type TierMeta = { + icon: typeof Layers + borderClass: string + badgeClass: string + desc: string +} + +const TIER_META: Record<string, TierMeta> = { + team: { icon: Globe, borderClass: "border-l-violet-400", badgeClass: "bg-violet-100 text-violet-700", desc: "Workspace-wide, set by admin" }, + personal: { icon: User, borderClass: "border-l-emerald-400", badgeClass: "bg-emerald-100 text-emerald-700", desc: "Your personal overrides" }, + project: { icon: FolderGit2, borderClass: "border-l-gray-300", badgeClass: "bg-gray-100 text-gray-500", desc: "SDK reads from workdir" }, + local: { icon: FileCode2, borderClass: "border-l-amber-300", badgeClass: "bg-amber-100 text-amber-700", desc: "Per-checkout .local.* overrides" }, +} + +function getTierMeta(id: string): TierMeta { + if (id.startsWith("profile:")) { + return { icon: Blocks, borderClass: "border-l-blue-400", badgeClass: "bg-blue-100 text-blue-700", desc: "Opt-in role-based config" } + } + return TIER_META[id] ?? TIER_META.team +} + +/** Map tier id to a Context page URL for editing the raw settings.json file. */ +function tierContextUrl(tier: TierInfo): { vault: string; file: string } | null { + if (tier.id === "team") return { vault: "knowledge", file: ".loopat/.claude/settings.json" } + if (tier.id.startsWith("profile:")) { + const name = tier.id.slice("profile:".length) + return { vault: "knowledge", file: `.loopat/profiles/${name}/.claude/settings.json` } + } + if (tier.id === "personal") return { vault: "personal", file: ".loopat/.claude/settings.json" } + return null +} + +function tierOrder(id: string): number { + if (id === "team") return 1 + if (id.startsWith("profile:")) return 2 + if (id === "personal") return 3 + if (id === "project") return 4 + if (id === "local") return 5 + return 99 +} + +function managedBadge(managedBy: string) { + if (managedBy === "admin") return "bg-violet-100 text-violet-700" + if (managedBy === "user") return "bg-gray-100 text-gray-600" + return "bg-gray-100 text-gray-400" +} + +// ── main panel ── + +export function ClaudeConfigPanel({ disabled: parentDisabled }: { disabled?: boolean }) { + const ws = useWorkspace() + const isAdmin = ws.currentUser?.role === "admin" + + const [data, setData] = useState<TiersResponse | null>(null) + const [loading, setLoading] = useState(true) + const [searchParams, setSearchParams] = useSearchParams() + + // expanded state synced to URL ?expand=personal,profile:dev + const expandParam = searchParams.get("expand") ?? "personal" + const expanded = new Set<string>(expandParam.split(",").filter(Boolean)) + const setExpanded = (next: Set<string>) => { + const val = [...next].join(",") + if (val) setSearchParams({ expand: val }, { replace: true }) + else setSearchParams({}, { replace: true }) + } + + const [profilesOpen, setProfilesOpen] = useState(false) + + const refresh = useCallback(async () => { + setLoading(true) + const d = await getTiers() + setData(d) + setLoading(false) + }, []) + + useEffect(() => { refresh() }, [refresh]) + + const toggleExpand = (id: string) => { + const next = new Set(expanded) + if (next.has(id)) next.delete(id) + else next.add(id) + setExpanded(next) + } + + const all = data?.tiers ?? [] + const profileTiers = all.filter((t) => t.id.startsWith("profile:")) + const nonProfileTiers = all.filter((t) => !t.id.startsWith("profile:")).sort((a, b) => tierOrder(a.id) - tierOrder(b.id)) + + // Summed stats across all profiles + const profileSummary = { + pluginCount: profileTiers.reduce((s, t) => s + t.pluginCount, 0), + mcpServerCount: profileTiers.reduce((s, t) => s + t.mcpServerCount, 0), + overrideCount: profileTiers.reduce((s, t) => s + Object.keys(t.overrides).length, 0), + } + + // Build summary bar tiles: team, profiles-group, personal, project, local + const summaryTiles = [ + ...nonProfileTiers.filter((t) => tierOrder(t.id) < 3), + ...(profileTiers.length > 0 ? [{ id: "__profiles__", label: `Profiles (${profileTiers.length})`, tier: profileTiers[0], isGroup: true }] : []), + ...nonProfileTiers.filter((t) => tierOrder(t.id) >= 3), + ] as Array<TierInfo | { id: string; label: string; tier: TierInfo; isGroup: true }> + + if (loading && !data) { + return <div className="flex items-center gap-2 py-12 justify-center text-[13px] text-gray-400"><RefreshCw size={13} className="animate-spin" /> loading tiers…</div> + } + + return ( + <div className="flex flex-col gap-5"> + {/* Intro + stats bar */} + <div className="rounded-lg border border-gray-200 bg-white overflow-hidden transition-shadow hover:shadow-sm"> + <div className="px-4 py-3 border-b border-gray-100 flex items-center gap-2.5"> + <Layers size={15} className="text-gray-400" /> + <div> + <span className="text-[13px] font-medium text-gray-900">Claude Config Composition</span> + <span className="text-[11px] text-gray-400 ml-2"> + {all.filter((t) => t.exists).length} active tiers · merged at loop spawn + </span> + </div> + <div className="flex-1" /> + <button onClick={refresh} disabled={loading} className="p-1.5 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"> + <RefreshCw size={13} className={loading ? "animate-spin" : ""} /> + </button> + </div> + + {/* Tier summary bar */} + <div className="flex flex-wrap gap-px bg-gray-100"> + {summaryTiles.map((tile) => { + if ("isGroup" in tile) { + return <ProfilesTileButton key="__profiles__" profiles={profileTiers} expanded={expanded} summary={profileSummary} open={profilesOpen} onToggle={() => setProfilesOpen(!profilesOpen)} /> + } + const tier = tile as TierInfo + const meta = getTierMeta(tier.id) + const Icon = meta.icon + const isExp = expanded.has(tier.id) + return ( + <button + key={tier.id} + type="button" + onClick={() => toggleExpand(tier.id)} + className={`flex-1 min-w-[120px] flex items-center gap-2 px-3 py-2.5 bg-white hover:bg-gray-50 transition-colors text-left ${isExp ? "bg-gray-50" : ""}`} + > + <Icon size={14} className={`shrink-0 ${tier.exists ? "text-gray-500" : "text-gray-300"}`} /> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-1.5"> + <span className={`text-[11px] font-medium truncate ${tier.exists ? "text-gray-700" : "text-gray-400"}`}> + {tier.label} + </span> + </div> + <div className="text-[10px] text-gray-400 mt-0.5 flex items-center gap-1.5"> + {tier.pluginCount > 0 && <span>{tier.pluginCount}p</span>} + {tier.mcpServerCount > 0 && <span>{tier.mcpServerCount}m</span>} + {tier.pluginCount === 0 && tier.mcpServerCount === 0 && !tier.exists && <span>—</span>} + {Object.keys(tier.overrides).length > 0 && ( + <span className="text-amber-500">{Object.keys(tier.overrides).length}↗</span> + )} + </div> + </div> + {isExp ? <ChevronDown size={10} className="text-gray-400 shrink-0" /> : <ChevronRight size={10} className="text-gray-400 shrink-0" />} + </button> + ) + })} + </div> + + {/* Profiles expansion — full width below the tiles row */} + {profilesOpen && profileTiers.length > 0 && ( + <div className="border-t border-gray-200 bg-gray-50/50 px-4 py-2 space-y-0.5"> + <div className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider px-1 mb-1"> + Select a profile to expand + </div> + {profileTiers.map((p) => { + const isExp = expanded.has(p.id) + return ( + <button + key={p.id} + type="button" + onClick={() => { + toggleExpand(p.id) + setProfilesOpen(false) + // scroll to the expanded tier detail + setTimeout(() => { + document.getElementById(`tier-${p.id}`)?.scrollIntoView({ behavior: "smooth", block: "start" }) + }, 50) + }} + className="w-full flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/80 text-left transition-colors" + > + <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${isExp ? "bg-blue-400" : "bg-gray-300"}`} /> + <span className="text-[12px] text-gray-700 flex-1">{p.label.replace("Profile: ", "")}</span> + <span className="text-[10px] text-gray-400 tabular-nums shrink-0"> + {p.pluginCount > 0 && <>{p.pluginCount}p </>} + {p.mcpServerCount > 0 && <>{p.mcpServerCount}m</>} + {!p.pluginCount && !p.mcpServerCount && "—"} + </span> + {isExp && <ChevronDown size={9} className="text-blue-400 shrink-0" />} + </button> + ) + })} + {/* Create profile (admin only) */} + {isAdmin && ( + <CreateProfileButton + onCreated={(name) => { + setProfilesOpen(false) + refresh().then(() => { + // Auto-expand the new profile + const newId = `profile:${name}` + toggleExpand(newId) + setTimeout(() => { + document.getElementById(`tier-${newId}`)?.scrollIntoView({ behavior: "smooth", block: "start" }) + }, 100) + }) + }} + /> + )} + </div> + )} + </div> + + {/* Expanded tier detail — profile tiers rendered after non-profiles */} + {[...nonProfileTiers, ...profileTiers].map((tier) => ( + <TierDetail + key={tier.id} + tier={tier} + isAdmin={!!isAdmin} + isExpanded={expanded.has(tier.id)} + disabled={parentDisabled} + onSaved={() => refresh()} + /> + ))} + </div> + ) +} + +// ── tier detail card ── + +function TierDetail({ + tier, + isAdmin, + isExpanded, + disabled, + onSaved, +}: { + tier: TierInfo + isAdmin: boolean + isExpanded: boolean + disabled?: boolean + onSaved: () => void +}) { + if (!isExpanded) return null + + const meta = getTierMeta(tier.id) + const Icon = meta.icon + const canEdit = tier.editable && (tier.managedBy === "user" || (tier.managedBy === "admin" && isAdmin)) + const isSdk = tier.managedBy === "sdk" + const isProfile = tier.id.startsWith("profile:") + const profileName = isProfile ? tier.id.slice("profile:".length) : "" + const lockReason = !canEdit && tier.managedBy === "admin" && !isAdmin ? "Admin access required" : null + + const [draft, setDraft] = useState<Record<string, any>>({}) + const [saving, setSaving] = useState(false) + const [err, setErr] = useState<string | null>(null) + const [saved, setSaved] = useState(false) + const navigate = useNavigate() + const focusMarketplaceRef = useRef<(() => void) | null>(null) + + const ctxUrl = tierContextUrl(tier) + + useEffect(() => { + setDraft(tier.settings ? { ...tier.settings } : {}) + setErr(null) + setSaved(false) + }, [tier.settings, tier.id]) + + const handleSave = async () => { + setSaving(true) + setErr(null) + const { _comment, ...clean } = draft + const r = await saveTierSettings(tier.id, clean) + setSaving(false) + if (!r.ok) { setErr(r.error ?? "save failed"); return } + setSaved(true) + setTimeout(() => setSaved(false), 2500) + onSaved() + } + + const overrideCount = Object.keys(tier.overrides).length + + return ( + <div id={`tier-${tier.id}`} className={`rounded-lg border overflow-hidden transition-shadow hover:shadow-sm ${canEdit ? "border-gray-200 bg-white" : "border-gray-200 bg-gray-50/50"}`}> + {/* Header */} + <div className={`flex items-center gap-3 px-4 py-3 border-b border-gray-100 border-l-2 ${meta.borderClass}`}> + <Icon size={16} className="text-gray-500 shrink-0" /> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="text-[14px] font-semibold text-gray-900">{tier.label}</span> + <span className={`text-[9px] px-1.5 py-0.5 rounded-full font-medium ${managedBadge(tier.managedBy)}`}> + {tier.managedBy === "admin" ? "admin" : tier.managedBy === "user" ? "you" : "SDK"} + </span> + {!tier.exists && !isSdk && ( + <span className="text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-full">not created</span> + )} + {lockReason && <Lock size={11} className="text-amber-500 shrink-0" />} + </div> + <div className="text-[11px] text-gray-400 mt-0.5"> + {isSdk ? tier.path : meta.desc} + </div> + </div> + {/* Stat chips */} + <div className="hidden sm:flex items-center gap-1.5 shrink-0"> + <StatChip label="Plugins" value={tier.pluginCount} /> + <StatChip label="MCP" value={tier.mcpServerCount} /> + {tier.skillCount > 0 && <StatChip label="Skills" value={tier.skillCount} />} + {tier.agentCount > 0 && <StatChip label="Agents" value={tier.agentCount} />} + {tier.toolchainCount > 0 && <StatChip label="Toolchain" value={tier.toolchainCount} />} + {overrideCount > 0 && ( + <span className="text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-full font-medium"> + {overrideCount} override{overrideCount > 1 ? "s" : ""} + </span> + )} + </div> + {/* Edit raw in Context */} + {ctxUrl && ( + <Tooltip> + <TooltipTrigger asChild> + <button + onClick={() => navigate(`/context/${ctxUrl.vault}?file=${encodeURIComponent(ctxUrl.file)}&edit=1`)} + className="shrink-0 p-1.5 rounded-lg text-gray-300 hover:text-gray-600 hover:bg-gray-100 transition-colors" + > + <FileCode2 size={13} /> + </button> + </TooltipTrigger> + <TooltipContent>edit {ctxUrl.vault}/{ctxUrl.file}</TooltipContent> + </Tooltip> + )} + </div> + + {/* Body */} + <div className="px-4 py-4 space-y-5"> + {isSdk ? ( + <div className="rounded-lg bg-gray-50 px-3 py-2.5 text-[12px] text-gray-500"> + SDK-managed — read directly from <code className="bg-gray-100 px-1 rounded text-[11px]">{tier.path}</code>. Not editable here. + </div> + ) : ( + <> + {/* File links */} + {ctxUrl && ( + <div className="flex items-center gap-2 text-[11px] text-gray-400"> + <button + onClick={() => navigate(`/context/${ctxUrl.vault}?file=${encodeURIComponent(ctxUrl.file)}&edit=1`)} + className="inline-flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors" + > + <FileCode2 size={11} /> + edit settings.json + </button> + {tier.claudeMd !== null && ( + <button + onClick={() => navigate(`/context/${ctxUrl.vault}?file=${encodeURIComponent(ctxUrl.file.replace("settings.json", "CLAUDE.md"))}`)} + className="inline-flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors" + > + view CLAUDE.md + </button> + )} + <span className="text-gray-300">·</span> + <span className="text-gray-400 truncate font-mono text-[10px]">{ctxUrl.vault}/{ctxUrl.file}</span> + </div> + )} + + {/* Override warnings */} + {overrideCount > 0 && ( + <div className="rounded-lg border border-amber-200 bg-amber-50/50 px-3 py-2"> + <div className="text-[11px] font-medium text-amber-700 mb-1.5"> + Overrides from lower tiers + </div> + <div className="space-y-0.5"> + {Object.entries(tier.overrides).slice(0, 8).map(([key, info]) => ( + <div key={key} className="flex items-center gap-1.5 text-[11px] text-amber-700/80"> + <AlertCircle size={10} className="text-amber-400 shrink-0" /> + <code className="text-[10px] bg-amber-100/50 px-1 rounded">{key}</code> + <span className="text-amber-500">shadows {info.overrides}</span> + </div> + ))} + {overrideCount > 8 && ( + <div className="text-[10px] text-amber-400 ml-5">+{overrideCount - 8} more</div> + )} + </div> + </div> + )} + + {/* Plugins */} + <SubSection title="Plugins" count={tier.pluginCount} defaultOpen={tier.pluginCount > 0}> + {!canEdit && tier.pluginCount === 0 ? ( + <div className="text-[12px] text-gray-400 italic py-2">No plugins configured in this tier</div> + ) : ( + <PluginToggleList + enabledPlugins={(draft?.enabledPlugins as Record<string, boolean>) ?? {}} + readonly={!canEdit || disabled} + focusMarketplaceRef={focusMarketplaceRef} + onChange={(enabled) => setDraft((d) => d ? { ...d, enabledPlugins: enabled } : { enabledPlugins: enabled })} + /> + )} + </SubSection> + + {/* MCP Servers */} + <SubSection title="MCP Servers" count={tier.mcpServerCount} defaultOpen={tier.mcpServerCount > 0}> + <McpServerEditor + servers={mcpServersFromJson(draft)} + readonly={!canEdit || disabled} + onChange={(servers) => setDraft((d) => d ? { ...d, mcpServers: servers } : { mcpServers: servers })} + /> + </SubSection> + + {/* Marketplaces */} + <SubSection + title="Marketplaces" + count={tier.marketplaceCount} + defaultOpen={tier.marketplaceCount > 0 || canEdit} + > + <MarketplaceEditor + marketplaces={(draft?.extraKnownMarketplaces as Record<string, any>) ?? {}} + readonly={!canEdit || disabled} + onChange={(mps) => setDraft((d) => d ? { ...d, extraKnownMarketplaces: mps } : { extraKnownMarketplaces: mps })} + focusRef={focusMarketplaceRef} + /> + </SubSection> + + {/* Hooks (summary only) */} + {tier.hookCount > 0 && ( + <SubSection title="Hooks" count={tier.hookCount} defaultOpen={false}> + <div className="text-[12px] text-gray-500"> + {Object.keys((draft?.hooks as Record<string, any>) ?? {}).length} hook groups configured. + </div> + </SubSection> + )} + + {/* Permissions hint */} + {lockReason && ( + <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-50 text-[12px] text-amber-700"> + <Lock size={12} /> + {lockReason} + </div> + )} + + {/* Delete profile (admin only) */} + {isProfile && isAdmin && ( + <div className="flex items-center justify-between pt-3 border-t border-gray-100"> + <button + onClick={async () => { + if (!confirm(`Delete profile "${profileName}"? This removes all its .claude/ contents permanently.`)) return + const r = await deleteProfile(profileName) + if (!r.ok) { setErr(r.error ?? "delete failed"); return } + onSaved() + }} + className="px-3 h-7 rounded-lg text-[11px] text-red-500 hover:bg-red-50 hover:text-red-600 transition-colors" + > + Delete profile + </button> + <div className="flex items-center gap-2"> + {err && <span className="text-[12px] text-red-600">{err}</span>} + {saved && ( + <span className="text-[12px] text-emerald-600 flex items-center gap-1"> + <Check size={13} /> saved + </span> + )} + <button + onClick={handleSave} + disabled={saving || disabled} + className="px-4 h-8 rounded-lg bg-gray-900 text-white text-xs font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors" + > + {saving ? "Saving…" : "Save"} + </button> + </div> + </div> + )} + + {/* Save (non-profile tiers) */} + {canEdit && !isProfile && ( + <div className="flex items-center justify-end gap-2 pt-3 border-t border-gray-100"> + {err && <span className="text-[12px] text-red-600">{err}</span>} + {saved && ( + <span className="text-[12px] text-emerald-600 flex items-center gap-1"> + <Check size={13} /> saved + </span> + )} + <button + onClick={handleSave} + disabled={saving || disabled} + className="px-4 h-8 rounded-lg bg-gray-900 text-white text-xs font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors" + > + {saving ? "Saving…" : "Save"} + </button> + </div> + )} + </> + )} + </div> + </div> + ) +} + +function StatChip({ label, value }: { label: string; value: number }) { + return ( + <span className="text-[10px] text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded-full font-medium tabular-nums"> + {value} <span className="text-gray-400">{label}</span> + </span> + ) +} + +function SubSection({ + title, + count, + defaultOpen, + children, +}: { + title: string + count: number + defaultOpen: boolean + children: React.ReactNode +}) { + const [open, setOpen] = useState(defaultOpen) + + return ( + <div> + <button + type="button" + onClick={() => setOpen(!open)} + className="flex items-center gap-1.5 mb-2 hover:text-gray-900 transition-colors" + > + {open ? <ChevronDown size={12} className="text-gray-400" /> : <ChevronRight size={12} className="text-gray-400" />} + <span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">{title}</span> + <span className="text-[10px] text-gray-400 tabular-nums">{count}</span> + </button> + {open && <div className="ml-3 pl-3 border-l-2 border-gray-100">{children}</div>} + </div> + ) +} + +// ── marketplace editor ── + +/** Extract a repo name from a git URL. e.g. "https://github.com/owner/repo.git" → "repo" */ +function extractNameFromUrl(url: string): string { + try { + // Strip trailing .git and query/hash + let cleaned = url.trim().replace(/\.git$/, "").split(/[?#]/)[0] + // Get last path segment + const parts = cleaned.replace(/\/$/, "").split("/") + return parts[parts.length - 1] || "" + } catch { return "" } +} + +export function MarketplaceEditor({ + marketplaces, + readonly, + onChange, + focusRef, +}: { + marketplaces: Record<string, any> + readonly?: boolean + onChange: (mps: Record<string, any>) => void + focusRef?: React.MutableRefObject<(() => void) | null> +}) { + const [adding, setAdding] = useState(false) + const [newName, setNewName] = useState("") + const [newSource, setNewSource] = useState<"git" | "github" | "directory">("git") + const [newValue, setNewValue] = useState("") + const [newBranch, setNewBranch] = useState("main") + const [nameTouched, setNameTouched] = useState(false) + const nameInputRef = useRef<HTMLInputElement>(null) + + // Expose a focus function to the parent via ref + useEffect(() => { + if (focusRef) { + focusRef.current = () => { + setAdding(true) + setTimeout(() => nameInputRef.current?.focus(), 50) + } + } + }, [focusRef]) + + const entries = Object.entries(marketplaces) + + const add = () => { + const name = newName.trim() + if (!name || !newValue.trim()) return + let source: any + switch (newSource) { + case "git": source = { source: "git", url: newValue.trim() }; break + case "github": source = { source: "github", repo: newValue.trim() }; break + case "directory": source = { source: "directory", path: newValue.trim() }; break + } + if (newSource !== "directory" && newBranch.trim() && newBranch.trim() !== "main") { + source.branch = newBranch.trim() + } + onChange({ ...marketplaces, [name]: { source } }) + setNewName("") + setNewValue("") + setNewBranch("main") + setNameTouched(false) + setAdding(false) + } + + const remove = (name: string) => { + const { [name]: _, ...rest } = marketplaces + onChange(rest) + } + + return ( + <div className="space-y-1"> + {entries.length === 0 && !adding && ( + <div className="text-[12px] text-gray-400 italic py-2"> + No additional marketplaces. The built-in marketplace is always available. + </div> + )} + {entries.map(([name, entry]) => { + const src = entry?.source ?? {} + const branch = src?.branch + const srcLabel = src.source === "git" ? `git ${src.url ?? ""}${branch ? ` @${branch}` : ""}` + : src.source === "github" ? `github:${src.repo ?? ""}${branch ? ` @${branch}` : ""}` + : src.source === "directory" ? `dir: ${src.path ?? ""}` + : JSON.stringify(src) + return ( + <div key={name} className="group flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors"> + <Store size={13} className="text-gray-400 shrink-0" /> + <div className="flex-1 min-w-0"> + <div className="text-[12px] font-medium text-gray-800 truncate">{name}</div> + <div className="text-[11px] text-gray-400 font-mono truncate">{srcLabel}</div> + </div> + {!readonly && ( + <button + onClick={() => remove(name)} + className="shrink-0 text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all" + title="remove" + > + <Trash2 size={13} /> + </button> + )} + </div> + ) + })} + + {adding && ( + <div className="rounded-lg border border-gray-200 bg-gray-50/50 p-3 space-y-2"> + <div className="flex items-center gap-2"> + <input + ref={nameInputRef} + autoFocus + value={newName} + onChange={(e) => { setNewName(e.target.value); setNameTouched(true) }} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add(); if (e.key === "Escape") { setAdding(false); setNewName(""); setNameTouched(false) } }} + placeholder="marketplace name" + className="flex-1 min-w-0 border border-gray-300 rounded px-2.5 py-1.5 text-[12px] outline-none focus:border-gray-900 bg-white" + /> + <select + value={newSource} + onChange={(e) => { setNewSource(e.target.value as any); if (e.target.value === "directory") { setNameTouched(true) } }} + className="w-24 shrink-0 border border-gray-300 rounded px-2 py-1.5 text-[12px] outline-none focus:border-gray-900 bg-white" + > + <option value="git">git URL</option> + <option value="github">github repo</option> + <option value="directory">directory</option> + </select> + </div> + <input + value={newValue} + onChange={(e) => { + setNewValue(e.target.value) + // Auto-fill name from URL if not manually edited + if ((newSource === "git" || newSource === "github") && !nameTouched) { + const extracted = extractNameFromUrl(e.target.value) + if (extracted) setNewName(extracted) + } + }} + placeholder={newSource === "git" ? "https://..." : newSource === "github" ? "owner/repo" : "/path/to/marketplace"} + className="ip text-[12px] w-full font-mono" + /> + {newSource !== "directory" && ( + <input + value={newBranch} + onChange={(e) => setNewBranch(e.target.value)} + placeholder="branch (default: main)" + className="ip text-[12px] w-full font-mono" + /> + )} + <div className="flex items-center gap-2"> + <button onClick={add} className="px-3 h-7 rounded-lg bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Add</button> + <button onClick={() => { setAdding(false); setNewName(""); setNewValue("") }} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + </div> + )} + + {!readonly && ( + <button + onClick={() => setAdding(true)} + className="flex items-center gap-1.5 px-3 py-2 text-[12px] text-gray-400 hover:text-gray-700 hover:bg-gray-50 rounded-lg w-full transition-colors" + > + <Plus size={13} /> + Add marketplace source + </button> + )} + </div> + ) +} + +// ── profiles dropdown tile ── + +function ProfilesTileButton({ + profiles, + expanded, + summary, + open, + onToggle, +}: { + profiles: TierInfo[] + expanded: Set<string> + summary: { pluginCount: number; mcpServerCount: number; overrideCount: number } + open: boolean + onToggle: () => void +}) { + const anyExpanded = profiles.some((p) => expanded.has(p.id)) + + return ( + <button + type="button" + onClick={onToggle} + className={`flex-1 min-w-[120px] flex items-center gap-2 px-3 py-2.5 bg-white hover:bg-gray-50 transition-colors text-left ${anyExpanded || open ? "bg-gray-50" : ""}`} + > + <Blocks size={14} className="text-blue-400 shrink-0" /> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-1.5"> + <span className="text-[11px] font-medium text-gray-700 truncate"> + Profiles ({profiles.length}) + </span> + </div> + <div className="text-[10px] text-gray-400 mt-0.5 flex items-center gap-1.5"> + {summary.pluginCount > 0 && <span>{summary.pluginCount}p</span>} + {summary.mcpServerCount > 0 && <span>{summary.mcpServerCount}m</span>} + {summary.pluginCount === 0 && summary.mcpServerCount === 0 && <span>—</span>} + {summary.overrideCount > 0 && ( + <span className="text-amber-500">{summary.overrideCount}↗</span> + )} + </div> + </div> + {open ? <ChevronDown size={10} className="text-gray-400 shrink-0" /> : <ChevronRight size={10} className="text-gray-400 shrink-0" />} + </button> + ) +} + +// ── profile lifecycle helpers ── + +function CreateProfileButton({ onCreated }: { onCreated: (name: string) => void }) { + const [adding, setAdding] = useState(false) + const [name, setName] = useState("") + const [err, setErr] = useState<string | null>(null) + + const create = async () => { + const n = name.trim() + if (!n) return + setErr(null) + const r = await createProfile(n) + if (!r.ok) { setErr(r.error ?? "create failed"); return } + setName("") + setAdding(false) + onCreated(n) + } + + if (!adding) { + return ( + <button + onClick={() => setAdding(true)} + className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-gray-400 hover:text-gray-700 hover:bg-white/80 rounded w-full transition-colors" + > + <Plus size={12} /> + Create profile + </button> + ) + } + + return ( + <div className="px-3 py-1.5 space-y-1.5"> + <input + autoFocus + value={name} + onChange={(e) => { setName(e.target.value); setErr(null) }} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) create(); if (e.key === "Escape") { setAdding(false); setName("") } }} + placeholder="profile name" + className="flex-1 min-w-0 border border-gray-300 rounded px-2.5 py-1.5 text-[12px] outline-none focus:border-gray-900 bg-white w-full" + /> + {err && <div className="text-[11px] text-red-600">{err}</div>} + <div className="flex items-center gap-2"> + <button onClick={create} className="px-3 h-7 rounded-lg bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Create</button> + <button onClick={() => { setAdding(false); setName(""); setErr(null) }} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + </div> + ) +} diff --git a/web/src/components/settings/McpServerEditor.tsx b/web/src/components/settings/McpServerEditor.tsx new file mode 100644 index 00000000..3f8872c6 --- /dev/null +++ b/web/src/components/settings/McpServerEditor.tsx @@ -0,0 +1,519 @@ +import { useEffect, useState } from "react" +import { Plus, Trash2, Server, Zap } from "lucide-react" + +// ── parsers for quick-add ── + +/** Try to parse CLI input: `claude mcp add <name> <url> [-t <type>] [-H "K: V"]` */ +function parseCli(input: string): McpServerRow | null { + const s = input.trim() + if (!s.includes("claude") || !s.includes("mcp") || !s.includes("add")) return null + + // Split into tokens respecting quoted strings + const tokens: string[] = [] + let i = 0 + while (i < s.length) { + if (s[i] === " " || s[i] === "\t") { i++; continue } + if (s[i] === '"' || s[i] === "'") { + const quote = s[i] + const end = s.indexOf(quote, i + 1) + if (end === -1) { tokens.push(s.slice(i + 1)); break } + tokens.push(s.slice(i + 1, end)) + i = end + 1 + } else { + let j = i + while (j < s.length && s[j] !== " " && s[j] !== "\t") j++ + tokens.push(s.slice(i, j)) + i = j + } + } + + // Find "add" and take name + url after it + const addIdx = tokens.findIndex((t) => t === "add") + if (addIdx === -1 || addIdx + 2 >= tokens.length) return null + + const name = tokens[Math.min(addIdx + 1, tokens.length - 1)] + const second = tokens[Math.min(addIdx + 2, tokens.length - 1)] + + // Determine if second token is URL or command + let url = "" + let command = "" + let type: McpServerRow["type"] = "stdio" + const headers: string[] = [] + const envs: string[] = [] + + if (second.startsWith("http://") || second.startsWith("https://")) { + url = second + type = "http" + } else if (!second.startsWith("-")) { + command = second + } + + for (let ti = addIdx + 3; ti < tokens.length; ti++) { + const t = tokens[ti] + if (t === "-t" || t === "--type") { + const v = tokens[++ti] + if (v === "http" || v === "sse" || v === "stdio") type = v + } else if (t === "-H" || t === "--header") { + headers.push(tokens[++ti] ?? "") + } else if (t === "-e" || t === "--env") { + envs.push(tokens[++ti] ?? "") + } else if (t === "-a" || t === "--args") { + // args follow — collect until next flag + } else if (!t.startsWith("-")) { + if (!command) command = t + } + } + + return { + name, + type, + url, + command, + args: "", + env: envs.join("\n"), + headers: headers.join("\n"), + } +} + +/** Try to parse JSON input: { "mcpServers": { "name": { ... } } } */ +function parseJson(input: string): McpServerRow | null { + try { + const obj = JSON.parse(input.trim()) + // Handle both { mcpServers: { name: {...} } } and just { name: {...} } + let servers: Record<string, any> + if (obj.mcpServers) { + servers = obj.mcpServers + } else if (obj.mcp) { + servers = obj.mcp + } else if (typeof Object.values(obj)[0] === "object" && Object.values(obj)[0] !== null && !Array.isArray(Object.values(obj)[0])) { + servers = obj + } else { + return null + } + const [name, srv] = Object.entries(servers)[0] ?? [] + if (!name || !srv || typeof srv !== "object") return null + + const type = srv?.type === "http" || srv?.type === "sse" || srv?.type === "remote" ? "http" as const + : srv?.type === "sse" ? "sse" as const + : "stdio" as const + + return { + name, + type, + url: srv?.url ?? "", + command: srv?.command ?? "", + args: srv?.args ? (Array.isArray(srv.args) ? srv.args.join(" ") : String(srv.args)) : "", + env: srv?.env ? Object.entries(srv.env).map(([k, v]) => `${k}=${v}`).join("\n") : "", + headers: srv?.headers ? Object.entries(srv.headers).map(([k, v]) => `${k}: ${v}`).join("\n") : "", + } + } catch { + return null + } +} + +function parseQuickAdd(input: string): McpServerRow | null { + return parseJson(input) ?? parseCli(input) +} + +export type McpServerRow = { + name: string + type: "stdio" | "http" | "sse" + url: string + command: string + args: string + env: string + headers: string +} + +function emptyRow(): McpServerRow { + return { name: "", type: "stdio", url: "", command: "", args: "", env: "", headers: "" } +} + +function rowFromJson(name: string, srv: any): McpServerRow { + return { + name, + type: (srv?.type === "http" || srv?.type === "sse" || srv?.type === "remote") ? "http" : "stdio", + url: srv?.url ?? "", + command: srv?.command ?? "", + args: srv?.args ? (Array.isArray(srv.args) ? srv.args.join(" ") : String(srv.args)) : "", + env: srv?.env ? Object.entries(srv.env).map(([k, v]) => `${k}=${v}`).join("\n") : "", + headers: srv?.headers ? Object.entries(srv.headers).map(([k, v]) => `${k}: ${v}`).join("\n") : "", + } +} + +function rowToJson(r: McpServerRow): any { + if (r.type === "http" || r.type === "sse") { + const out: any = { type: r.type, url: r.url } + if (r.headers.trim()) { + out.headers = Object.fromEntries( + r.headers.split("\n").filter(Boolean).map((line) => { + const colon = line.indexOf(":") + return colon >= 0 ? [line.slice(0, colon).trim(), line.slice(colon + 1).trim()] : [line.trim(), ""] + }), + ) + } + return out + } + const out: any = { command: r.command } + if (r.args.trim()) out.args = r.args.split(/\s+/) + if (r.env.trim()) { + out.env = Object.fromEntries( + r.env.split("\n").filter(Boolean).map((line) => { + const eq = line.indexOf("=") + return eq >= 0 ? [line.slice(0, eq), line.slice(eq + 1)] : [line, ""] + }), + ) + } + return out +} + +export function mcpServersFromJson(obj: Record<string, any> | null): Map<string, any> { + if (!obj?.mcpServers) return new Map() + return new Map(Object.entries(obj.mcpServers)) +} + +export function McpServerEditor({ + servers, + onChange, + readonly, +}: { + servers: Map<string, any> + onChange: (servers: Record<string, any>) => void + readonly?: boolean +}) { + const buildRows = () => [...servers.entries()].map(([k, v]) => rowFromJson(k, v)) + const [rows, setRows] = useState<McpServerRow[]>(buildRows) + const [adding, setAdding] = useState(false) + const [newRow, setNewRow] = useState<McpServerRow>(emptyRow()) + const [editing, setEditing] = useState<number | null>(null) + + // Sync rows when servers prop changes (e.g. parent re-fetches data after save) + useEffect(() => { + setRows(buildRows()) + setEditing(null) + setAdding(false) + }, [servers]) + + const emit = (rs: McpServerRow[]) => { + setRows(rs) + const out: Record<string, any> = {} + for (const r of rs) { + if (!r.name.trim()) continue + out[r.name] = rowToJson(r) + } + onChange(out) + } + + const update = (i: number, patch: Partial<McpServerRow>) => { + emit(rows.map((r, idx) => idx === i ? { ...r, ...patch } : r)) + } + + const remove = (i: number) => { + emit(rows.filter((_, idx) => idx !== i)) + setEditing(null) + } + + const commitAdd = () => { + if (!newRow.name.trim()) return + emit([...rows, newRow]) + setNewRow(emptyRow()) + setAdding(false) + } + + if (rows.length === 0 && !adding) { + return ( + <div className="py-3"> + <div className="flex flex-col items-center gap-1.5 text-center py-3"> + <Server size={18} className="text-gray-300" /> + <div className="text-[12px] text-gray-400">No MCP servers</div> + <div className="text-[11px] text-gray-400/70"> + Add servers to extend Claude with external tools. + </div> + </div> + {!readonly && ( + <button + onClick={() => setAdding(true)} + className="flex items-center gap-1.5 px-3 py-2 text-[12px] text-gray-400 hover:text-gray-700 hover:bg-gray-50 rounded-lg w-full transition-colors" + > + <Plus size={13} /> + Add MCP server + </button> + )} + </div> + ) + } + + return ( + <div className="space-y-1"> + {rows.map((r, i) => { + const isEditing = editing === i + return ( + <ServerRow + key={i} + row={r} + isEditing={isEditing} + readonly={readonly} + onEdit={() => setEditing(isEditing ? null : i)} + onCommit={(patch) => { + update(i, patch) + if (!isEditing) setEditing(null) + }} + onRemove={() => remove(i)} + /> + ) + })} + + {adding && ( + <div className="rounded-lg border border-gray-200 bg-blue-50/20 p-3 space-y-2"> + <div className="flex items-center gap-2"> + <input + autoFocus + value={newRow.name} + onChange={(e) => setNewRow((r) => ({ ...r, name: e.target.value }))} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) commitAdd(); if (e.key === "Escape") { setAdding(false); setNewRow(emptyRow()) } }} + placeholder="server name" + className="flex-1 min-w-0 border border-gray-300 rounded px-2.5 py-1.5 text-[12px] font-medium outline-none focus:border-gray-900 bg-white" + /> + <select + value={newRow.type} + onChange={(e) => setNewRow((r) => ({ ...r, type: e.target.value as McpServerRow["type"] }))} + className="w-20 shrink-0 border border-gray-300 rounded px-2 py-1.5 text-[12px] outline-none focus:border-gray-900 bg-white" + > + <option value="stdio">stdio</option> + <option value="http">http</option> + <option value="sse">sse</option> + </select> + </div> + {newRow.type === "http" || newRow.type === "sse" ? ( + <input + value={newRow.url} + onChange={(e) => setNewRow((r) => ({ ...r, url: e.target.value }))} + placeholder="https://example.com/mcp" + className="ip text-[12px] w-full font-mono" + /> + ) : ( + <div className="space-y-1.5"> + <input + value={newRow.command} + onChange={(e) => setNewRow((r) => ({ ...r, command: e.target.value }))} + placeholder="command (e.g. npx, uvx)" + className="ip text-[12px] w-full font-mono" + /> + <input + value={newRow.args} + onChange={(e) => setNewRow((r) => ({ ...r, args: e.target.value }))} + placeholder="args (space-separated)" + className="ip text-[12px] w-full font-mono" + /> + <textarea + value={newRow.env} + onChange={(e) => setNewRow((r) => ({ ...r, env: e.target.value }))} + placeholder="env vars (KEY=val, one per line)" + className="ip text-[12px] w-full font-mono resize-none h-14" + /> + </div> + )} + {(newRow.type === "http" || newRow.type === "sse") && ( + <textarea + value={newRow.headers} + onChange={(e) => setNewRow((r) => ({ ...r, headers: e.target.value }))} + placeholder="headers (Key: Value, one per line)" + className="ip text-[12px] w-full font-mono resize-none h-14" + /> + )} + <div className="flex items-center gap-2"> + <button onClick={commitAdd} className="px-3 h-7 rounded-lg bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Add</button> + <button onClick={() => { setAdding(false); setNewRow(emptyRow()) }} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + </div> + )} + + {!readonly && ( + <> + <QuickAdd onParse={(row) => { setNewRow(row); setAdding(true) }} /> + <button + onClick={() => setAdding(true)} + className="flex items-center gap-1.5 px-3 py-2 text-[12px] text-gray-400 hover:text-gray-700 hover:bg-gray-50 rounded-lg w-full transition-colors" + > + <Plus size={13} /> + Add MCP server + </button> + </> + )} + </div> + ) +} + +function ServerRow({ + row, + isEditing, + readonly, + onEdit, + onCommit, + onRemove, +}: { + row: McpServerRow + isEditing: boolean + readonly?: boolean + onEdit: () => void + onCommit: (patch: Partial<McpServerRow>) => void + onRemove: () => void +}) { + if (!isEditing) { + return ( + <div + className="group flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer" + onClick={readonly ? undefined : onEdit} + > + <span className={`w-2 h-2 rounded-full shrink-0 ${row.type === "stdio" ? "bg-purple-400" : "bg-blue-400"}`} /> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2 min-w-0"> + <span className="text-[13px] font-medium text-gray-800 truncate">{row.name || <span className="text-gray-400 italic">unnamed</span>}</span> + <span className="text-[10px] text-gray-400 uppercase shrink-0">{row.type}</span> + </div> + <div className="text-[11px] text-gray-400 truncate mt-0.5 font-mono"> + {row.type === "http" || row.type === "sse" ? row.url || "—" : row.command || "—"} + </div> + </div> + {!readonly && ( + <button + onClick={(e) => { e.stopPropagation(); onRemove() }} + className="shrink-0 text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all" + title="remove" + > + <Trash2 size={13} /> + </button> + )} + </div> + ) + } + + return ( + <div className="rounded-lg border border-gray-200 p-3 space-y-2"> + <div className="flex items-center gap-2 min-w-0"> + <input + value={row.name} + onChange={(e) => onCommit({ name: e.target.value })} + placeholder="server name" + className="flex-1 min-w-0 border border-gray-300 rounded px-2.5 py-1.5 text-[12px] font-medium outline-none focus:border-gray-900 bg-white" + /> + <select + value={row.type} + onChange={(e) => onCommit({ type: e.target.value as McpServerRow["type"] })} + className="w-20 shrink-0 border border-gray-300 rounded px-2 py-1.5 text-[12px] outline-none focus:border-gray-900 bg-white" + > + <option value="stdio">stdio</option> + <option value="http">http</option> + <option value="sse">sse</option> + </select> + <button onClick={onRemove} className="text-gray-300 hover:text-red-500 shrink-0" title="remove"> + <Trash2 size={13} /> + </button> + </div> + {row.type === "http" || row.type === "sse" ? ( + <input + value={row.url} + onChange={(e) => onCommit({ url: e.target.value })} + placeholder="https://example.com/mcp" + className="ip text-[12px] w-full font-mono" + /> + ) : ( + <div className="space-y-1.5"> + <input + value={row.command} + onChange={(e) => onCommit({ command: e.target.value })} + placeholder="command (e.g. npx, uvx)" + className="ip text-[12px] w-full font-mono" + /> + <input + value={row.args} + onChange={(e) => onCommit({ args: e.target.value })} + placeholder="args (space-separated)" + className="ip text-[12px] w-full font-mono" + /> + <textarea + value={row.env} + onChange={(e) => onCommit({ env: e.target.value })} + placeholder="env vars (KEY=val, one per line)" + className="ip text-[12px] w-full font-mono resize-none h-14" + /> + </div> + )} + {(row.type === "http" || row.type === "sse") && ( + <textarea + value={row.headers} + onChange={(e) => onCommit({ headers: e.target.value })} + placeholder="headers (Key: Value, one per line)" + className="ip text-[12px] w-full font-mono resize-none h-14" + /> + )} + <div className="flex items-center gap-2"> + <button onClick={onEdit} className="px-2.5 h-7 rounded-lg bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Done</button> + </div> + </div> + ) +} + +// ── quick-add: paste JSON or CLI command ── + +function QuickAdd({ onParse }: { onParse: (row: McpServerRow) => void }) { + const [open, setOpen] = useState(false) + const [input, setInput] = useState("") + const [err, setErr] = useState<string | null>(null) + + const handleParse = () => { + const trimmed = input.trim() + if (!trimmed) return + setErr(null) + const result = parseQuickAdd(trimmed) + if (!result) { + setErr("Couldn't parse. Paste a JSON object with mcpServers, or a `claude mcp add ...` command.") + return + } + onParse(result) + setInput("") + setOpen(false) + setErr(null) + } + + if (!open) { + return ( + <button + onClick={() => setOpen(true)} + className="flex items-center gap-1.5 px-3 py-2 text-[12px] text-gray-400 hover:text-gray-700 hover:bg-gray-50 rounded-lg w-full transition-colors" + > + <Zap size={13} /> + Paste JSON / CLI + </button> + ) + } + + return ( + <div className="rounded-lg border border-gray-200 bg-gray-50/50 p-3 space-y-2"> + <div className="flex items-center gap-2"> + <Zap size={13} className="text-amber-500 shrink-0" /> + <span className="text-[12px] font-medium text-gray-700">Paste JSON or CLI command</span> + <div className="flex-1" /> + <button onClick={() => { setOpen(false); setInput(""); setErr(null) }} className="text-[11px] text-gray-400 hover:text-gray-600">cancel</button> + </div> + <textarea + autoFocus + value={input} + onChange={(e) => { setInput(e.target.value); setErr(null) }} + onKeyDown={(e) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleParse() + }} + placeholder={`Paste JSON:\n{ "mcpServers": { "name": { "type": "http", "url": "...", "headers": { "Key": "Value" } } } }\n\nOr CLI:\nclaude mcp add name url -t http -H "Key: Value"`} + className="ip text-[12px] w-full font-mono resize-y min-h-[80px]" + rows={4} + /> + {err && ( + <div className="text-[11px] text-red-600">{err}</div> + )} + <div className="flex items-center gap-2"> + <button onClick={handleParse} className="px-3 h-7 rounded-lg bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Parse &amp; Fill</button> + <span className="text-[10px] text-gray-400">or Ctrl+Enter</span> + </div> + </div> + ) +} diff --git a/web/src/components/settings/MiseConfigPanel.tsx b/web/src/components/settings/MiseConfigPanel.tsx new file mode 100644 index 00000000..31d6c7c0 --- /dev/null +++ b/web/src/components/settings/MiseConfigPanel.tsx @@ -0,0 +1,1517 @@ +import { useCallback, useEffect, useState } from "react" +import { useNavigate, useSearchParams } from "react-router-dom" +import { useWorkspace } from "@/ctx" +import { getTiers, getTierMiseConfig, saveTierMiseConfig, createProfile, deleteProfile, getAdminPresets, type TierInfo, type TiersResponse, type MiseToolPreset } from "@/api" +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip" +import { parse as parseToml, stringify as stringifyToml } from "smol-toml" +import { + ChevronDown, ChevronRight, Lock, RefreshCw, Check, Layers, + Globe, User, Blocks, FolderGit2, FileCode2, Plus, Trash2, Terminal, + ExternalLink, +} from "lucide-react" + +// ── tier metadata ── + +type TierMeta = { + icon: typeof Layers + borderClass: string + badgeClass: string + desc: string +} + +const TIER_META: Record<string, TierMeta> = { + team: { icon: Globe, borderClass: "border-l-violet-400", badgeClass: "bg-violet-100 text-violet-700", desc: "Workspace-wide, set by admin" }, + personal: { icon: User, borderClass: "border-l-emerald-400", badgeClass: "bg-emerald-100 text-emerald-700", desc: "Your personal overrides" }, + project: { icon: FolderGit2, borderClass: "border-l-gray-300", badgeClass: "bg-gray-100 text-gray-500", desc: "SDK reads from workdir" }, + local: { icon: FileCode2, borderClass: "border-l-amber-300", badgeClass: "bg-amber-100 text-amber-700", desc: "Per-checkout .local.* overrides" }, +} + +function getTierMeta(id: string): TierMeta { + if (id.startsWith("profile:")) { + return { icon: Blocks, borderClass: "border-l-blue-400", badgeClass: "bg-blue-100 text-blue-700", desc: "Opt-in role-based config" } + } + return TIER_META[id] ?? TIER_META.team +} + +function tierOrder(id: string): number { + if (id === "team") return 1 + if (id.startsWith("profile:")) return 2 + if (id === "personal") return 3 + if (id === "project") return 4 + if (id === "local") return 5 + return 99 +} + +function managedBadge(managedBy: string) { + if (managedBy === "admin") return "bg-violet-100 text-violet-700" + if (managedBy === "user") return "bg-gray-100 text-gray-600" + return "bg-gray-100 text-gray-400" +} + +function miseContextUrl(tier: TierInfo): { vault: string; file: string } | null { + if (tier.id === "team") return { vault: "knowledge", file: ".loopat/.claude/mise.toml" } + if (tier.id.startsWith("profile:")) { + const name = tier.id.slice("profile:".length) + return { vault: "knowledge", file: `.loopat/profiles/${name}/.claude/mise.toml` } + } + if (tier.id === "personal") return { vault: "personal", file: ".loopat/.claude/mise.toml" } + return null +} + +// ── input styling ── + +const inputClass = "w-full px-2.5 py-1.5 border border-gray-300 rounded text-[12px] outline-none bg-white focus:border-gray-900 focus:ring-1 focus:ring-gray-900 transition-colors font-mono" +const inputClassSm = "w-full px-2 py-1 border border-gray-300 rounded text-[11px] outline-none bg-white focus:border-gray-900 font-mono" + +// ── TOML parse/rebuild ── + +type MiseData = { + tools: Record<string, { version: string; backend?: string }> + env: Record<string, string> + envFile: string + envPath: string[] + tasks: Record<string, MiseTask> + alias: Record<string, Record<string, string>> + settings: Record<string, any> + plugins: Record<string, string> + hooks: Record<string, string> +} + +type MiseTask = { + description?: string + run?: string + dir?: string + depends?: string[] + waitFor?: string[] + sources?: string[] + outputs?: string[] + hide?: boolean + raw?: boolean + env?: Record<string, string> + runWindows?: string +} + +function parseMiseToml(content: string): MiseData { + let parsed: Record<string, any> = {} + try { parsed = parseToml(content) as Record<string, any> } catch { /* raw edit fallback */ } + + // ── tools ── + const tools: Record<string, { version: string; backend?: string }> = {} + if (parsed.tools && typeof parsed.tools === "object") { + for (const [k, v] of Object.entries(parsed.tools as Record<string, any>)) { + if (k.startsWith("_")) continue + if (typeof v === "string") { + tools[k] = { version: v } + } else if (v && typeof v === "object") { + tools[k] = { + version: typeof v.version === "string" ? v.version : String(v.version ?? ""), + backend: typeof v.backend === "string" ? v.backend : undefined, + } + } + } + } + + // ── env ── + const env: Record<string, string> = {} + let envFile = "" + let envPath: string[] = [] + if (parsed.env && typeof parsed.env === "object") { + for (const [k, v] of Object.entries(parsed.env as Record<string, any>)) { + if (k === "_.file" && typeof v === "string") { envFile = v; continue } + if (k === "_.path" && Array.isArray(v)) { envPath = v.map(String); continue } + if (typeof v === "string") env[k] = v + else if (Array.isArray(v)) env[k] = v.join(":") + else if (v != null) env[k] = String(v) + } + } + + // ── tasks ── + const tasks: Record<string, MiseTask> = {} + if (parsed.tasks && typeof parsed.tasks === "object") { + for (const [k, v] of Object.entries(parsed.tasks as Record<string, any>)) { + if (!v || typeof v !== "object") continue + const runWin = (v as any)?.run_windows + const t: MiseTask = { + description: typeof v.description === "string" ? v.description : undefined, + run: typeof v.run === "string" ? v.run : undefined, + dir: typeof v.dir === "string" ? v.dir : undefined, + depends: Array.isArray(v.depends) ? v.depends.map(String) : undefined, + waitFor: Array.isArray(v.wait_for) ? v.wait_for.map(String) : undefined, + sources: Array.isArray(v.sources) ? v.sources.map(String) : undefined, + outputs: Array.isArray(v.outputs) ? v.outputs.map(String) : undefined, + hide: typeof v.hide === "boolean" ? v.hide : undefined, + raw: typeof v.raw === "boolean" ? v.raw : undefined, + env: v.env && typeof v.env === "object" ? Object.fromEntries( + Object.entries(v.env as Record<string, any>).map(([ek, ev]) => [ek, String(ev)]) + ) : undefined, + runWindows: typeof runWin === "string" ? runWin : undefined, + } + tasks[k] = t + } + } + + // ── alias ── + const alias: Record<string, Record<string, string>> = {} + if (parsed.alias && typeof parsed.alias === "object") { + for (const [tool, entries] of Object.entries(parsed.alias as Record<string, any>)) { + if (entries && typeof entries === "object") { + alias[tool] = {} + for (const [k, v] of Object.entries(entries as Record<string, any>)) { + alias[tool][k] = String(v) + } + } + } + } + + // ── settings ── + const settings: Record<string, any> = {} + if (parsed.settings && typeof parsed.settings === "object") { + for (const [k, v] of Object.entries(parsed.settings as Record<string, any>)) { + if (k.startsWith("_")) continue + settings[k] = v + } + } + + // ── plugins ── + const plugins: Record<string, string> = {} + if (parsed.plugins && typeof parsed.plugins === "object") { + for (const [k, v] of Object.entries(parsed.plugins as Record<string, any>)) { + plugins[k] = v && typeof v === "object" && typeof (v as any).url === "string" + ? (v as any).url + : typeof v === "string" ? v : "" + } + } + + // ── hooks ── + const hooks: Record<string, string> = {} + if (parsed.hooks && typeof parsed.hooks === "object") { + for (const [k, v] of Object.entries(parsed.hooks as Record<string, any>)) { + if (typeof v === "string") hooks[k] = v + } + } + + return { tools, env, envFile, envPath, tasks, alias, settings, plugins, hooks } +} + +function buildMiseToml(data: MiseData): string { + const obj: Record<string, any> = {} + + // tools + if (Object.keys(data.tools).length > 0) { + obj.tools = {} as Record<string, any> + for (const [k, t] of Object.entries(data.tools)) { + if (t.backend) { + obj.tools[k] = { version: t.version, backend: t.backend } + } else { + obj.tools[k] = t.version + } + } + } + + // env + const hasEnv = Object.keys(data.env).length > 0 || data.envFile || data.envPath.length > 0 + if (hasEnv) { + obj.env = {} as Record<string, any> + if (data.envFile) (obj.env as any)["_.file"] = data.envFile + if (data.envPath.length > 0) (obj.env as any)["_.path"] = data.envPath + Object.assign(obj.env, data.env) + } + + // tasks + if (Object.keys(data.tasks).length > 0) { + obj.tasks = {} as Record<string, any> + for (const [k, t] of Object.entries(data.tasks)) { + const taskObj: Record<string, any> = {} + if (t.description) taskObj.description = t.description + if (t.run) taskObj.run = t.run + if (t.dir) taskObj.dir = t.dir + if (t.depends && t.depends.length > 0) taskObj.depends = t.depends + if (t.waitFor && t.waitFor.length > 0) taskObj.wait_for = t.waitFor + if (t.sources && t.sources.length > 0) taskObj.sources = t.sources + if (t.outputs && t.outputs.length > 0) taskObj.outputs = t.outputs + if (t.hide !== undefined) taskObj.hide = t.hide + if (t.raw !== undefined) taskObj.raw = t.raw + if (t.env && Object.keys(t.env).length > 0) taskObj.env = t.env + if (t.runWindows) taskObj.run_windows = t.runWindows + obj.tasks[k] = taskObj + } + } + + // alias + if (Object.keys(data.alias).length > 0) { + obj.alias = data.alias + } + + // settings + if (Object.keys(data.settings).length > 0) { + obj.settings = data.settings + } + + // plugins + if (Object.keys(data.plugins).length > 0) { + obj.plugins = {} as Record<string, any> + for (const [k, url] of Object.entries(data.plugins)) { + obj.plugins[k] = { url } + } + } + + // hooks + if (Object.keys(data.hooks).length > 0) { + obj.hooks = data.hooks + } + + if (Object.keys(obj).length === 0) return "" + return stringifyToml(obj as any) +} + +// ── main panel ── + +type SectionId = "tools" | "env" | "tasks" | "alias" | "settings" | "plugins" | "hooks" | "raw" + +const SECTIONS: { id: SectionId; label: string; desc: string }[] = [ + { id: "tools", label: "Tools", desc: "Tool versions (node, python, rust, …)" }, + { id: "env", label: "Env", desc: "Environment variables" }, + { id: "tasks", label: "Tasks", desc: "Build / run tasks" }, + { id: "alias", label: "Alias", desc: "Tool version aliases" }, + { id: "settings", label: "Settings", desc: "mise behavior flags" }, + { id: "plugins", label: "Plugins", desc: "asdf plugin sources" }, + { id: "hooks", label: "Hooks", desc: "Lifecycle hook commands" }, + { id: "raw", label: "Raw TOML", desc: "Direct TOML editing" }, +] + +export function MiseConfigPanel({ disabled: parentDisabled }: { disabled?: boolean }) { + const ws = useWorkspace() + const isAdmin = ws.currentUser?.role === "admin" + + const [data, setData] = useState<TiersResponse | null>(null) + const [loading, setLoading] = useState(true) + const [searchParams, setSearchParams] = useSearchParams() + + const expandParam = searchParams.get("expand") ?? "personal" + const expanded = new Set<string>(expandParam.split(",").filter(Boolean)) + const setExpanded = (next: Set<string>) => { + const val = [...next].join(",") + if (val) setSearchParams({ expand: val }, { replace: true }) + else setSearchParams({}, { replace: true }) + } + + const [profilesOpen, setProfilesOpen] = useState(false) + + const refresh = useCallback(async () => { + setLoading(true) + const d = await getTiers() + setData(d) + setLoading(false) + }, []) + + useEffect(() => { refresh() }, [refresh]) + + const toggleExpand = (id: string) => { + const next = new Set(expanded) + if (next.has(id)) next.delete(id) + else next.add(id) + setExpanded(next) + } + + const all = data?.tiers ?? [] + const profileTiers = all.filter((t) => t.id.startsWith("profile:")) + const nonProfileTiers = all.filter((t) => !t.id.startsWith("profile:")).sort((a, b) => tierOrder(a.id) - tierOrder(b.id)) + + const profileToolSum = profileTiers.reduce((s, t) => s + t.toolchainCount, 0) + + const summaryTiles = [ + ...nonProfileTiers.filter((t) => tierOrder(t.id) < 3), + ...(profileTiers.length > 0 ? [{ id: "__profiles__", label: `Profiles (${profileTiers.length})`, tier: profileTiers[0], isGroup: true }] : []), + ...nonProfileTiers.filter((t) => tierOrder(t.id) >= 3), + ] as Array<TierInfo | { id: string; label: string; tier: TierInfo; isGroup: true }> + + if (loading && !data) { + return <div className="flex items-center gap-2 py-12 justify-center text-[13px] text-gray-400"><RefreshCw size={13} className="animate-spin" /> loading tiers…</div> + } + + return ( + <div className="flex flex-col gap-5"> + {/* Header */} + <div className="rounded-lg border border-gray-200 bg-white overflow-hidden transition-shadow hover:shadow-sm"> + <div className="px-4 py-3 border-b border-gray-100 flex items-center gap-2.5"> + <Terminal size={15} className="text-gray-400" /> + <div> + <span className="text-[13px] font-medium text-gray-900">Mise Config</span> + <span className="text-[11px] text-gray-400 ml-2"> + mise.toml per tier — tools, env, tasks, aliases, settings, plugins, hooks + </span> + </div> + <div className="flex-1" /> + <a + href="https://mise.jdx.dev/configuration.html" + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center gap-1 px-2 py-1 rounded text-[10px] text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors" + > + <ExternalLink size={10} /> docs + </a> + <button onClick={refresh} disabled={loading} className="p-1.5 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"> + <RefreshCw size={13} className={loading ? "animate-spin" : ""} /> + </button> + </div> + + {/* Tier summary bar */} + <div className="flex flex-wrap gap-px bg-gray-100"> + {summaryTiles.map((tile) => { + if ("isGroup" in tile) { + return ( + <ProfilesTileButton + key="__profiles__" + profiles={profileTiers} + expanded={expanded} + toolSum={profileToolSum} + open={profilesOpen} + onToggle={() => setProfilesOpen(!profilesOpen)} + /> + ) + } + const tier = tile as TierInfo + const meta = getTierMeta(tier.id) + const Icon = meta.icon + const isExp = expanded.has(tier.id) + return ( + <button + key={tier.id} + type="button" + onClick={() => toggleExpand(tier.id)} + className={`flex-1 min-w-[120px] flex items-center gap-2 px-3 py-2.5 bg-white hover:bg-gray-50 transition-colors text-left ${isExp ? "bg-gray-50" : ""}`} + > + <Icon size={14} className={`shrink-0 ${tier.exists ? "text-gray-500" : "text-gray-300"}`} /> + <div className="min-w-0 flex-1"> + <span className={`text-[11px] font-medium truncate ${tier.exists ? "text-gray-700" : "text-gray-400"}`}> + {tier.label} + </span> + <div className="text-[10px] text-gray-400 mt-0.5"> + {tier.toolchainCount > 0 ? `${tier.toolchainCount} tools` : tier.exists ? "0 tools" : "—"} + </div> + </div> + {isExp ? <ChevronDown size={10} className="text-gray-400 shrink-0" /> : <ChevronRight size={10} className="text-gray-400 shrink-0" />} + </button> + ) + })} + </div> + + {/* Profiles dropdown */} + {profilesOpen && profileTiers.length > 0 && ( + <div className="border-t border-gray-200 bg-gray-50/50 px-4 py-2 space-y-0.5"> + <div className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider px-1 mb-1"> + Select a profile to expand + </div> + {profileTiers.map((p) => { + const isExp = expanded.has(p.id) + return ( + <button + key={p.id} + type="button" + onClick={() => { + toggleExpand(p.id) + setProfilesOpen(false) + setTimeout(() => { + document.getElementById(`tier-${p.id}`)?.scrollIntoView({ behavior: "smooth", block: "start" }) + }, 50) + }} + className="w-full flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/80 text-left transition-colors" + > + <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${isExp ? "bg-blue-400" : "bg-gray-300"}`} /> + <span className="text-[12px] text-gray-700 flex-1">{p.label.replace("Profile: ", "")}</span> + <span className="text-[10px] text-gray-400 tabular-nums shrink-0"> + {p.toolchainCount > 0 ? `${p.toolchainCount} tools` : "—"} + </span> + {isExp && <ChevronDown size={9} className="text-blue-400 shrink-0" />} + </button> + ) + })} + {isAdmin && ( + <CreateProfileButton + onCreated={(name) => { + setProfilesOpen(false) + refresh().then(() => { + const newId = `profile:${name}` + toggleExpand(newId) + setTimeout(() => { + document.getElementById(`tier-${newId}`)?.scrollIntoView({ behavior: "smooth", block: "start" }) + }, 100) + }) + }} + /> + )} + </div> + )} + </div> + + {/* Expanded tier details */} + {[...nonProfileTiers, ...profileTiers].map((tier) => ( + <MiseTierDetail + key={tier.id} + tier={tier} + isAdmin={!!isAdmin} + isExpanded={expanded.has(tier.id)} + disabled={parentDisabled} + onSaved={() => refresh()} + /> + ))} + </div> + ) +} + +// ── profiles tile button ── + +function ProfilesTileButton({ + profiles, expanded, toolSum, open, onToggle, +}: { + profiles: TierInfo[]; expanded: Set<string>; toolSum: number; open: boolean; onToggle: () => void +}) { + const anyExpanded = profiles.some((p) => expanded.has(p.id)) + return ( + <button + type="button" onClick={onToggle} + className={`flex-1 min-w-[120px] flex items-center gap-2 px-3 py-2.5 bg-white hover:bg-gray-50 transition-colors text-left ${anyExpanded || open ? "bg-gray-50" : ""}`} + > + <Blocks size={14} className="text-blue-400 shrink-0" /> + <div className="min-w-0 flex-1"> + <span className="text-[11px] font-medium text-gray-700">Profiles ({profiles.length})</span> + <div className="text-[10px] text-gray-400 mt-0.5">{toolSum > 0 ? `${toolSum} tools` : "—"}</div> + </div> + {open ? <ChevronDown size={10} className="text-gray-400 shrink-0" /> : <ChevronRight size={10} className="text-gray-400 shrink-0" />} + </button> + ) +} + +// ── create profile ── + +function CreateProfileButton({ onCreated }: { onCreated: (name: string) => void }) { + const [adding, setAdding] = useState(false) + const [name, setName] = useState("") + const [err, setErr] = useState<string | null>(null) + const create = async () => { + const n = name.trim(); if (!n) return; setErr(null) + const r = await createProfile(n) + if (!r.ok) { setErr(r.error ?? "create failed"); return } + setName(""); setAdding(false); onCreated(n) + } + if (!adding) return ( + <button onClick={() => setAdding(true)} className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-gray-400 hover:text-gray-700 hover:bg-white/80 rounded w-full transition-colors"> + <Plus size={12} /> Create profile + </button> + ) + return ( + <div className="px-3 py-1.5 space-y-1.5"> + <input autoFocus value={name} onChange={(e) => { setName(e.target.value); setErr(null) }} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) create(); if (e.key === "Escape") { setAdding(false); setName("") } }} + placeholder="profile name" className={inputClass} /> + {err && <div className="text-[11px] text-red-600">{err}</div>} + <div className="flex items-center gap-2"> + <button onClick={create} className="px-3 h-7 rounded-lg bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Create</button> + <button onClick={() => { setAdding(false); setName(""); setErr(null) }} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + </div> + ) +} + +// ═══════════════════════════════════════════════════════════════ +// Tier detail card — the main editor for a single tier +// ═══════════════════════════════════════════════════════════════ + +function MiseTierDetail({ + tier, isAdmin, isExpanded, disabled, onSaved, +}: { + tier: TierInfo; isAdmin: boolean; isExpanded: boolean; disabled?: boolean; onSaved: () => void +}) { + if (!isExpanded) return null + + const meta = getTierMeta(tier.id) + const Icon = meta.icon + const canEdit = tier.editable && (tier.managedBy === "user" || (tier.managedBy === "admin" && isAdmin)) + const isSdk = tier.managedBy === "sdk" + const isProfile = tier.id.startsWith("profile:") + const profileName = isProfile ? tier.id.slice("profile:".length) : "" + const lockReason = !canEdit && tier.managedBy === "admin" && !isAdmin ? "Admin access required" : null + const navigate = useNavigate() + const ctxUrl = miseContextUrl(tier) + + const [rawContent, setRawContent] = useState("") + const [data, setData] = useState<MiseData>({ tools: {}, env: {}, envFile: "", envPath: [], tasks: {}, alias: {}, settings: {}, plugins: {}, hooks: {} }) + const [activeSection, setActiveSection] = useState<SectionId>("tools") + const [loaded, setLoaded] = useState(false) + const [saving, setSaving] = useState(false) + const [err, setErr] = useState<string | null>(null) + const [saved, setSaved] = useState(false) + + useEffect(() => { + if (!isExpanded) return + ;(async () => { + const r = await getTierMiseConfig(tier.id) + const c = r.exists ? r.content : "" + setRawContent(c) + setData(parseMiseToml(c)) + setLoaded(true) + setErr(null) + setSaved(false) + })() + }, [isExpanded, tier.id]) + + const handleSave = async () => { + setSaving(true); setErr(null) + const newContent = activeSection === "raw" ? rawContent : buildMiseToml(data) + const r = await saveTierMiseConfig(tier.id, newContent) + setSaving(false) + if (!r.ok) { setErr(r.error ?? "save failed"); return } + // Re-sync rawContent and parsed data after save from structured mode + if (activeSection !== "raw") { + setRawContent(newContent) + setData(parseMiseToml(newContent)) + } else { + setData(parseMiseToml(rawContent)) + } + setSaved(true) + setTimeout(() => setSaved(false), 2500) + onSaved() + } + + const updateData = (patch: Partial<MiseData>) => { + setData((d) => ({ ...d, ...patch })) + setSaved(false) + } + + const rawEdited = () => { + setData(parseMiseToml(rawContent)) + setSaved(false) + } + + const hasContent = Object.keys(data.tools).length > 0 || + Object.keys(data.env).length > 0 || data.envFile || data.envPath.length > 0 || + Object.keys(data.tasks).length > 0 || + Object.keys(data.alias).length > 0 || + Object.keys(data.settings).length > 0 || + Object.keys(data.plugins).length > 0 || + Object.keys(data.hooks).length > 0 + + if (!loaded) { + return ( + <div id={`tier-${tier.id}`} className="rounded-lg border border-gray-200 bg-white p-4 text-[12px] text-gray-400 italic"> + loading mise.toml… + </div> + ) + } + + return ( + <div id={`tier-${tier.id}`} className={`rounded-lg border overflow-hidden transition-shadow hover:shadow-sm ${canEdit ? "border-gray-200 bg-white" : "border-gray-200 bg-gray-50/50"}`}> + {/* Header */} + <div className={`flex items-center gap-3 px-4 py-3 border-b border-gray-100 border-l-2 ${meta.borderClass}`}> + <Icon size={16} className="text-gray-500 shrink-0" /> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="text-[14px] font-semibold text-gray-900">{tier.label}</span> + <span className={`text-[9px] px-1.5 py-0.5 rounded-full font-medium ${managedBadge(tier.managedBy)}`}> + {tier.managedBy === "admin" ? "admin" : tier.managedBy === "user" ? "you" : "SDK"} + </span> + {!tier.exists && !isSdk && ( + <span className="text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-full">not created</span> + )} + {lockReason && <Lock size={11} className="text-amber-500 shrink-0" />} + </div> + <div className="text-[11px] text-gray-400 mt-0.5">{isSdk ? tier.path : meta.desc}</div> + </div> + {/* Stat chips */} + <div className="hidden sm:flex items-center gap-1 shrink-0"> + {Object.keys(data.tools).length > 0 && <StatChip label="tools" value={Object.keys(data.tools).length} />} + {Object.keys(data.tasks).length > 0 && <StatChip label="tasks" value={Object.keys(data.tasks).length} />} + {Object.keys(data.env).length > 0 && <StatChip label="env" value={Object.keys(data.env).length} />} + </div> + {ctxUrl && ( + <Tooltip> + <TooltipTrigger asChild> + <button + onClick={() => navigate(`/context/${ctxUrl.vault}?file=${encodeURIComponent(ctxUrl.file)}&edit=1`)} + className="shrink-0 p-1.5 rounded-lg text-gray-300 hover:text-gray-600 hover:bg-gray-100 transition-colors" + > + <FileCode2 size={13} /> + </button> + </TooltipTrigger> + <TooltipContent>edit {ctxUrl.vault}/{ctxUrl.file}</TooltipContent> + </Tooltip> + )} + </div> + + {/* Body */} + <div className="px-4 py-4 space-y-4"> + {isSdk ? ( + <div className="rounded-lg bg-gray-50 px-3 py-2.5 text-[12px] text-gray-500"> + SDK-managed — mise.toml is read from <code className="bg-gray-100 px-1 rounded text-[11px]">{tier.path}/mise.toml</code>. Not editable here. + </div> + ) : ( + <> + {/* Section tabs */} + <div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5 overflow-x-auto"> + {SECTIONS.map((s) => ( + <button + key={s.id} + onClick={() => setActiveSection(s.id)} + className={`px-3 py-1 rounded text-[11px] font-medium whitespace-nowrap transition-colors ${activeSection === s.id ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`} + > + {s.label} + </button> + ))} + </div> + <p className="text-[10px] text-gray-400 -mt-3">{SECTIONS.find(s => s.id === activeSection)?.desc}</p> + + {/* ── Tools Editor ── */} + {activeSection === "tools" && ( + <ToolsEditor + tools={data.tools} + readonly={!canEdit || !!disabled} + onChange={(tools) => updateData({ tools })} + /> + )} + + {/* ── Env Editor ── */} + {activeSection === "env" && ( + <EnvEditor + env={data.env} + envFile={data.envFile} + envPath={data.envPath} + readonly={!canEdit || !!disabled} + onChange={(env, envFile, envPath) => updateData({ env, envFile, envPath })} + /> + )} + + {/* ── Tasks Editor ── */} + {activeSection === "tasks" && ( + <TasksEditor + tasks={data.tasks} + readonly={!canEdit || !!disabled} + onChange={(tasks) => updateData({ tasks })} + /> + )} + + {/* ── Alias Editor ── */} + {activeSection === "alias" && ( + <AliasEditor + alias={data.alias} + readonly={!canEdit || !!disabled} + onChange={(alias) => updateData({ alias })} + /> + )} + + {/* ── Settings Editor ── */} + {activeSection === "settings" && ( + <SettingsEditor + settings={data.settings} + readonly={!canEdit || !!disabled} + onChange={(settings) => updateData({ settings })} + /> + )} + + {/* ── Plugins Editor ── */} + {activeSection === "plugins" && ( + <PluginsEditor + plugins={data.plugins} + readonly={!canEdit || !!disabled} + onChange={(plugins) => updateData({ plugins })} + /> + )} + + {/* ── Hooks Editor ── */} + {activeSection === "hooks" && ( + <HooksEditor + hooks={data.hooks} + readonly={!canEdit || !!disabled} + onChange={(hooks) => updateData({ hooks })} + /> + )} + + {/* ── Raw Editor ── */} + {activeSection === "raw" && ( + <div className="space-y-2"> + <textarea + value={rawContent} + onChange={(e) => { setRawContent(e.target.value); setSaved(false) }} + onBlur={rawEdited} + readOnly={!canEdit || disabled} + spellCheck={false} + className="w-full h-80 px-3 py-2 border border-gray-300 rounded text-[12px] font-mono outline-none bg-white focus:border-gray-900 resize-y disabled:bg-gray-50 disabled:text-gray-500" + /> + </div> + )} + + {/* Lock warning */} + {lockReason && ( + <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-50 text-[12px] text-amber-700"> + <Lock size={12} /> {lockReason} + </div> + )} + + {/* Save bar */} + {canEdit && ( + <div className={`flex items-center gap-2 pt-3 border-t border-gray-100 ${isProfile ? "justify-between" : "justify-end"}`}> + {isProfile && isAdmin && ( + <button + onClick={async () => { + if (!confirm(`Delete profile "${profileName}"?`)) return + const r = await deleteProfile(profileName) + if (!r.ok) { setErr(r.error ?? "delete failed"); return } + onSaved() + }} + className="px-3 h-7 rounded-lg text-[11px] text-red-500 hover:bg-red-50 hover:text-red-600 transition-colors" + > + Delete profile + </button> + )} + <div className="flex items-center gap-2"> + {!hasContent && activeSection !== "raw" && !rawContent && ( + <span className="text-[11px] text-gray-400 italic">empty — save will create the file</span> + )} + {err && <span className="text-[12px] text-red-600">{err}</span>} + {saved && ( + <span className="text-[12px] text-emerald-600 flex items-center gap-1"><Check size={13} /> saved</span> + )} + <button + onClick={handleSave} + disabled={saving || disabled} + className="px-4 h-8 rounded-lg bg-gray-900 text-white text-xs font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors" + > + {saving ? "Saving…" : "Save"} + </button> + </div> + </div> + )} + </> + )} + </div> + </div> + ) +} + +function StatChip({ label, value }: { label: string; value: number }) { + return ( + <span className="text-[10px] text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded-full font-medium tabular-nums"> + {value} <span className="text-gray-400">{label}</span> + </span> + ) +} + +// ═══════════════════════════════════════════════════════════════ +// Section editors +// ═══════════════════════════════════════════════════════════════ + +// ── Tools ── + +function ToolsEditor({ tools, readonly, onChange }: { + tools: Record<string, { version: string; backend?: string }> + readonly: boolean + onChange: (tools: Record<string, { version: string; backend?: string }>) => void +}) { + const entries = Object.entries(tools) + const [adding, setAdding] = useState(false) + const [newName, setNewName] = useState("") + const [newVersion, setNewVersion] = useState("") + const [newBackend, setNewBackend] = useState("") + const [suggestions, setSuggestions] = useState<MiseToolPreset[]>([]) + + useEffect(() => { getAdminPresets().then(d => setSuggestions(d.miseToolPresets)).catch(() => {}) }, []) + + const quickAdd = (s: MiseToolPreset) => { + onChange({ ...tools, [s.name]: { version: s.suggestedVersion, backend: s.backend } }) + } + + const add = () => { + const n = newName.trim(); if (!n) return + onChange({ ...tools, [n]: { version: newVersion.trim() || "latest", backend: newBackend.trim() || undefined } }) + setNewName(""); setNewVersion(""); setNewBackend(""); setAdding(false) + } + + const remove = (name: string) => { + const { [name]: _, ...rest } = tools; onChange(rest) + } + + const update = (name: string, patch: Partial<{ version: string; backend?: string }>) => { + const cur = tools[name] + const next = { ...cur, ...patch } + if (next.backend === "") next.backend = undefined + onChange({ ...tools, [name]: next }) + } + + return ( + <div className="space-y-3"> + {entries.length === 0 && !adding && ( + <div className="text-[12px] text-gray-400 italic py-3">No tools. Add one to get started.</div> + )} + {entries.length > 0 && ( + <div className="border border-gray-200 rounded-lg overflow-hidden"> + <table className="w-full text-[13px]"> + <thead> + <tr className="border-b border-gray-100 bg-gray-50/50 text-left text-[10px] text-gray-500 uppercase tracking-wider"> + <th className="px-3 py-2 font-medium">Tool</th> + <th className="px-3 py-2 font-medium">Version</th> + <th className="px-3 py-2 font-medium hidden sm:table-cell">Backend</th> + {!readonly && <th className="px-3 py-2 font-medium w-10"></th>} + </tr> + </thead> + <tbody> + {entries.map(([name, t]) => ( + <tr key={name} className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors"> + <td className="px-3 py-2"> + {readonly ? ( + <code className="text-[12px] text-gray-800">{name}</code> + ) : ( + <input value={name} onChange={(e) => { + const { [name]: v, ...rest } = tools + onChange({ ...rest, [e.target.value]: v }) + }} className={inputClassSm} /> + )} + </td> + <td className="px-3 py-2"> + {readonly ? ( + <code className="text-[12px] text-gray-600">{t.version}</code> + ) : ( + <input value={t.version} onChange={(e) => update(name, { version: e.target.value })} className={`w-24 ${inputClassSm}`} /> + )} + </td> + <td className="px-3 py-2 hidden sm:table-cell"> + {readonly ? ( + <code className="text-[11px] text-gray-500">{t.backend || "—"}</code> + ) : ( + <input value={t.backend ?? ""} onChange={(e) => update(name, { backend: e.target.value })} placeholder="e.g. aqua:hashicorp/terraform" className={`w-44 ${inputClassSm}`} /> + )} + </td> + {!readonly && ( + <td className="px-3 py-2"> + <button onClick={() => remove(name)} className="text-gray-300 hover:text-red-500 transition-colors"><Trash2 size={12} /></button> + </td> + )} + </tr> + ))} + </tbody> + </table> + </div> + )} + {!readonly && ( + <> + {adding ? ( + <div className="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50/50"> + <div className="grid grid-cols-1 sm:grid-cols-3 gap-2"> + <input autoFocus value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add(); if (e.key === "Escape") setAdding(false) }} placeholder="tool name" className={inputClassSm} /> + <input value={newVersion} onChange={(e) => setNewVersion(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add() }} placeholder="version (e.g. 3.12)" className={inputClassSm} /> + <input value={newBackend} onChange={(e) => setNewBackend(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add() }} placeholder="backend (optional)" className={inputClassSm} /> + </div> + <div className="flex items-center gap-2"> + <button onClick={add} className="px-3 h-7 rounded bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Add</button> + <button onClick={() => setAdding(false)} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + </div> + ) : ( + <button onClick={() => setAdding(true)} className="text-[11px] text-gray-500 hover:text-gray-900 inline-flex items-center gap-1 transition-colors"> + <Plus size={11} /> add tool + </button> + )} + + {/* Suggested tool presets */} + {suggestions.filter(s => !tools[s.name]).length > 0 && ( + <div className="border-t border-gray-100 pt-3 mt-2"> + <span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Suggested tools</span> + <div className="flex flex-wrap items-center gap-1.5 mt-2"> + {suggestions.filter(s => !tools[s.name]).map(s => ( + <button + key={s.name} + onClick={() => quickAdd(s)} + className="px-2 py-0.5 rounded border border-gray-200 bg-white text-[10px] text-gray-500 hover:text-gray-900 hover:border-gray-400 transition-colors" + title={s.description} + > + + {s.name} {s.suggestedVersion} + </button> + ))} + </div> + </div> + )} + </> + )} + </div> + ) +} + +// ── Env ── + +function EnvEditor({ env, envFile, envPath, readonly, onChange }: { + env: Record<string, string>; envFile: string; envPath: string[]; readonly: boolean + onChange: (env: Record<string, string>, envFile: string, envPath: string[]) => void +}) { + const entries = Object.entries(env) + const [adding, setAdding] = useState(false) + const [newKey, setNewKey] = useState("") + const [newVal, setNewVal] = useState("") + + const add = () => { + const k = newKey.trim(); if (!k) return + onChange({ ...env, [k]: newVal }, envFile, envPath) + setNewKey(""); setNewVal(""); setAdding(false) + } + + const remove = (key: string) => { + const { [key]: _, ...rest } = env; onChange(rest, envFile, envPath) + } + + const update = (key: string, val: string) => { + onChange({ ...env, [key]: val }, envFile, envPath) + } + + return ( + <div className="space-y-4"> + {/* _.file */} + <div className="flex items-center gap-3"> + <label className="text-[11px] font-medium text-gray-500 w-20 shrink-0">_.file</label> + <input + value={envFile} onChange={(e) => onChange(env, e.target.value, envPath)} + readOnly={readonly} placeholder=".env (load from dotenv file)" + className={inputClassSm + " flex-1"} + /> + </div> + + {/* _.path */} + <div className="flex items-center gap-3"> + <label className="text-[11px] font-medium text-gray-500 w-20 shrink-0">_.path</label> + <input + value={envPath.join(", ")} onChange={(e) => onChange(env, envFile, e.target.value.split(",").map(s => s.trim()).filter(Boolean))} + readOnly={readonly} placeholder="./node_modules/.bin (comma-separated PATH additions)" + className={inputClassSm + " flex-1"} + /> + </div> + + <div className="border-t border-gray-100 pt-3"> + <div className="flex items-center justify-between mb-2"> + <span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">Variables</span> + {!readonly && ( + <button onClick={() => setAdding(true)} className="text-[11px] text-gray-500 hover:text-gray-900 inline-flex items-center gap-1"> + <Plus size={11} /> add + </button> + )} + </div> + {entries.length === 0 && !adding && ( + <div className="text-[12px] text-gray-400 italic py-2">No env vars set.</div> + )} + {entries.length > 0 && ( + <div className="border border-gray-200 rounded-lg overflow-hidden"> + <table className="w-full text-[13px]"> + <thead> + <tr className="border-b border-gray-100 bg-gray-50/50 text-left text-[10px] text-gray-500 uppercase tracking-wider"> + <th className="px-3 py-2 font-medium w-1/3">Name</th> + <th className="px-3 py-2 font-medium">Value</th> + {!readonly && <th className="px-3 py-2 font-medium w-10"></th>} + </tr> + </thead> + <tbody> + {entries.map(([k, v]) => ( + <tr key={k} className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors"> + <td className="px-3 py-2"> + {readonly ? <code className="text-[12px] text-gray-800">{k}</code> : <input value={k} onChange={(e) => { const { [k]: val, ...rest } = env; onChange({ ...rest, [e.target.value]: val }, envFile, envPath) }} className={inputClassSm} />} + </td> + <td className="px-3 py-2"> + {readonly ? <code className="text-[12px] text-gray-600">{v}</code> : <input value={v} onChange={(e) => update(k, e.target.value)} className={inputClassSm} />} + </td> + {!readonly && ( + <td className="px-3 py-2"><button onClick={() => remove(k)} className="text-gray-300 hover:text-red-500"><Trash2 size={12} /></button></td> + )} + </tr> + ))} + </tbody> + </table> + </div> + )} + {!readonly && adding && ( + <div className="flex items-center gap-2 mt-2"> + <input autoFocus value={newKey} onChange={(e) => setNewKey(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add(); if (e.key === "Escape") setAdding(false) }} placeholder="VAR_NAME" className={inputClassSm + " flex-1"} /> + <input value={newVal} onChange={(e) => setNewVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add() }} placeholder="value" className={inputClassSm + " flex-1"} /> + <button onClick={add} className="px-3 h-7 rounded bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Add</button> + <button onClick={() => setAdding(false)} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + )} + </div> + </div> + ) +} + +// ── Tasks ── + +function TasksEditor({ tasks, readonly, onChange }: { + tasks: Record<string, MiseTask>; readonly: boolean + onChange: (tasks: Record<string, MiseTask>) => void +}) { + const entries = Object.entries(tasks) + const [expandedTask, setExpandedTask] = useState<string | null>(null) + const [adding, setAdding] = useState(false) + const [newTaskName, setNewTaskName] = useState("") + + const add = () => { + const n = newTaskName.trim(); if (!n) return + onChange({ ...tasks, [n]: { run: "" } }) + setNewTaskName(""); setAdding(false); setExpandedTask(n) + } + + const remove = (name: string) => { + const { [name]: _, ...rest } = tasks; onChange(rest) + if (expandedTask === name) setExpandedTask(null) + } + + const update = (name: string, patch: Partial<MiseTask>) => { + onChange({ ...tasks, [name]: { ...tasks[name], ...patch } }) + } + + return ( + <div className="space-y-3"> + {entries.length === 0 && !adding && ( + <div className="text-[12px] text-gray-400 italic py-3">No tasks defined. Add a build or run task.</div> + )} + {entries.map(([name, t]) => { + const isExp = expandedTask === name + return ( + <div key={name} className="border border-gray-200 rounded-lg overflow-hidden"> + <button + onClick={() => setExpandedTask(isExp ? null : name)} + className="w-full flex items-center gap-2 px-3 py-2 hover:bg-gray-50 transition-colors text-left" + > + {isExp ? <ChevronDown size={12} className="text-gray-400 shrink-0" /> : <ChevronRight size={12} className="text-gray-400 shrink-0" />} + <code className="text-[13px] font-medium text-gray-800 flex-1">{name}</code> + {t.description && <span className="text-[11px] text-gray-400 truncate hidden sm:inline">{t.description}</span>} + {!readonly && ( + <button onClick={(e) => { e.stopPropagation(); remove(name) }} className="text-gray-300 hover:text-red-500 ml-1"> + <Trash2 size={12} /> + </button> + )} + </button> + {isExp && ( + <div className="border-t border-gray-100 px-4 py-3 space-y-3 bg-gray-50/30"> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> + <Labeled label="Run command"> + <input value={t.run ?? ""} onChange={(e) => update(name, { run: e.target.value })} readOnly={readonly} placeholder="npm run build" className={inputClass} /> + </Labeled> + <Labeled label="Description"> + <input value={t.description ?? ""} onChange={(e) => update(name, { description: e.target.value })} readOnly={readonly} placeholder="Build the project" className={inputClass} /> + </Labeled> + <Labeled label="Working dir"> + <input value={t.dir ?? ""} onChange={(e) => update(name, { dir: e.target.value })} readOnly={readonly} placeholder="{{config_root}}/frontend" className={inputClass} /> + </Labeled> + <Labeled label="Run (Windows)"> + <input value={t.runWindows ?? ""} onChange={(e) => update(name, { runWindows: e.target.value })} readOnly={readonly} placeholder="npm.cmd run build" className={inputClass} /> + </Labeled> + <Labeled label="Depends on (comma-sep)"> + <input value={t.depends?.join(", ") ?? ""} onChange={(e) => update(name, { depends: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })} readOnly={readonly} placeholder="lint, test" className={inputClass} /> + </Labeled> + <Labeled label="Wait for (comma-sep)"> + <input value={t.waitFor?.join(", ") ?? ""} onChange={(e) => update(name, { waitFor: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })} readOnly={readonly} placeholder="db:5432" className={inputClass} /> + </Labeled> + <Labeled label="Sources (comma-sep)"> + <input value={t.sources?.join(", ") ?? ""} onChange={(e) => update(name, { sources: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })} readOnly={readonly} placeholder="src/**/*.ts" className={inputClass} /> + </Labeled> + <Labeled label="Outputs (comma-sep)"> + <input value={t.outputs?.join(", ") ?? ""} onChange={(e) => update(name, { outputs: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })} readOnly={readonly} placeholder="dist/**/*.js" className={inputClass} /> + </Labeled> + </div> + <div className="flex items-center gap-4"> + <label className="flex items-center gap-2 text-[12px] text-gray-600 cursor-pointer"> + <input type="checkbox" checked={t.hide ?? false} onChange={(e) => update(name, { hide: e.target.checked || undefined })} disabled={readonly} className="h-3.5 w-3.5 rounded border-gray-300" /> + Hide in <code className="text-[11px] bg-gray-100 px-1 rounded">mise run</code> list + </label> + <label className="flex items-center gap-2 text-[12px] text-gray-600 cursor-pointer"> + <input type="checkbox" checked={t.raw ?? false} onChange={(e) => update(name, { raw: e.target.checked || undefined })} disabled={readonly} className="h-3.5 w-3.5 rounded border-gray-300" /> + Raw (pass directly to shell) + </label> + </div> + {/* Task env vars */} + <details className="text-[11px]"> + <summary className="text-gray-400 cursor-pointer hover:text-gray-600">Task environment variables ({Object.keys(t.env ?? {}).length})</summary> + <div className="mt-2 space-y-1"> + {(Object.entries(t.env ?? {})).map(([ek, ev]) => ( + <div key={ek} className="flex items-center gap-2"> + <input value={ek} onChange={(e) => { + const cur = { ...(t.env ?? {}) } + const val = cur[ek] ?? "" + delete cur[ek] + cur[e.target.value] = val + update(name, { env: cur }) + }} readOnly={readonly} placeholder="VAR" className={inputClassSm + " w-40"} /> + <span className="text-gray-400">=</span> + <input value={ev} onChange={(e) => { + update(name, { env: { ...(t.env ?? {}), [ek]: e.target.value } }) + }} readOnly={readonly} placeholder="value" className={inputClassSm + " flex-1"} /> + {!readonly && ( + <button onClick={() => { + const { [ek]: _, ...rest } = (t.env ?? {}) + update(name, { env: Object.keys(rest).length > 0 ? rest : undefined }) + }} className="text-gray-300 hover:text-red-500"><Trash2 size={11} /></button> + )} + </div> + ))} + {!readonly && ( + <button + onClick={() => update(name, { env: { ...(t.env ?? {}), "": "" } })} + className="text-[10px] text-gray-400 hover:text-gray-600 inline-flex items-center gap-1 mt-1" + ><Plus size={10} /> add env var</button> + )} + </div> + </details> + </div> + )} + </div> + ) + })} + {!readonly && ( + <> + {adding ? ( + <div className="flex items-center gap-2"> + <input autoFocus value={newTaskName} onChange={(e) => setNewTaskName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add(); if (e.key === "Escape") setAdding(false) }} placeholder="task name" className={inputClassSm + " flex-1"} /> + <button onClick={add} className="px-3 h-7 rounded bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Add</button> + <button onClick={() => setAdding(false)} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + ) : ( + <button onClick={() => setAdding(true)} className="text-[11px] text-gray-500 hover:text-gray-900 inline-flex items-center gap-1"> + <Plus size={11} /> add task + </button> + )} + </> + )} + </div> + ) +} + +// ── Alias ── + +function AliasEditor({ alias, readonly, onChange }: { + alias: Record<string, Record<string, string>>; readonly: boolean + onChange: (alias: Record<string, Record<string, string>>) => void +}) { + const [addingTool, setAddingTool] = useState("") + const [addingAlias, setAddingAlias] = useState("") + const [addingVersion, setAddingVersion] = useState("") + const [showAdd, setShowAdd] = useState(false) + + const add = () => { + const tool = addingTool.trim(); const al = addingAlias.trim(); if (!tool || !al) return + onChange({ ...alias, [tool]: { ...(alias[tool] ?? {}), [al]: addingVersion.trim() || "latest" } }) + setAddingTool(""); setAddingAlias(""); setAddingVersion(""); setShowAdd(false) + } + + const remove = (tool: string, al: string) => { + const { [al]: _, ...rest } = alias[tool] + if (Object.keys(rest).length === 0) { + const { [tool]: __, ...restAlias } = alias + onChange(restAlias) + } else { + onChange({ ...alias, [tool]: rest }) + } + } + + const entries = Object.entries(alias) + + return ( + <div className="space-y-3"> + {entries.length === 0 && !showAdd && ( + <div className="text-[12px] text-gray-400 italic py-3">No aliases. Create version aliases for tools (e.g. node:myapp → 22.11.0).</div> + )} + {entries.map(([tool, aliases]) => ( + <div key={tool} className="border border-gray-200 rounded-lg overflow-hidden"> + <div className="px-3 py-2 bg-gray-50/50 border-b border-gray-100 text-[11px] font-semibold text-gray-500 uppercase tracking-wider"> + {tool} + </div> + {Object.entries(aliases).map(([al, ver]) => ( + <div key={al} className="flex items-center gap-2 px-3 py-1.5 border-b border-gray-50 hover:bg-gray-50/50"> + <code className="text-[12px] text-gray-600 w-32 truncate">{al}</code> + <span className="text-gray-300">→</span> + <code className="text-[12px] text-gray-800 flex-1">{ver}</code> + {!readonly && ( + <button onClick={() => remove(tool, al)} className="text-gray-300 hover:text-red-500"><Trash2 size={11} /></button> + )} + </div> + ))} + </div> + ))} + {!readonly && ( + <> + {showAdd ? ( + <div className="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50/50"> + <div className="grid grid-cols-3 gap-2"> + <input autoFocus value={addingTool} onChange={(e) => setAddingTool(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add(); if (e.key === "Escape") setShowAdd(false) }} placeholder="tool (e.g. node)" className={inputClassSm} /> + <input value={addingAlias} onChange={(e) => setAddingAlias(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add() }} placeholder="alias (e.g. myapp)" className={inputClassSm} /> + <input value={addingVersion} onChange={(e) => setAddingVersion(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add() }} placeholder="version" className={inputClassSm} /> + </div> + <div className="flex items-center gap-2"> + <button onClick={add} className="px-3 h-7 rounded bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Add</button> + <button onClick={() => setShowAdd(false)} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + </div> + ) : ( + <button onClick={() => setShowAdd(true)} className="text-[11px] text-gray-500 hover:text-gray-900 inline-flex items-center gap-1"> + <Plus size={11} /> add alias + </button> + )} + </> + )} + </div> + ) +} + +// ── Settings ── + +const MISE_SETTINGS: { key: string; label: string; type: "bool" | "string" | "number" | "string[]"; desc: string; placeholder?: string }[] = [ + { key: "always_keep_download", label: "Always keep download", type: "bool", desc: "Keep downloaded archives after install" }, + { key: "always_keep_install", label: "Always keep install", type: "bool", desc: "Keep install directory after uninstall" }, + { key: "legacy_version_file", label: "Legacy version file", type: "bool", desc: "Read .node-version, .python-version, etc." }, + { key: "jobs", label: "Jobs", type: "number", desc: "Parallel install jobs (default: 4)" }, + { key: "experimental", label: "Experimental", type: "bool", desc: "Enable experimental features" }, + { key: "yes", label: "Yes", type: "bool", desc: "Auto-answer yes to prompts" }, + { key: "quiet", label: "Quiet", type: "bool", desc: "Suppress non-error output" }, + { key: "verbose", label: "Verbose", type: "bool", desc: "Show extra debug output" }, + { key: "raw", label: "Raw", type: "bool", desc: "Pass commands directly to shell" }, + { key: "color", label: "Color", type: "bool", desc: "Enable colored output" }, + { key: "pipx_uvx", label: "pipx / uvx", type: "bool", desc: "Use uvx for pipx operations" }, + { key: "python_venv_auto_create", label: "Python venv auto-create", type: "bool", desc: "Auto-create venv when entering dir" }, + { key: "python_default_packages_file", label: "Python default packages file", type: "string", desc: "Default pip packages file", placeholder: "~/.default-python-packages" }, + { key: "node_mirror_url", label: "Node mirror URL", type: "string", desc: "Custom nodejs.org mirror" }, + { key: "python_mirror_url", label: "Python mirror URL", type: "string", desc: "Custom python.org mirror" }, + { key: "plugin_autoupdate_last_check_duration", label: "Plugin autoupdate check", type: "string", desc: "Duration between update checks", placeholder: "7d" }, + { key: "trusted_config_paths", label: "Trusted config paths", type: "string[]", desc: "Paths to trust (comma-separated)", placeholder: "/path/to/trusted" }, +] + +function SettingsEditor({ settings, readonly, onChange }: { + settings: Record<string, any>; readonly: boolean + onChange: (settings: Record<string, any>) => void +}) { + const update = (key: string, val: any) => { + if (val === null || val === "" || val === undefined || (typeof val === "boolean" && val === true)) { + // For booleans, we use the default (true is mise default for most, so remove key) + const { [key]: _, ...rest } = settings + onChange(rest) + } else { + onChange({ ...settings, [key]: val }) + } + } + + // Show configured settings + all known ones not yet configured + const configuredKeys = new Set(Object.keys(settings)) + const knownSettings = MISE_SETTINGS.filter(s => configuredKeys.has(s.key) || s.type === "bool") + const unknownSettings = Object.entries(settings).filter(([k]) => !MISE_SETTINGS.some(s => s.key === k)) + + return ( + <div className="space-y-3"> + {knownSettings.map((s) => { + const val = settings[s.key] + const isSet = val !== undefined + return ( + <div key={s.key} className="flex items-start gap-3 py-1.5 border-b border-gray-50"> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <code className="text-[12px] text-gray-800">{s.label}</code> + {!isSet && <span className="text-[9px] text-gray-300">default</span>} + </div> + <div className="text-[10px] text-gray-400">{s.desc}</div> + </div> + <div className="shrink-0"> + {s.type === "bool" ? ( + <label className="relative inline-flex items-center cursor-pointer"> + <input + type="checkbox" checked={val === true} + onChange={(e) => update(s.key, e.target.checked || null)} + disabled={readonly} + className="sr-only peer" + /> + <div className="w-8 h-4.5 bg-gray-200 peer-checked:bg-gray-900 rounded-full transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-3.5 after:w-3.5 after:transition-transform peer-checked:after:translate-x-[14px]"></div> + </label> + ) : s.type === "number" ? ( + <input type="number" value={val ?? ""} onChange={(e) => update(s.key, e.target.value ? Number(e.target.value) : null)} readOnly={readonly} placeholder={s.placeholder} className={inputClassSm + " w-24"} /> + ) : ( + <input value={val ?? ""} onChange={(e) => update(s.key, e.target.value || null)} readOnly={readonly} placeholder={s.placeholder} className={inputClassSm + " w-48"} /> + )} + </div> + </div> + ) + })} + {/* Unknown/custom settings */} + {unknownSettings.length > 0 && ( + <div className="border-t border-gray-200 pt-3 mt-3"> + <span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Custom</span> + {unknownSettings.map(([k, v]) => ( + <div key={k} className="flex items-center gap-2 mt-2"> + <input value={k} readOnly className={inputClassSm + " w-40"} /> + <input value={String(v)} onChange={(e) => update(k, e.target.value)} readOnly={readonly} className={inputClassSm + " flex-1"} /> + {!readonly && ( + <button onClick={() => { const { [k]: _, ...rest } = settings; onChange(rest) }} className="text-gray-300 hover:text-red-500"><Trash2 size={11} /></button> + )} + </div> + ))} + </div> + )} + {!readonly && ( + <AddCustomSetting onAdd={(k, v) => onChange({ ...settings, [k]: v })} /> + )} + </div> + ) +} + +function AddCustomSetting({ onAdd }: { onAdd: (key: string, val: string) => void }) { + const [adding, setAdding] = useState(false) + const [key, setKey] = useState("") + const [val, setVal] = useState("") + const add = () => { const k = key.trim(); if (!k) return; onAdd(k, val); setKey(""); setVal(""); setAdding(false) } + if (!adding) return <button onClick={() => setAdding(true)} className="text-[11px] text-gray-500 hover:text-gray-900 inline-flex items-center gap-1"><Plus size={11} /> add custom setting</button> + return ( + <div className="flex items-center gap-2 mt-2"> + <input autoFocus value={key} onChange={(e) => setKey(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add(); if (e.key === "Escape") setAdding(false) }} placeholder="setting key" className={inputClassSm + " flex-1"} /> + <input value={val} onChange={(e) => setVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add() }} placeholder="value" className={inputClassSm + " flex-1"} /> + <button onClick={add} className="px-3 h-7 rounded bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Add</button> + <button onClick={() => setAdding(false)} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + ) +} + +// ── Plugins ── + +function PluginsEditor({ plugins, readonly, onChange }: { + plugins: Record<string, string>; readonly: boolean + onChange: (plugins: Record<string, string>) => void +}) { + const entries = Object.entries(plugins) + const [adding, setAdding] = useState(false) + const [newName, setNewName] = useState("") + const [newUrl, setNewUrl] = useState("") + + const add = () => { + const n = newName.trim(); if (!n) return + onChange({ ...plugins, [n]: newUrl.trim() || `https://github.com/asdf-community/asdf-${n}.git` }) + setNewName(""); setNewUrl(""); setAdding(false) + } + + const remove = (name: string) => { + const { [name]: _, ...rest } = plugins; onChange(rest) + } + + return ( + <div className="space-y-3"> + {entries.length === 0 && !adding && ( + <div className="text-[12px] text-gray-400 italic py-3">No plugins. Add asdf-compatible plugin sources for tools not in the default registry.</div> + )} + {entries.length > 0 && ( + <div className="border border-gray-200 rounded-lg overflow-hidden"> + <table className="w-full text-[13px]"> + <thead> + <tr className="border-b border-gray-100 bg-gray-50/50 text-left text-[10px] text-gray-500 uppercase tracking-wider"> + <th className="px-3 py-2 font-medium">Plugin</th> + <th className="px-3 py-2 font-medium">URL</th> + {!readonly && <th className="px-3 py-2 font-medium w-10"></th>} + </tr> + </thead> + <tbody> + {entries.map(([name, url]) => ( + <tr key={name} className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors"> + <td className="px-3 py-2"> + {readonly ? <code className="text-[12px] text-gray-800">{name}</code> : <input value={name} onChange={(e) => { const { [name]: v, ...rest } = plugins; onChange({ ...rest, [e.target.value]: v }) }} className={inputClassSm} />} + </td> + <td className="px-3 py-2"> + {readonly ? <code className="text-[11px] text-gray-500 truncate block max-w-xs">{url}</code> : <input value={url} onChange={(e) => onChange({ ...plugins, [name]: e.target.value })} className={inputClassSm} />} + </td> + {!readonly && ( + <td className="px-3 py-2"><button onClick={() => remove(name)} className="text-gray-300 hover:text-red-500"><Trash2 size={12} /></button></td> + )} + </tr> + ))} + </tbody> + </table> + </div> + )} + {!readonly && ( + <> + {adding ? ( + <div className="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50/50"> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> + <input autoFocus value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add(); if (e.key === "Escape") setAdding(false) }} placeholder="plugin name (e.g. rust)" className={inputClassSm} /> + <input value={newUrl} onChange={(e) => setNewUrl(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add() }} placeholder="git URL (or leave empty for default)" className={inputClassSm} /> + </div> + <div className="flex items-center gap-2"> + <button onClick={add} className="px-3 h-7 rounded bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Add</button> + <button onClick={() => setAdding(false)} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + </div> + ) : ( + <button onClick={() => setAdding(true)} className="text-[11px] text-gray-500 hover:text-gray-900 inline-flex items-center gap-1"> + <Plus size={11} /> add plugin + </button> + )} + </> + )} + </div> + ) +} + +// ── Hooks ── + +const HOOK_EVENTS = ["enter", "leave", "cd", "preinstall", "postinstall"] + +function HooksEditor({ hooks, readonly, onChange }: { + hooks: Record<string, string>; readonly: boolean + onChange: (hooks: Record<string, string>) => void +}) { + const update = (event: string, cmd: string) => { + if (!cmd.trim()) { + const { [event]: _, ...rest } = hooks + onChange(rest) + } else { + onChange({ ...hooks, [event]: cmd }) + } + } + + return ( + <div className="space-y-3"> + <div className="text-[11px] text-gray-500 mb-2"> + Commands that run on lifecycle events. Use <code className="bg-gray-100 px-1 rounded text-[10px]">{`{{env.PWD}}`}</code> for template variables. + </div> + {HOOK_EVENTS.map((event) => ( + <div key={event} className="flex items-center gap-3"> + <code className="text-[11px] font-medium text-gray-600 w-24 shrink-0">{event}</code> + <input + value={hooks[event] ?? ""} + onChange={(e) => update(event, e.target.value)} + readOnly={readonly} + placeholder={`echo '${event} hook'`} + className={inputClassSm + " flex-1"} + /> + </div> + ))} + {/* Custom hooks */} + {Object.entries(hooks).filter(([k]) => !HOOK_EVENTS.includes(k)).map(([k, v]) => ( + <div key={k} className="flex items-center gap-3"> + <input value={k} readOnly className={inputClassSm + " w-24 shrink-0"} /> + <input value={v} onChange={(e) => update(k, e.target.value)} readOnly={readonly} className={inputClassSm + " flex-1"} /> + {!readonly && <button onClick={() => { const { [k]: _, ...rest } = hooks; onChange(rest) }} className="text-gray-300 hover:text-red-500"><Trash2 size={11} /></button>} + </div> + ))} + {!readonly && <AddCustomHook onAdd={(k, v) => onChange({ ...hooks, [k]: v })} />} + </div> + ) +} + +function AddCustomHook({ onAdd }: { onAdd: (key: string, val: string) => void }) { + const [adding, setAdding] = useState(false) + const [key, setKey] = useState("") + const [val, setVal] = useState("") + const add = () => { const k = key.trim(); if (!k) return; onAdd(k, val); setKey(""); setVal(""); setAdding(false) } + if (!adding) return <button onClick={() => setAdding(true)} className="text-[11px] text-gray-500 hover:text-gray-900 inline-flex items-center gap-1 mt-1"><Plus size={11} /> add custom hook</button> + return ( + <div className="flex items-center gap-2 mt-1"> + <input autoFocus value={key} onChange={(e) => setKey(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add(); if (e.key === "Escape") setAdding(false) }} placeholder="hook event" className={inputClassSm + " w-32"} /> + <input value={val} onChange={(e) => setVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add() }} placeholder="command" className={inputClassSm + " flex-1"} /> + <button onClick={add} className="px-3 h-7 rounded bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Add</button> + <button onClick={() => setAdding(false)} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + ) +} + +function Labeled({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <label className="flex flex-col gap-1"> + <span className="text-[10px] font-medium text-gray-400 uppercase tracking-wider">{label}</span> + {children} + </label> + ) +} diff --git a/web/src/components/settings/PluginToggleList.tsx b/web/src/components/settings/PluginToggleList.tsx new file mode 100644 index 00000000..3687b22d --- /dev/null +++ b/web/src/components/settings/PluginToggleList.tsx @@ -0,0 +1,505 @@ +import { useEffect, useState } from "react" +import { + listAvailablePlugins, + browseMarketplacePlugins, + listMarketplaces, + refreshMarketplaces, + type PluginEntry, + type PluginWithStatus, + type MarketplaceSource, +} from "@/api" +import { Switch } from "@/components/ui/switch" +import { Plus, Trash2, Package, Search, RefreshCw, Store, ExternalLink, X, ChevronDown, AlertCircle } from "lucide-react" + +export function PluginToggleList({ + enabledPlugins, + onChange, + readonly, + focusMarketplaceRef, +}: { + enabledPlugins: Record<string, boolean> + onChange: (enabled: Record<string, boolean>) => void + readonly?: boolean + focusMarketplaceRef?: React.MutableRefObject<(() => void) | null> +}) { + const [installed, setInstalled] = useState<PluginEntry[]>([]) + const [loading, setLoading] = useState(true) + const [adding, setAdding] = useState(false) + const [newKey, setNewKey] = useState("") + + useEffect(() => { + listAvailablePlugins().then((p) => { + setInstalled(p) + setLoading(false) + }) + }, []) + + const toggle = (key: string) => { + if (readonly) return + const next = { ...enabledPlugins } + const current = next[key] ?? false + if (current) { + delete next[key] + } else { + next[key] = true + } + onChange(next) + } + + const addCustom = () => { + if (readonly || !newKey.trim()) return + onChange({ ...enabledPlugins, [newKey.trim()]: true }) + setNewKey("") + setAdding(false) + } + + const remove = (key: string) => { + if (readonly) return + const { [key]: _, ...rest } = enabledPlugins + onChange(rest) + } + + const installedKeys = new Set(installed.map((p) => `${p.name}@${p.marketplace}`)) + const enabledInstalled = installed.filter((p) => enabledPlugins[`${p.name}@${p.marketplace}`]) + const disabledInstalled = installed.filter((p) => !enabledPlugins[`${p.name}@${p.marketplace}`]) + const customEnabled = Object.keys(enabledPlugins).filter((k) => enabledPlugins[k] && !installedKeys.has(k)) + + return ( + <div className="space-y-2"> + {loading ? ( + <div className="flex items-center gap-2 py-3 text-[12px] text-gray-400"> + <RefreshCw size={11} className="animate-spin" /> loading… + </div> + ) : ( + <> + {/* Enabled + disabled installed */} + {enabledInstalled.length === 0 && disabledInstalled.length === 0 && customEnabled.length === 0 ? ( + <div className="py-3"> + <div className="flex flex-col items-center gap-1.5 text-center py-4"> + <Package size={18} className="text-gray-300" /> + <div className="text-[12px] text-gray-400">No plugins</div> + <div className="text-[11px] text-gray-400/70"> + Browse marketplaces below or type a plugin spec to add one. + </div> + </div> + </div> + ) : ( + <div className="space-y-0.5"> + {enabledInstalled.map((p) => { + const key = `${p.name}@${p.marketplace}` + return ( + <PluginRow + key={key} + name={p.displayName || p.name} + subtitle={`@${p.marketplace}`} + description={p.description} + enabled={true} + onToggle={() => toggle(key)} + readonly={readonly} + /> + ) + })} + {customEnabled.map((key) => ( + <PluginRow + key={key} + name={key} + subtitle="custom" + enabled={true} + onToggle={() => toggle(key)} + onRemove={() => remove(key)} + readonly={readonly} + /> + ))} + {disabledInstalled.map((p) => { + const key = `${p.name}@${p.marketplace}` + return ( + <PluginRow + key={key} + name={p.displayName || p.name} + subtitle={`@${p.marketplace}`} + description={p.description} + enabled={false} + onToggle={() => toggle(key)} + readonly={readonly} + /> + ) + })} + </div> + )} + </> + )} + + {/* Add custom plugin spec */} + {!readonly && ( + <AddPluginRow + open={adding} + value={newKey} + onOpen={() => setAdding(true)} + onCancel={() => { setAdding(false); setNewKey("") }} + onChange={setNewKey} + onAdd={addCustom} + /> + )} + + {/* Marketplace browser */} + {!readonly && ( + <MarketplaceBrowser + enabledPlugins={enabledPlugins} + onToggle={toggle} + focusMarketplaceRef={focusMarketplaceRef} + /> + )} + </div> + ) +} + +// ── marketplace browser ── + +function MarketplaceBrowser({ + enabledPlugins, + onToggle, + focusMarketplaceRef, +}: { + enabledPlugins: Record<string, boolean> + onToggle: (key: string) => void + focusMarketplaceRef?: React.MutableRefObject<(() => void) | null> +}) { + const [open, setOpen] = useState(false) + const [plugins, setPlugins] = useState<PluginWithStatus[]>([]) + const [marketplaces, setMarketplaces] = useState<MarketplaceSource[]>([]) + const [loading, setLoading] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [search, setSearch] = useState("") + const [error, setError] = useState<string | null>(null) + const [addedMsg, setAddedMsg] = useState<string | null>(null) + const [filterMp, setFilterMp] = useState<string>("all") + + const load = async () => { + setLoading(true) + setError(null) + const [p, m] = await Promise.all([browseMarketplacePlugins(), listMarketplaces()]) + setPlugins(p) + setMarketplaces(m) + setLoading(false) + } + + const refresh = async () => { + setRefreshing(true) + setError(null) + setAddedMsg(null) + const r = await refreshMarketplaces() + if (r.ok) { + if (r.added && r.added.length > 0) { + setAddedMsg(`Registered: ${r.added.join(", ")}`) + } + await load() + } else { + setError(r.error ?? "refresh failed") + } + setRefreshing(false) + } + + // When opened, auto-register any new marketplaces from tier settings.json + // into CC's known_marketplaces.json, then load the catalogs. + useEffect(() => { + if (!open) return + let cancelled = false + const init = async () => { + setLoading(true) + setError(null) + setAddedMsg(null) + const result = await refreshMarketplaces() + if (cancelled) return + if (result.ok && result.added && result.added.length > 0) { + setAddedMsg(`Auto-registered: ${result.added.join(", ")}`) + } + const [p, m] = await Promise.all([browseMarketplacePlugins(), listMarketplaces()]) + if (cancelled) return + setPlugins(p) + setMarketplaces(m) + setLoading(false) + } + init() + return () => { cancelled = true } + }, [open]) + + const mpNames = [...new Set(plugins.map((p) => p.marketplaceName).filter(Boolean))].sort() + + const filtered = plugins.filter((p) => { + if (filterMp !== "all" && p.marketplaceName !== filterMp) return false + if (search.trim()) { + const q = search.toLowerCase() + if (!p.name.toLowerCase().includes(q) && + !p.marketplaceName.toLowerCase().includes(q) && + !(p.description && p.description.toLowerCase().includes(q))) return false + } + return true + }) + + // Group by marketplace + const grouped: Record<string, PluginWithStatus[]> = {} + for (const p of filtered) { + const mp = p.marketplaceName || "unknown" + if (!grouped[mp]) grouped[mp] = [] + grouped[mp].push(p) + } + + return ( + <div> + {!open ? ( + <button + onClick={() => setOpen(true)} + className="flex items-center gap-1.5 px-3 py-2 text-[12px] text-gray-400 hover:text-gray-700 hover:bg-gray-50 rounded-lg w-full transition-colors" + > + <Store size={13} /> + Browse marketplace plugins + </button> + ) : ( + <div className="rounded-lg border border-gray-200 overflow-hidden"> + {/* Browser header */} + <div className="flex items-center gap-2 px-3 py-2 bg-gray-50 border-b border-gray-100"> + <Store size={13} className="text-gray-500 shrink-0" /> + <span className="text-[12px] font-medium text-gray-700">Marketplace Plugins</span> + <span className="text-[10px] text-gray-400"> + {marketplaces.length} source{marketplaces.length !== 1 ? "s" : ""} + </span> + <div className="flex-1" /> + {focusMarketplaceRef && ( + <button + onClick={() => focusMarketplaceRef.current?.()} + className="text-[11px] text-gray-400 hover:text-gray-700 px-1.5 py-0.5 rounded transition-colors" + title="Add a marketplace source" + > + + Add source + </button> + )} + <button + onClick={refresh} + disabled={refreshing} + className="p-1 rounded text-gray-400 hover:text-gray-700 disabled:opacity-50" + title="Refresh marketplace registrations" + > + <RefreshCw size={11} className={refreshing ? "animate-spin" : ""} /> + </button> + <button + onClick={() => setOpen(false)} + className="p-1 rounded text-gray-400 hover:text-gray-700" + > + <X size={13} /> + </button> + </div> + + {/* Search + Filter */} + <div className="px-3 py-2 border-b border-gray-100 flex items-center gap-2"> + <div className="relative flex-1"> + <Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" /> + <input + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="search plugins…" + className="w-full pl-7 pr-2 py-1.5 text-[12px] border border-gray-200 rounded-lg outline-none focus:border-gray-400 bg-white" + /> + </div> + {mpNames.length > 1 && ( + <select + value={filterMp} + onChange={(e) => setFilterMp(e.target.value)} + className="w-28 shrink-0 border border-gray-200 rounded-lg px-2 py-1.5 text-[11px] outline-none focus:border-gray-400 bg-white" + > + <option value="all">All sources</option> + {mpNames.map((mp) => ( + <option key={mp} value={mp}>{mp}</option> + ))} + </select> + )} + </div> + + {/* Status messages */} + {addedMsg && ( + <div className="px-3 py-1.5 bg-emerald-50 border-b border-emerald-100 text-[11px] text-emerald-700"> + {addedMsg} + </div> + )} + + {/* Plugin list */} + <div className="max-h-[300px] overflow-y-auto"> + {loading ? ( + <div className="flex items-center gap-2 px-3 py-6 justify-center text-[12px] text-gray-400"> + <RefreshCw size={11} className="animate-spin" /> loading marketplace catalogs… + </div> + ) : error ? ( + <div className="flex items-center gap-2 px-3 py-4 text-[12px] text-red-600"> + <AlertCircle size={12} /> {error} + </div> + ) : Object.keys(grouped).length === 0 ? ( + <div className="flex flex-col items-center gap-1.5 py-6 text-center"> + <Package size={18} className="text-gray-300" /> + <div className="text-[12px] text-gray-400"> + {search ? "No matching plugins" : + marketplaces.length === 0 ? "No marketplaces configured" : + "No plugins found in marketplaces"} + </div> + {marketplaces.length === 0 && ( + <div className="text-[11px] text-gray-400/70 px-4 space-y-2"> + <div> + No marketplace sources registered. Add one below or{" "} + {focusMarketplaceRef ? ( + <button + onClick={() => focusMarketplaceRef.current?.()} + className="text-gray-700 underline hover:text-gray-900" + > + add a marketplace source + </button> + ) : ( + "add a marketplace source" + )}{" "} + in the Marketplaces section, then refresh. + </div> + </div> + )} + </div> + ) : ( + <div className="py-1"> + {Object.entries(grouped).map(([mpName, mpPlugins]) => ( + <div key={mpName}> + <div className="px-3 py-1.5 text-[10px] font-semibold text-gray-400 uppercase tracking-wider bg-gray-50/50"> + {mpName} + </div> + {mpPlugins.map((p) => { + const key = `${p.name}@${p.marketplaceName}` + const isEnabled = enabledPlugins[key] ?? false + return ( + <label + key={key} + className="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 transition-colors cursor-pointer" + > + <Switch + checked={isEnabled} + onCheckedChange={() => onToggle(key)} + size="sm" + /> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-1.5 min-w-0"> + <span className="text-[12px] text-gray-900 truncate">{p.displayName || p.name}</span> + {p.installed && ( + <span className="text-[9px] bg-gray-100 text-gray-500 px-1 py-0.5 rounded-full shrink-0">installed</span> + )} + {isEnabled && ( + <span className="text-[9px] bg-emerald-100 text-emerald-700 px-1 py-0.5 rounded-full shrink-0">on</span> + )} + </div> + {p.description && ( + <div className="text-[11px] text-gray-400 mt-0.5 line-clamp-1">{p.description}</div> + )} + </div> + </label> + ) + })} + </div> + ))} + </div> + )} + </div> + </div> + )} + </div> + ) +} + +// ── plugin row ── + +function PluginRow({ + name, + subtitle, + description, + enabled, + onToggle, + onRemove, + readonly, +}: { + name: string + subtitle?: string + description?: string + enabled: boolean + onToggle: () => void + onRemove?: () => void + readonly?: boolean +}) { + return ( + <div className={`group flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${enabled ? "hover:bg-gray-50" : "hover:bg-gray-50/50"}`}> + <Switch checked={enabled} onCheckedChange={onToggle} disabled={readonly} size="sm" /> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-1.5 min-w-0"> + <span className={`text-[13px] truncate ${enabled ? "text-gray-900 font-medium" : "text-gray-400"}`}> + {name} + </span> + {subtitle && ( + <span className={`text-[10px] shrink-0 ${enabled ? "text-gray-400" : "text-gray-300"}`}>{subtitle}</span> + )} + </div> + {description && ( + <div className={`text-[11px] mt-0.5 line-clamp-1 ${enabled ? "text-gray-500" : "text-gray-400"}`}> + {description} + </div> + )} + </div> + {onRemove && !readonly && ( + <button + onClick={onRemove} + className="shrink-0 text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all" + title="remove" + > + <Trash2 size={13} /> + </button> + )} + </div> + ) +} + +// ── add custom ── + +function AddPluginRow({ + open, + value, + onOpen, + onCancel, + onChange, + onAdd, +}: { + open: boolean + value: string + onOpen: () => void + onCancel: () => void + onChange: (v: string) => void + onAdd: () => void +}) { + if (!open) { + return ( + <button + onClick={onOpen} + className="flex items-center gap-1.5 px-3 py-2 text-[12px] text-gray-400 hover:text-gray-700 hover:bg-gray-50 rounded-lg w-full transition-colors" + > + <Plus size={13} /> + Add plugin by spec + </button> + ) + } + + return ( + <div className="flex items-center gap-2 px-3 py-1.5"> + <input + autoFocus + value={value} + onChange={(e) => onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) onAdd() + if (e.key === "Escape") onCancel() + }} + placeholder="name@marketplace" + className="flex-1 min-w-0 border border-gray-300 rounded px-2.5 py-1.5 text-[12px] outline-none focus:border-gray-900 bg-white" + /> + <button onClick={onAdd} className="px-2.5 h-7 rounded-lg bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800 shrink-0">add</button> + <button onClick={onCancel} className="text-[11px] text-gray-400 hover:text-gray-600 shrink-0">cancel</button> + </div> + ) +} diff --git a/web/src/components/settings/PresetsPanel.tsx b/web/src/components/settings/PresetsPanel.tsx new file mode 100644 index 00000000..12e858b1 --- /dev/null +++ b/web/src/components/settings/PresetsPanel.tsx @@ -0,0 +1,396 @@ +import { useCallback, useEffect, useState } from "react" +import { + getAdminPresets, + updateAdminPresets, + type ProviderPreset, + type MiseToolPreset, + type PresetsData, +} from "@/api" +import { Plus, Trash2, RefreshCw, Check, Cpu, Wrench } from "lucide-react" + +const inputClass = "w-full px-2.5 py-1.5 border border-gray-300 rounded text-[12px] outline-none bg-white focus:border-gray-900 focus:ring-1 focus:ring-gray-900 transition-colors font-mono" +const inputClassSm = "w-full px-2 py-1 border border-gray-300 rounded text-[11px] outline-none bg-white focus:border-gray-900 font-mono" + +type SubTab = "providers" | "mise-tools" + +export function PresetsPanel() { + const [data, setData] = useState<PresetsData | null>(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [err, setErr] = useState<string | null>(null) + const [saved, setSaved] = useState(false) + const [subTab, setSubTab] = useState<SubTab>("providers") + + const refresh = useCallback(async () => { + setLoading(true) + setErr(null) + const d = await getAdminPresets() + setData(d) + setLoading(false) + }, []) + + useEffect(() => { refresh() }, [refresh]) + + const save = async (patch: Partial<PresetsData>) => { + if (!data) return + const next = { ...data, ...patch } + setData(next) + setSaving(true) + setErr(null) + const ok = await updateAdminPresets(next) + setSaving(false) + if (!ok) { setErr("save failed"); return } + setSaved(true) + setTimeout(() => setSaved(false), 2500) + } + + if (loading && !data) { + return <div className="text-[12px] text-gray-400 italic py-12 text-center">loading presets…</div> + } + + if (!data) { + return <div className="text-[12px] text-red-600 py-12 text-center">failed to load presets</div> + } + + return ( + <div className="space-y-4"> + {/* Sub-tab bar */} + <div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5 w-fit"> + <button + onClick={() => setSubTab("providers")} + className={`px-3 py-1 rounded text-[11px] font-medium transition-colors flex items-center gap-1.5 ${subTab === "providers" ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`} + > + <Cpu size={12} /> AI Providers + </button> + <button + onClick={() => setSubTab("mise-tools")} + className={`px-3 py-1 rounded text-[11px] font-medium transition-colors flex items-center gap-1.5 ${subTab === "mise-tools" ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700"}`} + > + <Wrench size={12} /> Mise Tools + </button> + </div> + + {/* Sub-tab content */} + {subTab === "providers" && ( + <ProviderPresetsEditor + presets={data.providerPresets} + onChange={(providerPresets) => save({ providerPresets })} + saving={saving} + /> + )} + {subTab === "mise-tools" && ( + <MiseToolPresetsEditor + presets={data.miseToolPresets} + onChange={(miseToolPresets) => save({ miseToolPresets })} + saving={saving} + /> + )} + + {/* Shared status bar */} + <div className="flex items-center justify-end gap-2"> + {err && <span className="text-[12px] text-red-600">{err}</span>} + {saved && ( + <span className="text-[12px] text-emerald-600 flex items-center gap-1"><Check size={13} /> saved</span> + )} + </div> + </div> + ) +} + +// ── Provider Presets Editor ── + +function ProviderPresetsEditor({ + presets, + onChange, + saving, +}: { + presets: ProviderPreset[] + onChange: (presets: ProviderPreset[]) => void + saving: boolean +}) { + const [adding, setAdding] = useState(false) + const [newName, setNewName] = useState("") + const [newBaseUrl, setNewBaseUrl] = useState("") + const [newModels, setNewModels] = useState("") + + const add = () => { + const n = newName.trim() + if (!n || !newBaseUrl.trim()) return + const models = newModels.trim() + ? newModels.split("\n").map(s => s.trim()).filter(Boolean) + : [] + onChange([...presets, { name: n, baseUrl: newBaseUrl.trim(), models }]) + setNewName(""); setNewBaseUrl(""); setNewModels(""); setAdding(false) + } + + const remove = (idx: number) => { + onChange(presets.filter((_, i) => i !== idx)) + } + + const update = (idx: number, patch: Partial<ProviderPreset>) => { + onChange(presets.map((p, i) => i === idx ? { ...p, ...patch } : p)) + } + + return ( + <div className="space-y-3"> + {presets.length === 0 && !adding && ( + <div className="text-[12px] text-gray-400 italic py-3">No provider presets. Add one to get started.</div> + )} + + {presets.length > 0 && ( + <div className="border border-gray-200 rounded-lg overflow-hidden"> + <table className="w-full text-[13px]"> + <thead> + <tr className="border-b border-gray-100 bg-gray-50/50 text-left text-[10px] text-gray-500 uppercase tracking-wider"> + <th className="px-3 py-2 font-medium w-1/5">Name</th> + <th className="px-3 py-2 font-medium">Base URL</th> + <th className="px-3 py-2 font-medium hidden sm:table-cell">Models</th> + <th className="px-3 py-2 font-medium w-10"></th> + </tr> + </thead> + <tbody> + {presets.map((p, idx) => ( + <tr key={idx} className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors"> + <td className="px-3 py-2"> + <input + value={p.name} + onChange={(e) => update(idx, { name: e.target.value })} + disabled={saving} + className={inputClassSm} + /> + </td> + <td className="px-3 py-2"> + <input + value={p.baseUrl} + onChange={(e) => update(idx, { baseUrl: e.target.value })} + disabled={saving} + className={inputClassSm} + /> + </td> + <td className="px-3 py-2 hidden sm:table-cell"> + <textarea + value={p.models.join("\n")} + onChange={(e) => update(idx, { models: e.target.value.split("\n").map(s => s.trim()).filter(Boolean) })} + disabled={saving} + rows={Math.max(1, p.models.length)} + className={inputClassSm + " resize-none"} + /> + </td> + <td className="px-3 py-2"> + <button + onClick={() => remove(idx)} + disabled={saving} + className="text-gray-300 hover:text-red-500 transition-colors disabled:opacity-30" + > + <Trash2 size={12} /> + </button> + </td> + </tr> + ))} + </tbody> + </table> + </div> + )} + + {adding ? ( + <div className="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50/50"> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> + <input + autoFocus + value={newName} + onChange={(e) => setNewName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add(); if (e.key === "Escape") setAdding(false) }} + placeholder="Provider name" + className={inputClassSm} + /> + <input + value={newBaseUrl} + onChange={(e) => setNewBaseUrl(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add() }} + placeholder="Base URL" + className={inputClassSm} + /> + </div> + <textarea + value={newModels} + onChange={(e) => setNewModels(e.target.value)} + placeholder="Models (one per line)" + rows={3} + className={inputClassSm + " resize-none"} + /> + <div className="flex items-center gap-2"> + <button onClick={add} className="px-3 h-7 rounded bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Add</button> + <button onClick={() => setAdding(false)} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + </div> + ) : ( + <button + onClick={() => setAdding(true)} + disabled={saving} + className="text-[11px] text-gray-500 hover:text-gray-900 inline-flex items-center gap-1 transition-colors disabled:opacity-50" + > + <Plus size={11} /> add provider preset + </button> + )} + </div> + ) +} + +// ── Mise Tool Presets Editor ── + +function MiseToolPresetsEditor({ + presets, + onChange, + saving, +}: { + presets: MiseToolPreset[] + onChange: (presets: MiseToolPreset[]) => void + saving: boolean +}) { + const [adding, setAdding] = useState(false) + const [newName, setNewName] = useState("") + const [newVersion, setNewVersion] = useState("") + const [newDesc, setNewDesc] = useState("") + const [newBackend, setNewBackend] = useState("") + + const add = () => { + const n = newName.trim() + if (!n || !newVersion.trim()) return + onChange([...presets, { + name: n, + suggestedVersion: newVersion.trim(), + description: newDesc.trim() || undefined, + backend: newBackend.trim() || undefined, + }]) + setNewName(""); setNewVersion(""); setNewDesc(""); setNewBackend(""); setAdding(false) + } + + const remove = (idx: number) => { + onChange(presets.filter((_, i) => i !== idx)) + } + + const update = (idx: number, patch: Partial<MiseToolPreset>) => { + onChange(presets.map((p, i) => i === idx ? { ...p, ...patch } : p)) + } + + return ( + <div className="space-y-3"> + {presets.length === 0 && !adding && ( + <div className="text-[12px] text-gray-400 italic py-3">No mise tool presets. Add one to get started.</div> + )} + + {presets.length > 0 && ( + <div className="border border-gray-200 rounded-lg overflow-hidden"> + <table className="w-full text-[13px]"> + <thead> + <tr className="border-b border-gray-100 bg-gray-50/50 text-left text-[10px] text-gray-500 uppercase tracking-wider"> + <th className="px-3 py-2 font-medium">Tool</th> + <th className="px-3 py-2 font-medium">Version</th> + <th className="px-3 py-2 font-medium hidden sm:table-cell">Description</th> + <th className="px-3 py-2 font-medium hidden sm:table-cell">Backend</th> + <th className="px-3 py-2 font-medium w-10"></th> + </tr> + </thead> + <tbody> + {presets.map((p, idx) => ( + <tr key={idx} className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors"> + <td className="px-3 py-2"> + <input + value={p.name} + onChange={(e) => update(idx, { name: e.target.value })} + disabled={saving} + className={inputClassSm + " w-24"} + /> + </td> + <td className="px-3 py-2"> + <input + value={p.suggestedVersion} + onChange={(e) => update(idx, { suggestedVersion: e.target.value })} + disabled={saving} + className={inputClassSm + " w-20"} + /> + </td> + <td className="px-3 py-2 hidden sm:table-cell"> + <input + value={p.description ?? ""} + onChange={(e) => update(idx, { description: e.target.value || undefined })} + disabled={saving} + placeholder="optional" + className={inputClassSm} + /> + </td> + <td className="px-3 py-2 hidden sm:table-cell"> + <input + value={p.backend ?? ""} + onChange={(e) => update(idx, { backend: e.target.value || undefined })} + disabled={saving} + placeholder="optional" + className={inputClassSm} + /> + </td> + <td className="px-3 py-2"> + <button + onClick={() => remove(idx)} + disabled={saving} + className="text-gray-300 hover:text-red-500 transition-colors disabled:opacity-30" + > + <Trash2 size={12} /> + </button> + </td> + </tr> + ))} + </tbody> + </table> + </div> + )} + + {adding ? ( + <div className="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50/50"> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> + <input + autoFocus + value={newName} + onChange={(e) => setNewName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add(); if (e.key === "Escape") setAdding(false) }} + placeholder="Tool name (e.g. python)" + className={inputClassSm} + /> + <input + value={newVersion} + onChange={(e) => setNewVersion(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) add() }} + placeholder="Suggested version (e.g. 3.12)" + className={inputClassSm} + /> + </div> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> + <input + value={newDesc} + onChange={(e) => setNewDesc(e.target.value)} + placeholder="Description (optional)" + className={inputClassSm} + /> + <input + value={newBackend} + onChange={(e) => setNewBackend(e.target.value)} + placeholder="Backend (optional, e.g. aqua:sharkdp/fd)" + className={inputClassSm} + /> + </div> + <div className="flex items-center gap-2"> + <button onClick={add} className="px-3 h-7 rounded bg-gray-900 text-white text-[11px] font-medium hover:bg-gray-800">Add</button> + <button onClick={() => setAdding(false)} className="text-[11px] text-gray-400 hover:text-gray-600">Cancel</button> + </div> + </div> + ) : ( + <button + onClick={() => setAdding(true)} + disabled={saving} + className="text-[11px] text-gray-500 hover:text-gray-900 inline-flex items-center gap-1 transition-colors disabled:opacity-50" + > + <Plus size={11} /> add mise tool preset + </button> + )} + </div> + ) +} diff --git a/web/src/components/ui/avatar.tsx b/web/src/components/ui/avatar.tsx new file mode 100644 index 00000000..52ef0c0f --- /dev/null +++ b/web/src/components/ui/avatar.tsx @@ -0,0 +1,107 @@ +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Root> & { + size?: "default" | "sm" | "lg" +}) { + return ( + <AvatarPrimitive.Root + data-slot="avatar" + data-size={size} + className={cn( + "group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6", + className + )} + {...props} + /> + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Image>) { + return ( + <AvatarPrimitive.Image + data-slot="avatar-image" + className={cn("aspect-square size-full", className)} + {...props} + /> + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { + return ( + <AvatarPrimitive.Fallback + data-slot="avatar-fallback" + className={cn( + "flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs", + className + )} + {...props} + /> + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + <span + data-slot="avatar-badge" + className={cn( + "absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none", + "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="avatar-group" + className={cn( + "group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background", + className + )} + {...props} + /> + ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="avatar-group-count" + className={cn( + "relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx new file mode 100644 index 00000000..4d38506c --- /dev/null +++ b/web/src/components/ui/button.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps<typeof buttonVariants> & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + <Comp + data-slot="button" + data-variant={variant} + data-size={size} + className={cn(buttonVariants({ variant, size, className }))} + {...props} + /> + ) +} + +export { Button, buttonVariants } diff --git a/web/src/components/ui/collapsible.tsx b/web/src/components/ui/collapsible.tsx new file mode 100644 index 00000000..2f7a4e7f --- /dev/null +++ b/web/src/components/ui/collapsible.tsx @@ -0,0 +1,33 @@ +"use client" + +import { Collapsible as CollapsiblePrimitive } from "radix-ui" + +function Collapsible({ + ...props +}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { + return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) { + return ( + <CollapsiblePrimitive.CollapsibleTrigger + data-slot="collapsible-trigger" + {...props} + /> + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) { + return ( + <CollapsiblePrimitive.CollapsibleContent + data-slot="collapsible-content" + {...props} + /> + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx new file mode 100644 index 00000000..4abd194c --- /dev/null +++ b/web/src/components/ui/dialog.tsx @@ -0,0 +1,156 @@ +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function Dialog({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Root>) { + return <DialogPrimitive.Root data-slot="dialog" {...props} /> +} + +function DialogTrigger({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> +} + +function DialogPortal({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Portal>) { + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> +} + +function DialogClose({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Close>) { + return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { + return ( + <DialogPrimitive.Overlay + data-slot="dialog-overlay" + className={cn( + "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", + className + )} + {...props} + /> + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Content> & { + showCloseButton?: boolean +}) { + return ( + <DialogPortal data-slot="dialog-portal"> + <DialogOverlay /> + <DialogPrimitive.Content + data-slot="dialog-content" + className={cn( + "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-white p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg", + className + )} + {...props} + > + {children} + {showCloseButton && ( + <DialogPrimitive.Close + data-slot="dialog-close" + className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + > + <XIcon /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + )} + </DialogPrimitive.Content> + </DialogPortal> + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="dialog-header" + className={cn("flex flex-col gap-2 text-center sm:text-left", className)} + {...props} + /> + ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( + <div + data-slot="dialog-footer" + className={cn( + "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", + className + )} + {...props} + > + {children} + {showCloseButton && ( + <DialogPrimitive.Close asChild> + <Button variant="outline">Close</Button> + </DialogPrimitive.Close> + )} + </div> + ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Title>) { + return ( + <DialogPrimitive.Title + data-slot="dialog-title" + className={cn("text-lg leading-none font-semibold", className)} + {...props} + /> + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Description>) { + return ( + <DialogPrimitive.Description + data-slot="dialog-description" + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/web/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx new file mode 100644 index 00000000..4bbfbd11 --- /dev/null +++ b/web/src/components/ui/popover.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import { Popover as PopoverPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps<typeof PopoverPrimitive.Root>) { + return <PopoverPrimitive.Root data-slot="popover" {...props} /> +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { + return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 8, + children, + ...props +}: React.ComponentProps<typeof PopoverPrimitive.Content>) { + return ( + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + data-slot="popover-content" + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-72 rounded-xl border border-gray-200 bg-white p-0 shadow-xl outline-none", + "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", + "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className, + )} + {...props} + > + {children} + </PopoverPrimitive.Content> + </PopoverPrimitive.Portal> + ) +} + +function PopoverClose({ + ...props +}: React.ComponentProps<typeof PopoverPrimitive.Close>) { + return <PopoverPrimitive.Close data-slot="popover-close" {...props} /> +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverClose } diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx new file mode 100644 index 00000000..a9615001 --- /dev/null +++ b/web/src/components/ui/switch.tsx @@ -0,0 +1,43 @@ +import * as React from "react" + +/** + * Toggle switch styled like a minimal on/off slider. + * Adapted from patterns observed in cc-switch. + */ +export function Switch({ + checked, + onCheckedChange, + disabled, + size = "default", +}: { + checked: boolean + onCheckedChange: (checked: boolean) => void + disabled?: boolean + size?: "default" | "sm" +}) { + const h = size === "sm" ? "h-4 w-[28px]" : "h-5 w-[36px]" + const thumbH = size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5" + const translateX = size === "sm" ? "translate-x-[13px]" : "translate-x-[17px]" + return ( + <button + type="button" + role="switch" + aria-checked={checked} + disabled={disabled} + onClick={() => onCheckedChange(!checked)} + className={` + ${h} rounded-full shrink-0 transition-colors duration-150 + ${checked ? "bg-gray-900" : "bg-gray-200"} + ${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer hover:" + (checked ? "bg-gray-800" : "bg-gray-300")} + `} + > + <span + className={` + ${thumbH} block rounded-full bg-white shadow-sm + transform transition-transform duration-150 + ${checked ? translateX : "translate-x-[2px]"} + `} + /> + </button> + ) +} diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx new file mode 100644 index 00000000..ef14f5fc --- /dev/null +++ b/web/src/components/ui/tooltip.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import { Tooltip as TooltipPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { + return ( + <TooltipPrimitive.Provider + data-slot="tooltip-provider" + delayDuration={delayDuration} + {...props} + /> + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Root>) { + return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { + return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> +} + +function TooltipContent({ + className, + sideOffset = 4, + children, + ...props +}: React.ComponentProps<typeof TooltipPrimitive.Content>) { + return ( + <TooltipPrimitive.Portal> + <TooltipPrimitive.Content + data-slot="tooltip-content" + sideOffset={sideOffset} + className={cn( + "z-50 overflow-hidden rounded-md bg-gray-900 px-3 py-1.5 text-xs text-gray-100 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + > + {children} + <TooltipPrimitive.Arrow className="fill-gray-900" /> + </TooltipPrimitive.Content> + </TooltipPrimitive.Portal> + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/web/src/ctx.ts b/web/src/ctx.ts new file mode 100644 index 00000000..6c84e279 --- /dev/null +++ b/web/src/ctx.ts @@ -0,0 +1,10 @@ +import { createContext, useContext } from "react" +import type { WorkspaceState } from "./state" + +export const WorkspaceCtx = createContext<WorkspaceState | null>(null) + +export function useWorkspace(): WorkspaceState { + const v = useContext(WorkspaceCtx) + if (!v) throw new Error("useWorkspace outside provider") + return v +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 00000000..335ca75d --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,455 @@ +@import "tailwindcss"; +@import "tw-shimmer"; + +/* ─── Class-based dark mode ─── */ +@variant dark (&.dark); + +html, body { + touch-action: pan-x pan-y; +} + +/* ─── Dark mode: gray scale inversion ─── */ +/* Light mode uses Tailwind default grays. Dark mode remaps every shade so + existing bg-gray-* / text-gray-* / border-gray-* classes automatically + produce sensible dark-mode colors without per-element dark: variants. */ + +.dark { + /* gray scale — neutral dark palette, lightest→darkest inverted */ + --color-gray-50: #0f0f12; + --color-gray-100: #1a1a1e; + --color-gray-200: #26262b; + --color-gray-300: #35353b; + --color-gray-400: #4e4e56; + --color-gray-500: #73737d; + --color-gray-600: #92929b; + --color-gray-700: #b5b5bd; + --color-gray-800: #ceced4; + --color-gray-900: #e6e6eb; + + /* white → card surface in dark mode; black stays black for overlays */ + --color-white: #1a1a1e; + + /* blue — used for links, primary actions, status */ + --color-blue-50: #0d1b33; + --color-blue-100: #132444; + --color-blue-200: #0d2f6a; + --color-blue-300: #1a4496; + --color-blue-400: #58a6ff; + --color-blue-500: #58a6ff; + --color-blue-600: #79b8ff; + --color-blue-700: #a5cdfd; + --color-blue-800: #c4ddfe; + --color-blue-900: #dceafe; + + /* red — destructive actions, errors */ + --color-red-50: #1f1315; + --color-red-100: #2d1518; + --color-red-200: #461920; + --color-red-300: #6e212a; + --color-red-400: #f85149; + --color-red-500: #f85149; + --color-red-600: #ff7b72; + --color-red-700: #ffa198; + --color-red-800: #ffc1ba; + --color-red-900: #ffdcd7; + + /* green / emerald — success, connected state */ + --color-green-50: #0d1b14; + --color-green-100: #132218; + --color-green-200: #15301f; + --color-green-300: #1a482b; + --color-green-400: #3fb950; + --color-green-500: #3fb950; + --color-green-600: #56d364; + --color-green-700: #7ee787; + --color-green-800: #a5f0af; + --color-green-900: #caf7d0; + + --color-emerald-50: #0d1b14; + --color-emerald-100: #132218; + --color-emerald-200: #15301f; + --color-emerald-300: #1a482b; + --color-emerald-400: #3fb950; + --color-emerald-500: #3fb950; + --color-emerald-600: #56d364; + --color-emerald-700: #7ee787; + --color-emerald-800: #a5f0af; + --color-emerald-900: #caf7d0; + + /* amber / yellow — warnings, pending */ + --color-amber-50: #1b1810; + --color-amber-100: #272114; + --color-amber-200: #3a2e0f; + --color-amber-300: #5c4412; + --color-amber-400: #d29922; + --color-amber-500: #d29922; + --color-amber-600: #e3b341; + --color-amber-700: #ecc95e; + --color-amber-800: #f3db85; + --color-amber-900: #f8eab0; + + --color-yellow-50: #1b1810; + --color-yellow-100: #272114; + --color-yellow-200: #3a2e0f; + --color-yellow-300: #5c4412; + --color-yellow-400: #d29922; + --color-yellow-500: #d29922; + --color-yellow-600: #e3b341; + --color-yellow-700: #ecc95e; + + /* purple / violet — agent badges, secondary accent */ + --color-purple-50: #17122c; + --color-purple-100: #1f1837; + --color-purple-200: #2d1f58; + --color-purple-300: #442e8a; + --color-purple-400: #a371f7; + --color-purple-500: #a371f7; + --color-purple-600: #b892f9; + --color-purple-700: #cab2fb; + --color-purple-800: #dbcefd; + --color-purple-900: #eae2fe; + + --color-violet-50: #17122c; + --color-violet-100: #1f1837; + --color-violet-200: #2d1f58; + --color-violet-300: #442e8a; + --color-violet-400: #a371f7; + --color-violet-500: #a371f7; + --color-violet-600: #b892f9; + --color-violet-700: #cab2fb; + --color-violet-800: #dbcefd; + --color-violet-900: #eae2fe; + + /* sky / cyan — tool renderer accents, links */ + --color-sky-50: #0c1726; + --color-sky-100: #101e33; + --color-sky-200: #0e2c52; + --color-sky-300: #0e3d7a; + --color-sky-400: #58a6ff; + --color-sky-500: #79b8ff; + --color-sky-600: #a5cdfd; + --color-sky-700: #c4ddfe; + + --color-cyan-50: #0c1726; + --color-cyan-100: #101e33; + --color-cyan-200: #0e2c52; + --color-cyan-300: #0e3d7a; + --color-cyan-400: #58a6ff; + --color-cyan-500: #79b8ff; + + /* indigo */ + --color-indigo-50: #16122d; + --color-indigo-100: #1d1738; + --color-indigo-200: #281f5e; + --color-indigo-300: #3b2e96; + --color-indigo-400: #6f5cf7; + --color-indigo-500: #8b7cf9; + --color-indigo-600: #a69afa; + --color-indigo-700: #c0b8fc; + + /* orange */ + --color-orange-50: #1b1410; + --color-orange-100: #261b13; + --color-orange-200: #3d230f; + --color-orange-300: #603312; + --color-orange-400: #f0883e; + --color-orange-500: #f0883e; + --color-orange-600: #f4a261; + --color-orange-700: #f7ba88; + + /* pink */ + --color-pink-50: #1b1220; + --color-pink-100: #27172e; + --color-pink-200: #3d1747; + --color-pink-300: #601e70; + --color-pink-400: #f778ba; + --color-pink-500: #f778ba; +} + +/* ─── Shell / body dark mode ─── */ +.dark body { + color: #b1bac4; + background: #0d1117; +} + +/* ─── Markdown prose dark mode ─── */ +.dark .prose-loopat { + color: #b1bac4; +} +.dark .prose-loopat h1, +.dark .prose-loopat h2, +.dark .prose-loopat h3, +.dark .prose-loopat h4 { + color: #e6edf3; +} +.dark .prose-loopat blockquote { + border-left-color: #30363d; + color: #8b949e; +} +.dark .prose-loopat code:not(pre code) { + background: #21262d; + color: #b1bac4; +} +.dark .prose-loopat pre { + background: #161b22; + color: #b1bac4; +} +.dark .prose-loopat table th, +.dark .prose-loopat table td { + border-color: #30363d; +} +.dark .prose-loopat table th { + background: #161b22; +} +.dark .prose-loopat hr { + border-top-color: #21262d; +} + +/* ─── User message gradient fade (collapsed state) ─── */ +.user-msg-fade { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2rem; + background: linear-gradient(to top, #fff 0%, transparent 100%); + pointer-events: none; +} +.dark .user-msg-fade { + background: linear-gradient(to top, #1a1a1e 0%, transparent 100%); +} + +/* ─── Milkdown editor dark mode ─── */ +.dark .milkdown-editor .ProseMirror { + color: #b1bac4; +} +.dark .milkdown-editor .ProseMirror h1, +.dark .milkdown-editor .ProseMirror h2, +.dark .milkdown-editor .ProseMirror h3 { + color: #e6edf3; +} +.dark .milkdown-editor .ProseMirror code { + background: #21262d; + color: #b1bac4; +} +.dark .milkdown-editor .ProseMirror pre { + background: #0d1117; + color: #b1bac4; +} +.dark .milkdown-editor .ProseMirror blockquote { + border-left-color: #30363d; + color: #8b949e; +} +.dark .milkdown-editor .ProseMirror hr { + border-top-color: #21262d; +} +.dark .milkdown-editor .ProseMirror a { + color: #58a6ff; +} +.dark .milkdown-editor .ProseMirror th, +.dark .milkdown-editor .ProseMirror td { + border-color: #30363d; +} +.dark .milkdown-editor .ProseMirror th { + background: #161b22; +} + +/* ─── CodeMirror theme ─── + Light: CodeMirror defaults. + Dark: One Dark (Atom) — exact palette from atom.io/one-dark-syntax */ + +:root { + --cm-bg: #ffffff; + --cm-text: #24292e; + --cm-border: #e1e4e8; + --cm-gutter: #959da5; + --cm-activeLine: #f6f8fa; + --cm-cursor: #24292e; + --cm-selection: #c8e1ff; + --cm-matchBracket: #c8e1ff; + /* diff / code blocks */ + --cm-diff-add-bg: #dcffe4; + --cm-diff-add-text: #22863a; + --cm-diff-del-bg: #ffeef0; + --cm-diff-del-text: #cb2431; + --cm-diff-hdr-bg: #f1f8ff; + --cm-diff-hdr-text: #0366d6; + --cm-code-bg: #f8f9fa; + --cm-code-border: #e1e4e8; + /* syntax — GitHub light (matching github.css) */ + --cm-keyword: #d73a49; + --cm-string: #032f62; + --cm-number: #005cc5; + --cm-comment: #6a737d; + --cm-def: #6f42c1; + --cm-typeName: #6f42c1; + --cm-propertyName: #005cc5; + --cm-operator: #d73a49; + --cm-constant: #005cc5; + --cm-variableName: #24292e; + --cm-labelName: #24292e; + --cm-tag: #22863a; + --cm-attribute: #6f42c1; + --cm-link: #032f62; + --cm-regexp: #032f62; + --cm-bracket: #24292e; +} +.dark { + /* chrome — neutral dark, good contrast for all syntax colors */ + --cm-bg: #1e1e24; + --cm-text: #c8ccd4; + --cm-border: #181a1f; + --cm-gutter: #636d83; + --cm-activeLine: #2c313c; + --cm-cursor: #528bff; + --cm-selection: #3e4451; + --cm-matchBracket: #3a3a3a; + /* syntax — One Dark syntax theme exactly */ + --cm-keyword: #c678dd; + --cm-string: #98c379; + --cm-number: #d19a66; + --cm-comment: #5c6370; + --cm-def: #6cb8ff; + --cm-typeName: #e5c07b; + --cm-propertyName: #abb2bf; + --cm-operator: #56b6c2; + --cm-constant: #56b6c2; + --cm-variableName: #e06c75; + --cm-labelName: #abb2bf; + --cm-tag: #e06c75; + --cm-attribute: #d19a66; + --cm-link: #6cb8ff; + --cm-regexp: #56b6c2; + --cm-bracket: #abb2bf; + /* diff / code blocks — dark */ + --cm-diff-add-bg: #12261c; + --cm-diff-add-text: #7ee787; + --cm-diff-del-bg: #261216; + --cm-diff-del-text: #ffa198; + --cm-diff-hdr-bg: #121e33; + --cm-diff-hdr-text: #a5cdfd; + --cm-code-bg: #1a1a1e; + --cm-code-border: #26262b; +} + +/* Apply syntax token colors via CodeMirror classes */ +.cm-editor .cm-keyword { color: var(--cm-keyword) !important; } +.cm-editor .cm-string, .cm-editor .cm-specialCharString { color: var(--cm-string) !important; } +.cm-editor .cm-number { color: var(--cm-number) !important; } +.cm-editor .cm-comment { color: var(--cm-comment) !important; font-style: italic; } +.cm-editor .cm-def { color: var(--cm-def) !important; } +.cm-editor .cm-typeName { color: var(--cm-typeName) !important; } +.cm-editor .cm-propertyName { color: var(--cm-propertyName) !important; } +.cm-editor .cm-operator { color: var(--cm-operator) !important; } +.cm-editor .cm-atom { color: var(--cm-constant) !important; } +.cm-editor .cm-variableName { color: var(--cm-variableName) !important; } +.cm-editor .cm-labelName { color: var(--cm-labelName) !important; } +.cm-editor .cm-tag { color: var(--cm-tag) !important; } +.cm-editor .cm-attribute { color: var(--cm-attribute) !important; } +.cm-editor .cm-link { color: var(--cm-link) !important; } +.cm-editor .cm-bracket { color: var(--cm-bracket) !important; } +.cm-editor .cm-matchingBracket { background-color: var(--cm-matchBracket) !important; } +.cm-editor .cm-nonmatchingBracket { color: var(--cm-variableName) !important; } + +/* highlight.js dark overrides — uses github.css for light, One Dark for dark */ +.dark .hljs { background: transparent !important; color: var(--cm-text) !important; } +.dark .hljs-keyword { color: var(--cm-keyword) !important; } +.dark .hljs-string { color: var(--cm-string) !important; } +.dark .hljs-number { color: var(--cm-number) !important; } +.dark .hljs-comment { color: var(--cm-comment) !important; font-style: italic; } +.dark .hljs-function { color: var(--cm-def) !important; } +.dark .hljs-type { color: var(--cm-typeName) !important; } +.dark .hljs-built_in { color: var(--cm-typeName) !important; } +.dark .hljs-title { color: var(--cm-def) !important; } +.dark .hljs-title.function_ { color: var(--cm-def) !important; } +.dark .hljs-title.class_ { color: var(--cm-typeName) !important; } +.dark .hljs-params { color: var(--cm-variableName) !important; } +.dark .hljs-attr { color: var(--cm-attribute) !important; } +.dark .hljs-attribute { color: var(--cm-attribute) !important; } +.dark .hljs-selector-tag { color: var(--cm-tag) !important; } +.dark .hljs-selector-class { color: var(--cm-typeName) !important; } +.dark .hljs-selector-id { color: var(--cm-attribute) !important; } +.dark .hljs-variable { color: var(--cm-variableName) !important; } +.dark .hljs-literal { color: var(--cm-constant) !important; } +.dark .hljs-meta { color: var(--cm-constant) !important; } +.dark .hljs-tag { color: var(--cm-tag) !important; } +.dark .hljs-name { color: var(--cm-tag) !important; } +.dark .hljs-regexp { color: var(--cm-regexp) !important; } +.dark .hljs-symbol { color: var(--cm-constant) !important; } +.dark .hljs-bullet { color: var(--cm-constant) !important; } +.dark .hljs-link { color: var(--cm-link) !important; } +.dark .hljs-deletion { color: var(--cm-diff-del-text) !important; } +.dark .hljs-addition { color: var(--cm-diff-add-text) !important; } +.dark .hljs-punctuation { color: var(--cm-bracket) !important; } +.dark .hljs-operator { color: var(--cm-operator) !important; } +.dark .hljs-property { color: var(--cm-propertyName) !important; } +.dark .hljs-section { color: var(--cm-def) !important; font-weight: bold; } + +/* Diff line base text — overridden by hljs token classes */ +.diff-line-add { color: var(--cm-diff-add-text); } +.diff-line-del { color: var(--cm-diff-del-text); } +.diff-line-ctx { color: var(--cm-text); } + +/* ─── Kanban progress bar background ─── */ +.dark .kanban-progress-bg { + background-color: #21262d; +} + +/* ─── TokenUsagePage chart grid lines ─── */ +.dark .token-chart-grid line { + stroke: #21262d; +} + +/* Chat dot animations */ +@keyframes blink-green { + 0%, 100% { background-color: #22c55e; } + 50% { background-color: #bbf7d0; } +} + +/* ClaudeStatus morphing icon */ +@keyframes morph { + 0%, 100% { + border-radius: 50% 50% 60% 40%; + background-color: #3b82f6; + transform: scale(1); + } + 25% { + border-radius: 40% 60% 50% 50%; + background-color: #6366f1; + transform: scale(1.15); + } + 50% { + border-radius: 55% 45% 40% 60%; + background-color: #8b5cf6; + transform: scale(0.9); + } + 75% { + border-radius: 45% 55% 60% 40%; + background-color: #6366f1; + transform: scale(1.1); + } +} + +@keyframes glow { + 0%, 100% { opacity: 0.3; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(1.8); } +} + +@layer base { + * { box-sizing: border-box; } + html, body { margin: 0; padding: 0; height: 100%; } + #root { margin: 0; padding: 0; height: 100%; } + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-size: 14px; + color: #1a1a1a; + background: #fafafa; + } + button { + font: inherit; + cursor: pointer; + } + input, textarea { + font: inherit; + } +} diff --git a/web/src/lib/useIsMobile.ts b/web/src/lib/useIsMobile.ts new file mode 100644 index 00000000..9e8dd0f3 --- /dev/null +++ b/web/src/lib/useIsMobile.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react" + +const QUERY = "(max-width: 767px)" + +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === "undefined") return false + return window.matchMedia(QUERY).matches + }) + + useEffect(() => { + const mql = window.matchMedia(QUERY) + const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches) + mql.addEventListener("change", onChange) + return () => mql.removeEventListener("change", onChange) + }, []) + + return isMobile +} diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts new file mode 100644 index 00000000..bd0c391d --- /dev/null +++ b/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 00000000..d786132e --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import { App } from "./App" +import { ErrorBoundary } from "./ErrorBoundary" +import "./index.css" + +// Disable pinch-zoom on mobile (iOS Safari ignores meta viewport + touch-action) +document.addEventListener("gesturestart", (e) => e.preventDefault()) + +createRoot(document.getElementById("root")!).render( + <StrictMode> + <ErrorBoundary> + <App /> + </ErrorBoundary> + </StrictMode> +) diff --git a/web/src/pages/AdminSystemPage.tsx b/web/src/pages/AdminSystemPage.tsx new file mode 100644 index 00000000..e54f0531 --- /dev/null +++ b/web/src/pages/AdminSystemPage.tsx @@ -0,0 +1,245 @@ +/** + * Admin dashboard for the loopat process itself: version info, activity + * counters, and a "pull latest" button. Polls /api/admin/system every 5s + * while open. Non-admins get redirected — server also enforces 403. + * + * Pull does NOT restart the server. bun --hot picks up code changes + * in-place. Schema/dep changes need a real restart (ssh in, scripts/stop.sh + * && scripts/start.sh) — the warning text says so. + */ +import { useCallback, useEffect, useState } from "react" +import { useNavigate } from "react-router-dom" +import { ArrowLeft, RefreshCw, Activity, GitBranch, Download } from "lucide-react" +import { + getAdminSystem, + adminCheckForUpdates, + adminPull, + type AdminSystemInfo, +} from "../api" +import { useWorkspace } from "../ctx" + +export function AdminSystemPage() { + const navigate = useNavigate() + const ws = useWorkspace() + const isAdmin = ws.currentUser?.role === "admin" + + const [info, setInfo] = useState<AdminSystemInfo | null>(null) + const [loading, setLoading] = useState(true) + const [checking, setChecking] = useState(false) + const [pulling, setPulling] = useState(false) + const [lastPullResult, setLastPullResult] = useState<string>("") + const [lastPullOk, setLastPullOk] = useState<boolean | null>(null) + const [error, setError] = useState("") + + const refresh = useCallback(async () => { + const next = await getAdminSystem() + if (next) setInfo(next) + else setError("forbidden or server unreachable") + setLoading(false) + }, []) + + useEffect(() => { + if (!isAdmin) return + refresh() + const id = setInterval(refresh, 5000) + return () => clearInterval(id) + }, [isAdmin, refresh]) + + if (!isAdmin) { + return ( + <div className="h-full flex items-center justify-center text-sm text-gray-500"> + admin only.&nbsp; + <button className="underline" onClick={() => navigate("/loop")}>back</button> + </div> + ) + } + + async function check() { + setChecking(true); setError("") + try { + const r = await adminCheckForUpdates() + if (!r.ok) setError(r.error ?? "check failed") + await refresh() + } finally { setChecking(false) } + } + + async function pull() { + if (!info) return + if (info.activity.activeLoops > 0) { + const ok = confirm( + `${info.activity.activeLoops} loop(s) and ${info.activity.activeUsers} user(s) are active.\n\n` + + `bun --hot will reload code in-place — most pulls don't disturb anyone. ` + + `If the pull touches deps or schema, you'll need to ssh in and restart manually.\n\n` + + `Continue?` + ) + if (!ok) return + } + setPulling(true); setError(""); setLastPullResult("") + try { + const r = await adminPull() + setLastPullOk(r.ok) + if (!r.ok) { + setLastPullResult(r.error ?? "pull failed") + } else if (!r.pulled) { + setLastPullResult("already up to date") + } else { + setLastPullResult(`pulled ${r.oldHead?.slice(0,7)} → ${r.newHead?.slice(0,7)}\n${r.message ?? ""}`.trim()) + } + await refresh() + } finally { setPulling(false) } + } + + return ( + <div className="h-full overflow-y-auto bg-gray-50"> + <div className="max-w-3xl mx-auto p-4 sm:p-6 flex flex-col gap-4"> + <div className="flex items-center gap-2"> + <button + onClick={() => navigate(-1)} + className="p-1 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700" + title="back" + > + <ArrowLeft className="w-4 h-4" /> + </button> + <h1 className="text-lg font-semibold text-gray-900">Platform</h1> + <span className="text-xs text-gray-400 ml-auto">refreshes every 5s</span> + </div> + + {loading && !info && ( + <div className="text-sm text-gray-400 py-8 text-center">loading…</div> + )} + + {error && ( + <div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5"> + {error} + </div> + )} + + {info && ( + <> + <Card icon={<GitBranch className="w-4 h-4" />} title="Version"> + <div className="flex flex-col gap-1 text-sm"> + <div className="text-gray-700"> + <span className="text-gray-400 mr-2">branch</span> + <span className="font-mono">{info.version.branch}</span> + <span className="text-gray-400 mx-2">·</span> + <span className="text-gray-400 mr-1">commit</span> + <span className="font-mono">{info.version.commit.slice(0, 7)}</span> + </div> + {info.version.behindBy > 0 ? ( + <div className="text-amber-700 text-xs"> + behind by {info.version.behindBy} commit{info.version.behindBy === 1 ? "" : "s"} —{" "} + <span className="font-mono">{info.version.latestCommit?.slice(0, 7)}</span>{" "} + "{info.version.latestMessage}" + </div> + ) : ( + <div className="text-xs text-emerald-700">up to date</div> + )} + <div className="mt-2"> + <button + onClick={check} + disabled={checking} + className="text-xs px-2.5 py-1 rounded border border-gray-300 hover:bg-gray-100 disabled:opacity-50 inline-flex items-center gap-1.5" + > + <RefreshCw className={`w-3 h-3 ${checking ? "animate-spin" : ""}`} /> + {checking ? "checking…" : "Check for updates"} + </button> + </div> + </div> + </Card> + + <Card icon={<Activity className="w-4 h-4" />} title="Activity"> + <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mb-3"> + <Stat label="Active loops" value={info.activity.activeLoops} /> + <Stat label="Active users" value={info.activity.activeUsers} /> + <Stat label="Live WS" value={info.activity.totalWs} /> + <Stat label="SDK streaming" value={info.activity.totalGenerating} /> + </div> + {info.activity.loops.length === 0 ? ( + <div className="text-xs text-gray-400 text-center py-3">no active loops</div> + ) : ( + <table className="w-full text-xs"> + <thead className="text-gray-400 border-b border-gray-200"> + <tr> + <th className="text-left font-normal py-1.5 pr-2">title</th> + <th className="text-left font-normal py-1.5 pr-2">driver</th> + <th className="text-right font-normal py-1.5 pr-2">WS</th> + <th className="text-left font-normal py-1.5">state</th> + </tr> + </thead> + <tbody> + {info.activity.loops.map((l) => ( + <tr key={l.id} className="border-b border-gray-100 last:border-b-0"> + <td className="py-1.5 pr-2 truncate max-w-[260px]" title={l.title}>{l.title}</td> + <td className="py-1.5 pr-2 text-gray-600">{l.driver}</td> + <td className="py-1.5 pr-2 text-right">{l.wsCount}</td> + <td className="py-1.5"> + {l.generating ? ( + <span className="text-blue-600">streaming…</span> + ) : l.lastMsgAgeSec >= 0 && l.lastMsgAgeSec < 60 ? ( + <span className="text-emerald-600">msg {l.lastMsgAgeSec}s ago</span> + ) : ( + <span className="text-gray-400">idle (attached)</span> + )} + </td> + </tr> + ))} + </tbody> + </table> + )} + </Card> + + <Card icon={<Download className="w-4 h-4" />} title="Update"> + <div className="flex flex-col gap-2 text-sm"> + <button + onClick={pull} + disabled={pulling || info.version.behindBy === 0} + className={ + "self-start text-sm px-3 py-1.5 rounded text-white disabled:opacity-50 " + + (info.activity.activeLoops > 0 ? "bg-amber-600 hover:bg-amber-700" : "bg-gray-900 hover:bg-gray-700") + } + > + {pulling ? "pulling…" : info.version.behindBy === 0 ? "Pull latest (nothing to pull)" : "Pull latest"} + </button> + {info.activity.activeLoops > 0 && info.version.behindBy > 0 && ( + <div className="text-xs text-amber-700"> + {info.activity.activeLoops} loop(s) active — bun --hot reloads code in-place. + Dep / schema changes need a manual restart. + </div> + )} + {lastPullResult && ( + <pre className={ + "text-xs whitespace-pre-wrap rounded p-2 border " + + (lastPullOk ? "bg-emerald-50 border-emerald-200 text-emerald-800" : "bg-red-50 border-red-200 text-red-800") + }> + {lastPullResult} + </pre> + )} + </div> + </Card> + </> + )} + </div> + </div> + ) +} + +function Card({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) { + return ( + <div className="bg-white border border-gray-200 rounded-lg p-4"> + <div className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-3"> + <span className="text-gray-400">{icon}</span> + {title} + </div> + {children} + </div> + ) +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( + <div className="flex flex-col"> + <span className="text-[10px] text-gray-400 uppercase tracking-wide">{label}</span> + <span className="text-xl font-semibold text-gray-900 tabular-nums">{value}</span> + </div> + ) +} diff --git a/web/src/pages/AuthPage.tsx b/web/src/pages/AuthPage.tsx new file mode 100644 index 00000000..9577482f --- /dev/null +++ b/web/src/pages/AuthPage.tsx @@ -0,0 +1,169 @@ +import { useState, type FormEvent } from "react" +import { useWorkspace } from "../ctx" + +type Mode = "login" | "register" + +/** + * Login / register. Registration only creates the account — personal repo + * setup lives in Settings → Personal Repo (token → pick/create flow), and the + * SetupPersonalRepoCard nudges new users there after they land. Keeping auth + * lean avoids duplicating the repo-picker UI in two places. + */ +export function AuthPage({ onClose }: { onClose?: () => void } = {}) { + const ws = useWorkspace() + const [mode, setMode] = useState<Mode>("login") + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [busy, setBusy] = useState(false) + const [error, setError] = useState<string | null>(null) + const [pendingNotice, setPendingNotice] = useState<string | null>(null) + + const submit = async (e: FormEvent) => { + e.preventDefault() + if (busy) return + setError(null) + setBusy(true) + try { + if (mode === "login") { + const r = await ws.login(username.trim().toLowerCase(), password) + if (r.error) setError(r.error) + else if (onClose) onClose() + return + } + const r = await ws.register({ + username: username.trim().toLowerCase(), + password, + }) + if (r.error) { + setError(r.error) + return + } + // Pending account: no session was issued — show a notice and bounce + // back to the login tab. They can't proceed until an admin activates. + if (r.user && r.user.status === "pending") { + setPendingNotice(`账号 ${r.user.id} 已创建,等待管理员激活后即可登录。`) + setMode("login") + setPassword("") + return + } + if (onClose) onClose() + } finally { + setBusy(false) + } + } + + return ( + <div + className={ + onClose + ? "fixed inset-0 z-50 bg-black/30 flex items-center justify-center" + : "h-full w-full flex items-center justify-center bg-gray-50" + } + onClick={onClose ? () => onClose() : undefined} + > + <div + className="w-full max-w-[420px] mx-4 bg-white rounded-md shadow-xl border border-gray-200 p-4 md:p-6" + onClick={(e) => e.stopPropagation()} + > + <div className="flex items-center gap-2 mb-5"> + <span className="text-xl leading-none">🧶</span> + <span className="text-base font-semibold text-gray-900">loopat</span> + {onClose && ( + <button + type="button" + onClick={onClose} + className="ml-auto text-gray-400 hover:text-gray-700 px-1" + aria-label="close" + > + ✕ + </button> + )} + </div> + + <div className="flex border-b border-gray-200 mb-5"> + {(["login", "register"] as Mode[]).map((m) => ( + <button + key={m} + type="button" + onClick={() => { + setMode(m) + setError(null) + setPendingNotice(null) + }} + className={ + mode === m + ? "px-3 py-2 text-sm border-b-2 border-gray-900 text-gray-900 font-medium" + : "px-3 py-2 text-sm text-gray-500 hover:text-gray-800" + } + > + {m === "login" ? "Login" : "Register"} + </button> + ))} + </div> + <form onSubmit={submit} className="flex flex-col gap-4"> + <Field label="Username" hint="lowercase a-z 0-9 _ - · 1-32 chars"> + <input + type="text" + value={username} + onChange={(e) => setUsername(e.target.value)} + placeholder="simpx" + autoFocus + autoComplete="username" + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded outline-none focus:border-gray-500" + /> + </Field> + <Field label="Password"> + <input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + autoComplete={mode === "login" ? "current-password" : "new-password"} + className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded outline-none focus:border-gray-500" + /> + </Field> + {mode === "register" && ( + <div className="text-[11px] text-gray-400 leading-relaxed"> + Just creates your account. Set up your personal repo afterwards in + Settings → Personal Repo. + </div> + )} + {pendingNotice && !error && ( + <div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1.5"> + {pendingNotice} + </div> + )} + {error && ( + <div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5"> + {error} + </div> + )} + <button + type="submit" + disabled={busy || !username || !password} + className="px-3 h-9 text-sm rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50" + > + {busy ? (mode === "login" ? "logging in…" : "registering…") : mode === "login" ? "Login" : "Register"} + </button> + </form> + </div> + </div> + ) +} + +function Field({ + label, + hint, + children, +}: { + label: string + hint?: string + children: React.ReactNode +}) { + return ( + <label className="flex flex-col gap-1"> + <span className="text-xs text-gray-700 font-medium">{label}</span> + {children} + {hint && <span className="text-[11px] text-gray-400">{hint}</span>} + </label> + ) +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx new file mode 100644 index 00000000..bb129c8b --- /dev/null +++ b/web/src/pages/ChatPage.tsx @@ -0,0 +1,1071 @@ +/** + * Chat tab — Slack-like channels + 1:1 DMs with single-level threads. + * + * Storage of record is server-side SQLite (chat.db). Threading model: + * every top-level message IS a thread (length ≥ 1); a reply has + * `parentId` set to the root's id. Replies cannot themselves be replied + * to (no nesting). When a loop is spawned, it's spawned FROM A THREAD — + * root + replies snapshot to /loopat/context/chat/<rootId>.jsonl in the + * new loop's sandbox, giving the AI a clean semantic unit (vs. a noisy + * whole-channel dump). + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useNavigate, useParams } from "react-router-dom" +import { PanelLeftClose, PanelLeftOpen } from "lucide-react" +import { useIsMobile } from "../lib/useIsMobile" +import { + listChatConversations, + listChatMessages, + getChatThread, + listChatUsers, + sendChatMessage, + markChatRead, + createChatChannel, + deleteChatChannel, + openChatDm, + spawnLoopFromThread, + type ChatConversation, + type ChatMessage, + type ChatThreadRoot, + type ChatWorkspaceUser, +} from "../api" +import { useChatWebSocket, type ChatWsEvent } from "../useChatWebSocket" +import { useWorkspace } from "../ctx" + +/** + * Outbox entry for a send that hasn't been confirmed by the DB. We render it + * inline so the sender always sees feedback — pending while in flight, + * failed (with retry/discard) on error. Server-assigned `id: number` only + * exists after success, so we key by client-generated `tempId` until then. + */ +type PendingMsg = { + tempId: string + convId: string + parentId: number | null + text: string + ts: number + status: "pending" | "failed" + error?: string +} + +function newTempId(): string { + return `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +function formatTime(ts: number): string { + const d = new Date(ts) + const today = new Date() + const sameDay = d.toDateString() === today.toDateString() + const yesterday = new Date(today.getTime() - 86_400_000).toDateString() === d.toDateString() + const hhmm = d.toTimeString().slice(0, 5) + if (sameDay) return hhmm + if (yesterday) return `yesterday ${hhmm}` + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${hhmm}` +} + +function convDisplayName(conv: ChatConversation): string { + if (conv.kind === "channel") return conv.name ?? "(unnamed)" + return conv.peerUserId ?? "(unknown)" +} + +function convSigil(conv: ChatConversation): string { + return conv.kind === "channel" ? "#" : "@" +} + +export function ChatPage() { + const ws = useWorkspace() + const me = ws.currentUser?.id ?? "" + const isAdmin = ws.currentUser?.role === "admin" + const navigate = useNavigate() + const { convId } = useParams<{ convId?: string }>() + + const [convs, setConvs] = useState<ChatConversation[]>([]) + const [users, setUsers] = useState<ChatWorkspaceUser[]>([]) + const [messages, setMessages] = useState<ChatThreadRoot[]>([]) + const [draft, setDraft] = useState("") + const [sending, setSending] = useState(false) + // Outbox: locally-tracked sends that have NOT been confirmed by the DB. + // Renders inline below the confirmed feed (or replies) with a status: + // - "pending": faded + "sending…", entry stays + // - "failed": red + error + retry/discard buttons + // On HTTP success the entry is removed and the server-returned message + // is appended to the confirmed list. WS for own message dedupes by id. + // Keyed by tempId — the only client-side identifier before DB assigns one. + const [pendingRoots, setPendingRoots] = useState<PendingMsg[]>([]) + const [pendingReplies, setPendingReplies] = useState<PendingMsg[]>([]) + // Thread panel state. activeThreadRootId = which message's thread is open + // in the right pane. thread = root + replies (loaded async). spawning is + // per-thread (button lives in panel header). + const [activeThreadRootId, setActiveThreadRootId] = useState<number | null>(null) + const [thread, setThread] = useState<{ root: ChatMessage; replies: ChatMessage[] } | null>(null) + const [threadDraft, setThreadDraft] = useState("") + const [threadSending, setThreadSending] = useState(false) + const [spawning, setSpawning] = useState(false) + const [showDmPicker, setShowDmPicker] = useState(false) + const [showNewChannel, setShowNewChannel] = useState(false) + const [sidebarOpen, setSidebarOpen] = useState(false) + const isMobile = useIsMobile() + const messagesEndRef = useRef<HTMLDivElement | null>(null) + const threadEndRef = useRef<HTMLDivElement | null>(null) + const activeConvIdRef = useRef<string | undefined>(convId) + activeConvIdRef.current = convId + const activeThreadRootIdRef = useRef<number | null>(null) + activeThreadRootIdRef.current = activeThreadRootId + + // ── render window: bottom-up progressive reveal ── + const RENDER_WINDOW_SIZE = 10 + const RENDER_WINDOW_BATCH = 20 + const [renderCount, setRenderCount] = useState(RENDER_WINDOW_SIZE) + const visibleMessages = messages.slice(-renderCount) + const hasOlderMessages = messages.length > renderCount + + const loadMoreMessages = useCallback(() => { + setRenderCount((prev) => prev + RENDER_WINDOW_BATCH) + }, []) + const loadMoreRef = useRef(loadMoreMessages) + loadMoreRef.current = loadMoreMessages + const hasOlderRef = useRef(hasOlderMessages) + hasOlderRef.current = hasOlderMessages + + const active = useMemo(() => convs.find((c) => c.id === convId), [convs, convId]) + const channels = useMemo(() => convs.filter((c) => c.kind === "channel").sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")), [convs]) + const dms = useMemo( + () => + convs + .filter((c) => c.kind === "dm") + .sort((a, b) => (b.lastMessageTs ?? 0) - (a.lastMessageTs ?? 0)), + [convs], + ) + + // ── data loading ── + + const refreshConvs = useCallback(async () => { + const list = await listChatConversations() + setConvs(list) + }, []) + + const refreshUsers = useCallback(async () => { + const list = await listChatUsers() + setUsers(list) + }, []) + + useEffect(() => { + refreshConvs() + refreshUsers() + }, [refreshConvs, refreshUsers]) + + // On URL convId change → fetch messages, mark read, optimistically zero + // unread. Also close any open thread panel — threads are conv-scoped. + const convWithDataRef = useRef<string | null>(null) + useEffect(() => { + if (!convId) return + setActiveThreadRootId(null) + setThread(null) + setRenderCount(RENDER_WINDOW_SIZE) + convWithDataRef.current = null + progressiveReadyRef.current = false + let cancelled = false + listChatMessages(convId, { limit: 100 }).then((msgs) => { + if (cancelled) return + convWithDataRef.current = convId + setMessages(msgs) + // mark-read up to the latest message + const last = msgs[msgs.length - 1] + if (last) { + markChatRead(convId, last.id).catch(() => {}) + setConvs((prev) => prev.map((c) => (c.id === convId ? { ...c, unread: 0 } : c))) + // Notify the global tab-title hook so the (N) prefix drops + // immediately, without waiting for a refetch. + window.dispatchEvent(new CustomEvent("loopat:chat-read", { detail: { convId } })) + } + }) + return () => { + cancelled = true + } + }, [convId]) + + // Fetch thread on open. The selected root may have arrived as part of a + // ws-pushed reply count bump on a message we don't have in `messages` + // yet, so we don't try to read it from local state — always GET. + useEffect(() => { + if (activeThreadRootId == null) { + setThread(null) + return + } + let cancelled = false + setThread(null) + getChatThread(activeThreadRootId).then((t) => { + if (cancelled) return + setThread(t) + }) + return () => { + cancelled = true + } + }, [activeThreadRootId]) + + // Default redirect to first channel when no convId + useEffect(() => { + if (convId) return + if (convs.length === 0) return + const first = channels[0] ?? convs[0] + if (first) navigate(`/chat/${first.id}`, { replace: true }) + }, [convId, convs, channels, navigate]) + + // Auto-scroll on new messages (main feed + open thread panel) + // Initial scroll uses "auto" (no animation); live WS arrivals use "smooth". + const initialScrollDoneRef = useRef(false) + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: initialScrollDoneRef.current ? "smooth" : "auto" }) + initialScrollDoneRef.current = true + }, [messages, renderCount]) + useEffect(() => { + threadEndRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [thread?.replies.length]) + + // Bottom-up progressive reveal: start with RENDER_WINDOW_SIZE newest + // messages, then load older batches as the user scrolls to the top. + const progressiveReadyRef = useRef(false) + const msgContainerRef = useRef<HTMLDivElement | null>(null) + useEffect(() => { + setRenderCount(RENDER_WINDOW_SIZE) + progressiveReadyRef.current = false + }, [convId]) + + useEffect(() => { + if (!convId || messages.length === 0 || convWithDataRef.current !== convId || progressiveReadyRef.current) return + progressiveReadyRef.current = true + // First batch: immediately load 20 more for a smooth start + setRenderCount((prev) => prev + RENDER_WINDOW_BATCH) + requestAnimationFrame(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "auto" }) + }) + // Scroll-to-top triggers subsequent batches + const container = msgContainerRef.current + if (!container) return + let debounce: ReturnType<typeof setTimeout> | null = null + const onScroll = () => { + if (debounce) clearTimeout(debounce) + debounce = setTimeout(() => { + if (container.scrollTop < 60 && hasOlderRef.current) { + loadMoreRef.current() + requestAnimationFrame(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "auto" }) + }) + } + }, 400) + } + container.addEventListener("scroll", onScroll, { passive: true }) + return () => { + container.removeEventListener("scroll", onScroll) + if (debounce) clearTimeout(debounce) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [convId, messages]) + + // ── websocket ── + + const onEvent = useCallback( + (e: ChatWsEvent) => { + if (e.type === "message") { + const m = e.message + if (m.convId === activeConvIdRef.current) { + if (m.parentId == null) { + // New thread root → append to main feed (with 0 replies stats). + setMessages((prev) => ( + prev.some((x) => x.id === m.id) + ? prev + : [...prev, { ...m, replyCount: 0, lastReplyTs: null }] + )) + } else { + // Reply → bump parent's replyCount/lastReplyTs in main feed, + // and append to thread panel if that thread is open. + setMessages((prev) => prev.map((x) => + x.id === m.parentId + ? { ...x, replyCount: x.replyCount + 1, lastReplyTs: m.ts } + : x, + )) + if (activeThreadRootIdRef.current === m.parentId) { + setThread((prev) => + prev && !prev.replies.some((r) => r.id === m.id) + ? { ...prev, replies: [...prev.replies, m] } + : prev, + ) + } + } + markChatRead(m.convId, m.id).catch(() => {}) + } else if (m.author !== me) { + setConvs((prev) => + prev.map((c) => + c.id === m.convId + ? { ...c, unread: c.unread + 1, lastMessageTs: m.ts } + : c, + ), + ) + } + } else if (e.type === "conv_created") { + setConvs((prev) => { + if (prev.some((c) => c.id === e.conv.id)) return prev + return [...prev, e.conv] + }) + } else if (e.type === "conv_deleted") { + setConvs((prev) => prev.filter((c) => c.id !== e.convId)) + if (activeConvIdRef.current === e.convId) { + navigate("/chat", { replace: true }) + } + } + }, + [navigate, me], + ) + + const { subscribe, unsubscribe } = useChatWebSocket(onEvent) + + // Subscribe to every visible conv so unread counts stay live in the rail. + // Active conv also gets its messages. + useEffect(() => { + for (const c of convs) subscribe(c.id) + return () => { + for (const c of convs) unsubscribe(c.id) + } + }, [convs, subscribe, unsubscribe]) + + // ── actions ── + + /** Push a top-level send through the outbox. On success the pending entry + * is replaced by the server-confirmed message; on failure it stays + * visible with retry/discard buttons. Shared by handleSend and the + * per-pending retry handler. */ + const submitRoot = async (text: string, tempId: string, targetConvId: string) => { + setPendingRoots((prev) => prev.map((p) => + p.tempId === tempId ? { ...p, status: "pending", error: undefined } : p, + )) + const r = await sendChatMessage(targetConvId, text) + if (r.message) { + const m = r.message + setPendingRoots((prev) => prev.filter((p) => p.tempId !== tempId)) + setMessages((prev) => ( + prev.some((x) => x.id === m.id) + ? prev + : [...prev, { ...m, replyCount: 0, lastReplyTs: null }] + )) + } else { + setPendingRoots((prev) => prev.map((p) => + p.tempId === tempId ? { ...p, status: "failed", error: r.error ?? "send failed" } : p, + )) + } + } + + const handleSend = async () => { + const text = draft.trim() + if (!text || !convId || sending) return + setSending(true) + const tempId = newTempId() + setPendingRoots((prev) => [ + ...prev, + { tempId, convId, parentId: null, text, ts: Date.now(), status: "pending" }, + ]) + setDraft("") + await submitRoot(text, tempId, convId) + setSending(false) + } + + const retryRoot = (p: PendingMsg) => { + if (!p.convId) return + submitRoot(p.text, p.tempId, p.convId).catch(() => {}) + } + + const discardRoot = (tempId: string) => { + setPendingRoots((prev) => prev.filter((p) => p.tempId !== tempId)) + } + + /** Same outbox pattern as submitRoot, but the destination is a thread + * reply: success appends to thread.replies (WS dedupes), failure leaves + * the pending entry visible with retry/discard. */ + const submitReply = async (text: string, tempId: string, targetConvId: string, parentId: number) => { + setPendingReplies((prev) => prev.map((p) => + p.tempId === tempId ? { ...p, status: "pending", error: undefined } : p, + )) + const r = await sendChatMessage(targetConvId, text, parentId) + if (r.message) { + const m = r.message + setPendingReplies((prev) => prev.filter((p) => p.tempId !== tempId)) + setThread((prev) => + prev && !prev.replies.some((x) => x.id === m.id) + ? { ...prev, replies: [...prev.replies, m] } + : prev, + ) + } else { + setPendingReplies((prev) => prev.map((p) => + p.tempId === tempId ? { ...p, status: "failed", error: r.error ?? "send failed" } : p, + )) + } + } + + const handleThreadSend = async () => { + const text = threadDraft.trim() + if (!text || !convId || !thread || threadSending) return + setThreadSending(true) + const tempId = newTempId() + setPendingReplies((prev) => [ + ...prev, + { tempId, convId, parentId: thread.root.id, text, ts: Date.now(), status: "pending" }, + ]) + setThreadDraft("") + await submitReply(text, tempId, convId, thread.root.id) + setThreadSending(false) + } + + const retryReply = (p: PendingMsg) => { + if (!p.convId || p.parentId == null) return + submitReply(p.text, p.tempId, p.convId, p.parentId).catch(() => {}) + } + + const discardReply = (tempId: string) => { + setPendingReplies((prev) => prev.filter((p) => p.tempId !== tempId)) + } + + const handleSpawnLoopFromThread = async () => { + if (!thread || spawning) return + setSpawning(true) + const r = await spawnLoopFromThread(thread.root.id) + if (r.loopId) { + // The server created the loop directly — refresh ws.loops so LoopPage + // finds it on mount (otherwise it falls back to /loop which redirects + // back to the first loop, causing a URL ping-pong). + await ws.refresh() + setSpawning(false) + navigate(`/loop/${r.loopId}`) + } else { + setSpawning(false) + if (r.error) console.error("spawn failed:", r.error) + } + } + + const handleCreateChannel = async (name: string, topic: string) => { + const r = await createChatChannel(name, topic || undefined) + if (r.conv) { + setShowNewChannel(false) + navigate(`/chat/${r.conv.id}`) + // refresh in case ws hasn't delivered yet + refreshConvs() + } else if (r.error) { + alert(r.error) + } + } + + const handleDeleteChannel = async (id: string) => { + if (!isAdmin) return + if (!confirm("Delete this channel? Messages will be archived but hidden.")) return + const r = await deleteChatChannel(id) + if (!r.ok && r.error) alert(r.error) + } + + const handleOpenDm = async (username: string) => { + const r = await openChatDm(username) + if (r.conv) { + setShowDmPicker(false) + navigate(`/chat/${r.conv.id}`) + refreshConvs() + } else if (r.error) { + alert(r.error) + } + } + + // ── render ── + + const sidebar = ( + <aside className="w-60 shrink-0 border-r border-gray-200 bg-white flex flex-col h-full"> + {isMobile && ( + <div className="h-9 flex items-center px-3 border-b border-gray-200"> + <button + onClick={() => setSidebarOpen(false)} + className="text-gray-500 hover:text-gray-900 px-1 rounded hover:bg-gray-100 mr-1" + title="close sidebar" + > + <PanelLeftClose size={14} /> + </button> + <span className="text-[11px] uppercase tracking-wide text-gray-500 font-medium">Chat</span> + </div> + )} + <div className={isMobile ? "flex-1 min-h-0 overflow-auto py-2" : ""}> + <div className="px-3 mt-3 mb-1 text-xs text-gray-500 flex items-center justify-between"> + <span>Channels</span> + <button + type="button" + onClick={() => setShowNewChannel(true)} + className="text-gray-500 hover:text-gray-900 text-base leading-none" + title="new channel" + >+</button> + </div> + <div className="flex flex-col gap-0.5"> + {channels.map((c) => ( + <ConvRow + key={c.id} + conv={c} + active={c.id === convId} + onClick={() => { navigate(`/chat/${c.id}`); if (isMobile) setSidebarOpen(false) }} + onDelete={isAdmin ? () => handleDeleteChannel(c.id) : undefined} + /> + ))} + {channels.length === 0 && ( + <div className="mx-2 px-2 py-1 text-[11px] text-gray-400">no channels yet</div> + )} + </div> + <div className="px-3 mt-4 mb-1 text-xs text-gray-500 flex items-center justify-between"> + <span>Direct messages</span> + <button + type="button" + onClick={() => setShowDmPicker(true)} + className="text-gray-500 hover:text-gray-900 text-base leading-none" + title="new DM" + >+</button> + </div> + <div className="flex flex-col gap-0.5"> + {dms.map((c) => ( + <ConvRow + key={c.id} + conv={c} + active={c.id === convId} + onClick={() => { navigate(`/chat/${c.id}`); if (isMobile) setSidebarOpen(false) }} + /> + ))} + {dms.length === 0 && ( + <div className="mx-2 px-2 py-1 text-[11px] text-gray-400">no DMs yet</div> + )} + </div> + </div> + <div className="flex-1" /> + </aside> + ) + + return ( + <div className="flex h-full w-full bg-white"> + {/* Rail */} + {isMobile ? ( + <> + {sidebarOpen ? ( + <div className="fixed inset-0 z-30" onClick={() => setSidebarOpen(false)}> + <div className="absolute inset-0 bg-black/30" /> + <div className="absolute left-0 top-0 bottom-0 w-60 max-w-[80vw] shadow-xl" onClick={(e) => e.stopPropagation()}> + {sidebar} + </div> + </div> + ) : ( + <aside className="w-9 shrink-0 border-r border-gray-200 bg-white flex flex-col items-center pt-2"> + <button + type="button" + onClick={() => setSidebarOpen(true)} + className="w-7 h-7 flex items-center justify-center text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded" + title="open sidebar" + > + <PanelLeftOpen size={16} /> + </button> + </aside> + )} + </> + ) : ( + sidebar + )} + + {/* Conversation pane */} + <main className="flex-1 min-w-0 flex flex-col bg-white"> + {active ? ( + <> + <header className="px-5 h-12 shrink-0 border-b border-gray-200 flex items-center justify-between"> + <div className="flex items-center gap-2 min-w-0"> + <span className="text-[15px] font-medium text-gray-900 truncate"> + {convSigil(active)}{convDisplayName(active)} + </span> + {active.topic && ( + <span className="text-xs text-gray-500 truncate">— {active.topic}</span> + )} + </div> + </header> + + <div ref={msgContainerRef} className="flex-1 min-h-0 overflow-auto px-5 py-4 flex flex-col gap-3 chat-messages-container"> + {messages.length === 0 && ( + <div className="text-[13px] text-gray-500">no messages yet — say hi</div> + )} + {visibleMessages.map((m) => ( + <MessageRow + key={m.id} + message={m} + isMe={m.author === me} + active={m.id === activeThreadRootId} + onOpenThread={() => setActiveThreadRootId(m.id)} + /> + ))} + {pendingRoots + .filter((p) => p.convId === convId) + .map((p) => ( + <PendingRow + key={p.tempId} + pending={p} + author={me} + onRetry={() => retryRoot(p)} + onDiscard={() => discardRoot(p.tempId)} + /> + ))} + <div ref={messagesEndRef} /> + </div> + + <div className="px-5 pb-4 pt-2 shrink-0"> + <div className="rounded-2xl border border-gray-200 bg-white p-2.5 shadow-sm flex flex-col gap-2"> + <textarea + value={draft} + onChange={(e) => setDraft(e.target.value)} + onKeyDown={(e) => { + // Skip Enter while IME is composing (CJK input methods + // use Enter to commit the candidate; firing send here + // sends the wrong text and steals the commit keystroke). + if (e.nativeEvent.isComposing) return + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSend() + } + }} + rows={1} + placeholder={`Message ${convSigil(active)}${convDisplayName(active)}…`} + className="field-sizing-content w-full max-h-40 min-h-10 resize-none bg-transparent px-1.5 py-1 text-sm text-gray-900 outline-none placeholder:text-gray-400" + /> + <div className="flex items-center justify-between border-t border-gray-100 pt-2 px-0.5"> + <div className="text-[10px] text-gray-400">Enter to send · Shift+Enter for newline</div> + <button + type="button" + onClick={handleSend} + disabled={sending || !draft.trim()} + className="px-3 py-1 rounded bg-gray-900 text-white text-xs hover:bg-gray-700 disabled:opacity-40" + >send</button> + </div> + </div> + </div> + </> + ) : ( + <div className="flex-1 flex items-center justify-center text-gray-500 text-sm"> + select a conversation + </div> + )} + </main> + + {/* Thread panel — Slack-style slide-out from the right. Renders only + when a thread is open. Width is fixed-ish (96 → 30vw) so the main + feed shrinks gracefully on smaller screens. */} + {active && activeThreadRootId != null && ( + <ThreadPanel + isMobile={isMobile} + thread={thread} + me={me} + spawning={spawning} + threadDraft={threadDraft} + threadSending={threadSending} + pendingReplies={pendingReplies} + threadEndRef={threadEndRef} + onClose={() => setActiveThreadRootId(null)} + onSpawnLoop={handleSpawnLoopFromThread} + onThreadDraftChange={setThreadDraft} + onThreadSend={handleThreadSend} + onRetryReply={retryReply} + onDiscardReply={discardReply} + /> + )} + + {showNewChannel && ( + <NewChannelDialog + onClose={() => setShowNewChannel(false)} + onCreate={handleCreateChannel} + /> + )} + {showDmPicker && ( + <DmPickerDialog + users={users} + existing={dms} + onClose={() => setShowDmPicker(false)} + onPick={handleOpenDm} + /> + )} + </div> + ) +} + +function ThreadPanel({ isMobile, thread, me, spawning, threadDraft, threadSending, pendingReplies, threadEndRef, onClose, onSpawnLoop, onThreadDraftChange, onThreadSend, onRetryReply, onDiscardReply }: { + isMobile: boolean + thread: { root: ChatMessage; replies: ChatMessage[] } | null + me: string + spawning: boolean + threadDraft: string + threadSending: boolean + pendingReplies: PendingMsg[] + threadEndRef: React.RefObject<HTMLDivElement | null> + onClose: () => void + onSpawnLoop: () => void + onThreadDraftChange: (v: string) => void + onThreadSend: () => void + onRetryReply: (p: PendingMsg) => void + onDiscardReply: (tempId: string) => void +}) { + const body = ( + <> + <header className="px-4 h-12 shrink-0 border-b border-gray-200 flex items-center justify-between"> + <div className="text-[13px] font-medium text-gray-900">Thread</div> + <div className="flex items-center gap-2"> + <button + type="button" + onClick={onSpawnLoop} + disabled={spawning || !thread} + className="px-2 py-1 rounded border border-gray-200 text-[11px] text-gray-700 hover:bg-gray-50 hover:border-gray-300 disabled:opacity-50 flex items-center gap-1" + > + <span className="text-gray-500">⑂</span> + <span>{spawning ? "spawning…" : "spawn loop"}</span> + </button> + <button + type="button" + onClick={onClose} + className="text-gray-400 hover:text-gray-900 text-base leading-none px-1" + >×</button> + </div> + </header> + <div className="flex-1 min-h-0 overflow-auto px-4 py-3 flex flex-col gap-3"> + {thread === null ? ( + <div className="text-[12px] text-gray-400 italic">loading…</div> + ) : ( + <> + <MessageRow message={thread.root} isMe={thread.root.author === me} compact /> + {thread.replies.length > 0 && ( + <div className="flex items-center gap-2 text-[11px] text-gray-400"> + <span className="flex-1 h-px bg-gray-200" /> + <span>{thread.replies.length} {thread.replies.length === 1 ? "reply" : "replies"}</span> + <span className="flex-1 h-px bg-gray-200" /> + </div> + )} + {thread.replies.map((r) => ( + <MessageRow key={r.id} message={r} isMe={r.author === me} compact /> + ))} + {pendingReplies + .filter((p) => thread && p.parentId === thread.root.id) + .map((p) => ( + <PendingRow + key={p.tempId} + pending={p} + author={me} + compact + onRetry={() => onRetryReply(p)} + onDiscard={() => onDiscardReply(p.tempId)} + /> + ))} + <div ref={threadEndRef} /> + </> + )} + </div> + <div className="px-4 pb-4 pt-2 shrink-0"> + <div className="rounded-2xl border border-gray-200 bg-white p-2.5 shadow-sm flex flex-col gap-2"> + <textarea + value={threadDraft} + onChange={(e) => onThreadDraftChange(e.target.value)} + onKeyDown={(e) => { + if (e.nativeEvent.isComposing) return + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + onThreadSend() + } + }} + rows={1} + placeholder="Reply…" + className="field-sizing-content w-full max-h-40 min-h-10 resize-none bg-transparent px-1.5 py-1 text-sm text-gray-900 outline-none placeholder:text-gray-400" + /> + <div className="flex items-center justify-between border-t border-gray-100 pt-2 px-0.5"> + <div className="text-[10px] text-gray-400">Enter to reply · Shift+Enter for newline</div> + <button + type="button" + onClick={onThreadSend} + disabled={threadSending || !threadDraft.trim() || !thread} + className="px-3 py-1 rounded bg-gray-900 text-white text-xs hover:bg-gray-700 disabled:opacity-40" + >reply</button> + </div> + </div> + </div> + </> + ) + + if (isMobile) { + return ( + <div className="fixed inset-0 z-30" onClick={onClose}> + <div className="absolute inset-0 bg-black/30" /> + <div className="absolute right-0 top-0 bottom-0 w-full max-w-full bg-white border-l border-gray-200 shadow-xl flex flex-col" onClick={(e) => e.stopPropagation()}> + {body} + </div> + </div> + ) + } + + return ( + <aside className="w-[28rem] max-w-[40vw] min-w-[20rem] shrink-0 border-l border-gray-200 bg-white flex flex-col"> + {body} + </aside> + ) +} + +function ConvRow(props: { + conv: ChatConversation + active: boolean + onClick: () => void + onDelete?: () => void +}) { + const c = props.conv + return ( + <div className="relative group"> + <button + type="button" + onClick={props.onClick} + className={ + props.active + ? "mx-2 px-2 py-1 w-[calc(100%-1rem)] rounded text-[13px] flex items-center gap-2 bg-gray-100 text-gray-900" + : "mx-2 px-2 py-1 w-[calc(100%-1rem)] rounded text-[13px] flex items-center gap-2 text-gray-500 hover:bg-gray-50 hover:text-gray-900" + } + > + <span className="text-gray-400">{convSigil(c)}</span> + <span className="truncate flex-1 text-left">{convDisplayName(c)}</span> + {c.unread > 0 && ( + <span className="text-[11px] px-1.5 rounded-full bg-gray-200 text-gray-700">{c.unread}</span> + )} + </button> + {props.onDelete && ( + <button + type="button" + onClick={(e) => { e.stopPropagation(); props.onDelete!() }} + className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 text-xs px-1" + title="delete channel (admin)" + >×</button> + )} + </div> + ) +} + +/** + * Single message row. If `message` is a ChatThreadRoot (has replyCount), + * renders the "💬 N replies" affordance and a hover "Reply" button. The + * `compact` variant disables both (used inside ThreadPanel where the + * thread is already open). + */ +function MessageRow(props: { + message: ChatMessage | ChatThreadRoot + isMe: boolean + active?: boolean + compact?: boolean + onOpenThread?: () => void +}) { + const m = props.message + const isMe = props.isMe + const replyCount = "replyCount" in m ? m.replyCount : 0 + const lastReplyTs = "lastReplyTs" in m ? m.lastReplyTs : null + return ( + <div + className={ + "group/msg relative -mx-1 px-1 py-0.5 rounded flex gap-3 " + + (isMe ? "flex-row-reverse " : "") + + (props.active ? "bg-amber-50/60" : "hover:bg-gray-50") + } + > + <div + className={ + isMe + ? "w-7 h-7 rounded shrink-0 flex items-center justify-center text-[11px] font-medium bg-gray-900 text-white" + : "w-7 h-7 rounded shrink-0 flex items-center justify-center text-[11px] font-medium bg-gray-200 text-gray-900" + } + title={m.author} + > + {m.author.slice(0, 1).toUpperCase()} + </div> + <div className="flex-1 min-w-0"> + <div className={`flex items-center gap-2 ${isMe ? "justify-end" : ""}`}> + {isMe ? ( + <> + <span className="text-[11px] text-gray-500">{formatTime(m.ts)}</span> + <span className="text-[10px] text-gray-500">you</span> + <span className="text-[13px] font-medium text-gray-900">{m.author}</span> + </> + ) : ( + <> + <span className="text-[13px] font-medium text-gray-900">{m.author}</span> + <span className="text-[11px] text-gray-500">{formatTime(m.ts)}</span> + </> + )} + </div> + <div className={`text-[13px] text-gray-900 whitespace-pre-wrap leading-relaxed break-words ${isMe ? "text-right" : ""}`}> + {m.text} + </div> + {/* Two states, same position (predictable scan path): + - replies exist → persistent "💬 N replies · last X" (always shown) + - no replies → bare "💬 Reply" shown ONLY on row hover (less noise) */} + {!props.compact && props.onOpenThread && replyCount > 0 && ( + <button + type="button" + onClick={props.onOpenThread} + className={`mt-1 text-[11px] text-blue-600 hover:underline ${isMe ? "ml-auto block text-right" : ""}`} + > + 💬 {replyCount} {replyCount === 1 ? "reply" : "replies"} + {lastReplyTs && <span className="text-gray-400 ml-1.5">· last {formatTime(lastReplyTs)}</span>} + </button> + )} + {!props.compact && props.onOpenThread && replyCount === 0 && ( + <button + type="button" + onClick={props.onOpenThread} + className={ + "mt-1 text-[11px] text-gray-400 hover:text-blue-600 hover:underline opacity-0 group-hover/msg:opacity-100 transition-opacity " + + (isMe ? "ml-auto block text-right" : "") + } + >💬 Reply</button> + )} + </div> + </div> + ) +} + +/** + * Render an outbox entry. Visual matches MessageRow shape (so it sits in + * the feed naturally), but with a status badge — never let a "sending" or + * "failed" entry look identical to a confirmed message. + * + * pending: faded body + "sending…" pill, no actions + * failed: red border + error text + Retry / Discard buttons + */ +function PendingRow(props: { + pending: PendingMsg + author: string + compact?: boolean + onRetry: () => void + onDiscard: () => void +}) { + const p = props.pending + const isFailed = p.status === "failed" + return ( + <div + className={ + "flex flex-row-reverse gap-3 -mx-1 px-1 py-0.5 rounded " + + (isFailed ? "ring-1 ring-red-200 bg-red-50/40" : "") + } + > + <div className="w-7 h-7 rounded shrink-0 flex items-center justify-center text-[11px] font-medium bg-gray-900 text-white" title={props.author}> + {props.author.slice(0, 1).toUpperCase()} + </div> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2 justify-end"> + <span className="text-[11px] text-gray-500">{formatTime(p.ts)}</span> + <span className="text-[10px] text-gray-500">you</span> + <span className="text-[13px] font-medium text-gray-900">{props.author}</span> + {isFailed ? ( + <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-100 text-red-700 border border-red-200">failed</span> + ) : ( + <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 inline-flex items-center gap-1"> + <span className="inline-block w-2 h-2 rounded-full bg-gray-400 animate-pulse" /> + sending… + </span> + )} + </div> + <div + className={ + "text-[13px] whitespace-pre-wrap leading-relaxed break-words text-right " + + (isFailed ? "text-gray-700" : "text-gray-400") + } + > + {p.text} + </div> + {isFailed && ( + <div className="mt-1 flex items-center gap-2 justify-end text-[11px]"> + {p.error && <span className="text-red-600">{p.error}</span>} + <button + type="button" + onClick={props.onRetry} + className="px-1.5 py-0.5 rounded border border-gray-300 text-gray-700 hover:bg-white" + >retry</button> + <button + type="button" + onClick={props.onDiscard} + className="px-1.5 py-0.5 rounded text-gray-500 hover:text-red-600" + >discard</button> + </div> + )} + </div> + </div> + ) +} + +function NewChannelDialog(props: { onClose: () => void; onCreate: (name: string, topic: string) => void }) { + const [name, setName] = useState("") + const [topic, setTopic] = useState("") + return ( + <div className="fixed inset-0 z-30 bg-black/30 flex items-center justify-center" onClick={props.onClose}> + <div className="bg-white rounded-md shadow-lg w-96 p-4 flex flex-col gap-3" onClick={(e) => e.stopPropagation()}> + <div className="text-sm font-medium text-gray-900">New channel</div> + <input + autoFocus + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="channel-name" + className="px-2 py-1.5 text-sm rounded border border-gray-200 outline-none focus:border-gray-400" + /> + <input + value={topic} + onChange={(e) => setTopic(e.target.value)} + placeholder="topic (optional)" + className="px-2 py-1.5 text-sm rounded border border-gray-200 outline-none focus:border-gray-400" + /> + <div className="flex justify-end gap-2 pt-1"> + <button type="button" onClick={props.onClose} className="px-3 py-1.5 text-xs text-gray-600 hover:text-gray-900">cancel</button> + <button + type="button" + onClick={() => name.trim() && props.onCreate(name.trim(), topic.trim())} + disabled={!name.trim()} + className="px-3 py-1.5 text-xs rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-40" + >create</button> + </div> + </div> + </div> + ) +} + +function DmPickerDialog(props: { + users: ChatWorkspaceUser[] + existing: ChatConversation[] + onClose: () => void + onPick: (username: string) => void +}) { + const [q, setQ] = useState("") + const filtered = props.users + .filter((u) => !u.isMe) + .filter((u) => u.id.toLowerCase().includes(q.toLowerCase())) + return ( + <div className="fixed inset-0 z-30 bg-black/30 flex items-center justify-center" onClick={props.onClose}> + <div className="bg-white rounded-md shadow-lg w-96 p-4 flex flex-col gap-3" onClick={(e) => e.stopPropagation()}> + <div className="text-sm font-medium text-gray-900">Direct message</div> + <input + autoFocus + value={q} + onChange={(e) => setQ(e.target.value)} + placeholder="search users…" + className="px-2 py-1.5 text-sm rounded border border-gray-200 outline-none focus:border-gray-400" + /> + <div className="max-h-64 overflow-auto flex flex-col gap-0.5"> + {filtered.map((u) => ( + <button + key={u.id} + type="button" + onClick={() => props.onPick(u.id)} + className="text-left px-2 py-1.5 text-sm rounded hover:bg-gray-100 flex items-center gap-2" + > + <span className="w-6 h-6 rounded bg-gray-200 text-gray-900 text-[11px] flex items-center justify-center"> + {u.id.slice(0, 1).toUpperCase()} + </span> + <span>{u.id}</span> + {u.role === "admin" && ( + <span className="text-[10px] text-gray-400 ml-auto">admin</span> + )} + </button> + ))} + {filtered.length === 0 && ( + <div className="text-xs text-gray-400 px-2 py-2">no users match</div> + )} + </div> + </div> + </div> + ) +} diff --git a/web/src/pages/ContextPage.tsx b/web/src/pages/ContextPage.tsx new file mode 100644 index 00000000..3405df66 --- /dev/null +++ b/web/src/pages/ContextPage.tsx @@ -0,0 +1,1300 @@ +/** + * Context tab. Layout/visuals + behavior ported from + * phase1-prototype/src/pages/context.tsx (VaultPane + DocView). + * - sub-nav: Knowledge / Notes / Personal / Repos (Agents skipped) + * - VaultPane: tree (left) + DocView (right) + * - DocView read mode: markdown + wikilinks + Backlinks panel (right w-64) + * - DocView edit mode: split source (CodeMirror) + live preview + * - Header buttons: distill (notes), edit by loop (non-secret), edit (non-knowledge) + * - Save → auto-commit (server side) + */ +import { NavLink, useParams, useNavigate, useSearchParams } from "react-router-dom" +import { + vaultList, + vaultFlatList, + vaultRead, + vaultWrite, + saveNotes, + notesBehind, + refreshNotes, + pullPersonalVault, + pushPersonalVault, + vaultCreateFile, + vaultCreateFolder, + vaultDeleteFile, + vaultBacklinks, + getContextRepos, + putContextRepos, + syncStatus, + syncPull, + syncPush, + type VaultEntry, + type VaultId, + type ContextRepoSpec, + type ContextRepoRoster, + type Backlink, + type SyncResource, + type RepoSyncStatus, +} from "../api" +import { useEffect, useState, useCallback, useRef, type FormEvent } from "react" +import { useWorkspace } from "../ctx" +import { useIsMobile } from "../lib/useIsMobile" +import { lazy, Suspense } from "react" +const CodeEditor = lazy(() => import("../components/markdown/CodeEditor").then(m => ({ default: m.CodeEditor }))) +const Markdown = lazy(() => import("../components/markdown/Markdown").then(m => ({ default: m.Markdown }))) +const MilkdownEditor = lazy(() => import("../components/markdown/MilkdownEditor").then(m => ({ default: m.MilkdownEditor }))) +import { PanelLeftClose, PanelLeftOpen, Trash2, File, Eye, FilePlus, FolderPlus, Upload, RefreshCw, Maximize2, Minimize2 } from "lucide-react" +import { Tree, type TreeNodeData, type TreeContextAction } from "../components/Tree" + +type SubId = VaultId + +const SUBS: { id: SubId; label: string }[] = [ + { id: "knowledge", label: "Knowledge" }, + { id: "notes", label: "Notes" }, + { id: "personal", label: "Personal" }, + { id: "repos", label: "Repos" }, +] + +const VALID = new Set<SubId>(["knowledge", "notes", "personal", "repos"]) + +export function ContextPage() { + const { sub } = useParams<{ sub: string }>() + const [searchParams] = useSearchParams() + const active = (VALID.has(sub as SubId) ? sub : "knowledge") as SubId + const initialFile = searchParams.get("file") || undefined + const initialEditing = searchParams.get("edit") === "1" + + return ( + <div className="flex flex-col h-full w-full"> + <nav className="flex items-center gap-1 px-3 h-9 shrink-0 border-b border-gray-200 bg-white"> + {SUBS.map((s) => ( + <NavLink + key={s.id} + to={`/context/${s.id}`} + className={({ isActive }) => + isActive + ? "h-7 px-2.5 rounded flex items-center gap-1.5 text-xs bg-gray-100 text-gray-900" + : "h-7 px-2.5 rounded flex items-center gap-1.5 text-xs text-gray-500 hover:bg-gray-50 hover:text-gray-900" + } + > + <span>{s.label}</span> + </NavLink> + ))} + </nav> + <div className="flex-1 min-h-0 min-w-0"> + {active === "repos" ? <ReposPane /> + : <VaultPane key={active} vault={active as VaultId} initialFile={initialFile} initialEditing={initialEditing} />} + </div> + </div> + ) +} + +// ============================================================================ +// VaultPane: left tree + right DocView +// ============================================================================ + +const VAULT_TAGLINE: Record<VaultId, string> = { + knowledge: "workspace's distilled materials", + notes: "workspace · public", + personal: "yours · private", + repos: "registered code repos", +} + +// Sidebar-footer widget that drives /api/sync/<resource>/{status,pull,push}. +// ff-only on both directions; on conflict the API returns 400 and we surface +// the error text. `canPush=false` hides the Push button (used for repos). +function SyncWidget({ resource, canPush = true }: { resource: SyncResource; canPush?: boolean }) { + const [status, setStatus] = useState<RepoSyncStatus | null>(null) + const [busy, setBusy] = useState<"pull" | "push" | "status" | null>(null) + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null) + + const refresh = useCallback(async () => { + setBusy("status") + const s = await syncStatus(resource) + setStatus(s) + setBusy(null) + }, [resource]) + + useEffect(() => { void refresh() }, [refresh]) + + const onPull = async () => { + setBusy("pull"); setMsg(null) + const r = await syncPull(resource) + setBusy(null) + setMsg(r.ok ? { ok: true, text: r.message ?? "pulled" } : { ok: false, text: r.error ?? "pull failed" }) + if (r.ok) void refresh() + } + const onPush = async () => { + setBusy("push"); setMsg(null) + const r = await syncPush(resource) + setBusy(null) + setMsg(r.ok ? { ok: true, text: r.message ?? "pushed" } : { ok: false, text: r.error ?? "push failed" }) + if (r.ok) void refresh() + } + + const ahead = status?.ahead ?? 0 + const behind = status?.behind ?? 0 + const noRemote = status && !status.hasRemote + + return ( + <div className="px-3 py-1.5 border-t border-gray-200 flex flex-col gap-1 text-[11px]"> + <div className="flex items-center gap-2"> + {noRemote ? ( + <span className="text-gray-400">no remote</span> + ) : ( + <> + <span className={ahead > 0 ? "text-emerald-700" : "text-gray-400"} title="unpushed commits">↑{ahead}</span> + <span className={behind > 0 ? "text-amber-700" : "text-gray-400"} title="unpulled commits">↓{behind}</span> + {status && status.uncommitted > 0 && ( + <span className="text-red-600" title="uncommitted changes in primary">! {status.uncommitted}</span> + )} + <button + onClick={() => void refresh()} + disabled={busy !== null} + className="ml-auto text-gray-400 hover:text-gray-700 disabled:opacity-50" + title="refresh status" + >↻</button> + </> + )} + </div> + {!noRemote && ( + <div className="flex items-center gap-1"> + <button + onClick={onPull} + disabled={busy !== null || behind === 0} + className="px-1.5 h-5 rounded border border-gray-200 hover:bg-gray-100 text-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" + > + {busy === "pull" ? "pulling…" : "pull"} + </button> + {canPush && ( + <button + onClick={onPush} + disabled={busy !== null || ahead === 0} + className="px-1.5 h-5 rounded border border-gray-200 hover:bg-gray-100 text-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" + > + {busy === "push" ? "pushing…" : "push"} + </button> + )} + {msg && ( + <span className={"truncate flex-1 min-w-0 " + (msg.ok ? "text-emerald-700" : "text-red-600")} title={msg.text}> + {msg.text} + </span> + )} + </div> + )} + </div> + ) +} + +// notes is edited through a per-user UI-loop worktree. This bar polls how far +// behind origin you are (every 5s — a hint, never an auto-refresh) and offers a +// manual Refresh (ff-pull). On open it pulls once to start from the latest. +function NotesSyncBar({ onRefreshed }: { onRefreshed: () => void }) { + const [behind, setBehind] = useState(0) + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState<string | null>(null) + const onRefreshedRef = useRef(onRefreshed) + onRefreshedRef.current = onRefreshed + + const doRefresh = useCallback(async () => { + setBusy(true); setMsg(null) + const r = await refreshNotes() + setBusy(false) + if (r.ok) { setBehind(0); onRefreshedRef.current() } + else if (r.diverged) setMsg("你有未保存的编辑,先保存再刷新") + else setMsg(r.error ?? "refresh failed") + }, []) + + // On open: pull once to start from the latest. + useEffect(() => { void doRefresh() }, [doRefresh]) + + // Poll how far behind origin we are, every 5s. No auto-refresh — just the hint. + useEffect(() => { + const tick = () => notesBehind().then(setBehind).catch(() => {}) + tick() + const id = setInterval(tick, 5000) + return () => clearInterval(id) + }, []) + + return ( + <div className="px-3 py-2 border-t border-gray-200 flex flex-col gap-1.5 text-[11px]"> + {behind > 0 ? ( + <div className="flex items-center justify-between gap-2 bg-amber-50 border border-amber-200 rounded px-2 py-1 text-amber-800"> + <span>远端已更新 {behind} 个改动</span> + <button + type="button" + onClick={doRefresh} + disabled={busy} + className="px-2 py-0.5 rounded bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-50" + > + {busy ? "刷新中…" : "刷新"} + </button> + </div> + ) : ( + <div className="flex items-center justify-between gap-2 text-gray-400"> + <span>已是最新</span> + <button + type="button" + onClick={doRefresh} + disabled={busy} + className="text-gray-500 hover:text-gray-900 underline disabled:opacity-50" + > + {busy ? "刷新中…" : "刷新"} + </button> + </div> + )} + {msg && <div className="text-amber-700">{msg}</div>} + </div> + ) +} + +function VaultPane({ vault, initialFile, initialEditing }: { vault: VaultId; initialFile?: string; initialEditing?: boolean }) { + const [tree, setTree] = useState<VaultEntry[]>([]) + const [flat, setFlat] = useState<VaultEntry[]>([]) + const [pickedPath, setPickedPath] = useState<string | null>(initialFile ?? null) + const [searchParams, setSearchParams] = useSearchParams() + + // Sync pickedPath to the URL so navigation is reflected in the address bar. + // Guard against loops: skip if URL already has the same file. + useEffect(() => { + const current = searchParams.get("file") ?? null + if (current === pickedPath) return + if (pickedPath) { + setSearchParams({ file: pickedPath }, { replace: true }) + } else { + setSearchParams({}, { replace: true }) + } + }, [pickedPath, searchParams, setSearchParams]) + const [reloadKey, setReloadKey] = useState(0) + const [showNewFile, setShowNewFile] = useState(false) + const [query, setQuery] = useState("") + const [sidebarOpen, setSidebarOpen] = useState(false) + const [creating, setCreating] = useState<{ type: "file" | "folder"; path: string } | null>(null) + const [newName, setNewName] = useState("") + const isMobile = useIsMobile() + + // initialize expansion and file selection from ?file= query param + const initRef = useRef(false) + if (!initRef.current && initialFile) { + initRef.current = true + const treeId = `vault-${vault}` + const parentDir = initialFile.includes("/") ? initialFile.substring(0, initialFile.lastIndexOf("/")) : "" + if (parentDir) { + try { + const key = "loopat:tree:expanded:" + treeId + const raw = localStorage.getItem(key) + const expanded: string[] = raw ? JSON.parse(raw) : [] + for (let p = parentDir; p; p = p.includes("/") ? p.substring(0, p.lastIndexOf("/")) : "") { + if (!expanded.includes(p)) expanded.push(p) + } + localStorage.setItem(key, JSON.stringify(expanded)) + } catch {} + } + } + + useEffect(() => { + vaultList(vault).then((entries) => { + setTree(entries) + setPickedPath((prev) => { + // Keep the initial file from URL even if it's not in the listing + // (e.g. .loopat/config.json from Settings → "edit raw"). + if (prev) return prev + const first = entries.find((e) => e.type === "file" && e.path.endsWith(".md")) + return first ? first.path : null + }) + }) + vaultFlatList(vault).then(setFlat) + setQuery("") + }, [vault, reloadKey]) + + // Background sync on entering a vault: the point-open shows the local cache + // instantly (above), then we sync from origin in the background and, only if + // something changed, bump reloadKey once so the tree refetches. Depends on + // [vault] only (NOT reloadKey) so it fires on entry, not in a loop. Same UX + // for all three; the mechanism differs by structure: + // - knowledge / personal: fetch+ff the main repo + // - notes: ff the UI-loop worktree to origin/main (check `behind` first) + // (repos is a roster, not a file tree — handled by ReposPane.) + useEffect(() => { + let cancelled = false + const bump = () => { if (!cancelled) setReloadKey((k) => k + 1) } + if (vault === "knowledge") { + syncPull("knowledge").then((r) => { + if (r.ok && (r.message ?? "").toLowerCase() !== "already up to date") bump() + }) + } else if (vault === "notes") { + notesBehind().then((n) => { + if (n > 0) refreshNotes().then((r) => { if (r.ok) bump() }) + }) + } else if (vault === "personal") { + // Clean ff only — if the pull would conflict / needs a stash (local edits), + // stay on the local copy silently rather than disrupt the user. + pullPersonalVault().then((r) => { + if (r.ok && (r.files?.length ?? 0) > 0) bump() + }) + } + return () => { cancelled = true } + }, [vault]) + + const onCreate = async (path: string) => { + const r = await vaultCreateFile(vault, path) + if (r.ok) { + setShowNewFile(false) + setReloadKey((k) => k + 1) + setPickedPath(path) + } else { + alert(`failed: ${r.error}`) + } + } + + const handleCreate = async () => { + if (!creating || !newName.trim()) { setCreating(null); return } + const targetPath = creating.path + "/" + newName.trim() + if (creating.type === "file") { + const r = await vaultCreateFile(vault, targetPath) + if (!r.ok) { alert(`create failed: ${r.error}`); return } + } else { + const r = await vaultCreateFolder(vault, targetPath) + if (!r.ok) { alert(`create failed: ${r.error}`); return } + } + setCreating(null) + setNewName("") + setReloadKey((k) => k + 1) + } + + const handleAction = useCallback((action: string, node: TreeNodeData) => { + if (action === "view") { + setPickedPath(node.path) + } else if (action === "new-file" || action === "new-folder") { + setCreating({ type: action === "new-file" ? "file" : "folder", path: node.path }) + setNewName("") + } else if (action === "delete") { + if (!confirm(`Delete "${node.name}"?`)) return + vaultDeleteFile(vault, node.path).then((r) => { + if (r.ok) { + setPickedPath((p) => p === node.path ? null : p) + setReloadKey((k) => k + 1) + } else { + alert(`delete failed: ${r.error}`) + } + }) + } + }, [vault]) + + const getContextActions = useCallback((node: TreeNodeData): TreeContextAction[] => { + if (isSecretsFolder(vault, node.path)) return [] + if (node.type === "dir") { + return [ + { label: "New file", icon: <FilePlus size={12} />, action: "new-file" }, + { label: "New folder", icon: <FolderPlus size={12} />, action: "new-folder" }, + { label: "Delete", icon: <Trash2 size={12} />, action: "delete", danger: true }, + ] + } + if (isSecretFile(vault, node.path)) { + return [] + } + return [ + { label: "View", icon: <Eye size={12} />, action: "view" }, + { label: "Delete", icon: <Trash2 size={12} />, action: "delete", danger: true }, + ] + }, [vault]) + + const handleLoadChildren = useCallback((path: string) => { + return vaultList(vault, path).then((entries) => entries as TreeNodeData[]) + }, [vault]) + + const getNodeClassName = useCallback((node: TreeNodeData, depth: number, isOpen: boolean, isPicked: boolean): string => { + if (node.type === "dir") { + const secretsFolder = isSecretsFolder(vault, node.path) + if (secretsFolder) { + return "w-full py-1.5 flex items-center gap-1.5 bg-amber-50/40 hover:bg-amber-50 text-left border-y border-amber-200/60 mt-1" + } + return "w-full py-1 flex items-center gap-1 hover:bg-gray-50 text-left" + } + const secret = isSecretFile(vault, node.path) + return ( + "w-full py-1 flex items-center gap-2 text-left " + + (isPicked ? "bg-gray-100" : secret ? "hover:bg-amber-50/50" : "hover:bg-gray-50") + ) + }, [vault]) + + const q = query.trim().toLowerCase() + const searching = q.length > 0 + const matches = searching + ? flat + .filter((e) => e.path.toLowerCase().includes(q)) + .slice(0, 60) + : [] + + const sidebar = ( + <aside className="w-64 shrink-0 border-r border-gray-200 bg-white flex flex-col h-full"> + <div className="px-3 h-9 flex items-center gap-1 border-b border-gray-200"> + {isMobile && ( + <button + onClick={() => setSidebarOpen(false)} + className="text-gray-500 hover:text-gray-900 px-1 rounded hover:bg-gray-100 mr-1" + title="close sidebar" + > + <PanelLeftClose size={14} /> + </button> + )} + <SearchIcon /> + <input + type="text" + value={query} + onChange={(e) => setQuery(e.target.value)} + placeholder="search files…" + className="flex-1 min-w-0 bg-transparent outline-none text-[12px] text-gray-700 placeholder:text-gray-400" + /> + <button + onClick={() => setShowNewFile(true)} + className="text-gray-500 hover:text-gray-900 px-1.5 rounded hover:bg-gray-100 text-xs" + title="new file" + > + + + </button> + <button + onClick={() => setReloadKey((k) => k + 1)} + className="text-gray-500 hover:text-gray-900 px-1.5 rounded hover:bg-gray-100 text-xs" + title="refresh" + > + ↻ + </button> + </div> + <div className="flex-1 min-h-0 overflow-auto py-2"> + {searching ? ( + matches.length === 0 ? ( + <div className="px-3 py-4 text-[12px] text-gray-400 italic">no matches</div> + ) : ( + matches.map((e) => ( + <button + key={e.path} + type="button" + onClick={() => { + setPickedPath(e.path) + if (isMobile) setSidebarOpen(false) + }} + className={ + "w-full px-3 py-1.5 flex items-center gap-2 text-left " + + (pickedPath === e.path ? "bg-gray-100" : "hover:bg-gray-50") + } + title={e.path} + > + <span className="text-gray-500">📄</span> + <span className="flex-1 min-w-0 truncate text-[13px] text-gray-900">{e.path}</span> + </button> + )) + ) + ) : ( + <> + <Tree + treeId={`vault-${vault}`} + entries={tree as TreeNodeData[]} + onPick={(path) => { + setPickedPath(path) + if (isMobile) setSidebarOpen(false) + }} + picked={pickedPath} + onLoadChildren={handleLoadChildren} + getContextActions={getContextActions} + onAction={handleAction} + nodeClassName={getNodeClassName} + reloadKey={reloadKey} + /> + {tree.length === 0 && ( + <div className="px-3 py-4 text-[12px] text-gray-400 italic"> + empty · click + 创建第一个文件 + </div> + )} + </> + )} + </div> + <div className="px-3 h-9 border-t border-gray-200 flex items-center text-[11px] text-gray-500"> + {VAULT_TAGLINE[vault]} + </div> + {vault === "knowledge" && <SyncWidget resource={vault} />} + {vault === "notes" && <NotesSyncBar onRefreshed={() => setReloadKey((k) => k + 1)} />} + </aside> + ) + + return ( + <div className="flex h-full w-full"> + {isMobile ? ( + <> + {sidebarOpen ? ( + <div className="fixed inset-0 z-30" onClick={() => setSidebarOpen(false)}> + <div className="absolute inset-0 bg-black/30" /> + <div className="absolute left-0 top-0 bottom-0 w-64 max-w-[80vw] shadow-xl" onClick={(e) => e.stopPropagation()}> + {sidebar} + </div> + </div> + ) : ( + <aside className="w-9 shrink-0 border-r border-gray-200 bg-white flex flex-col items-center pt-2"> + <button + type="button" + onClick={() => setSidebarOpen(true)} + className="w-7 h-7 flex items-center justify-center text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded" + title="open file tree" + > + <PanelLeftOpen size={16} /> + </button> + </aside> + )} + </> + ) : ( + sidebar + )} + <main className="flex-1 min-w-0 flex flex-col bg-white min-h-0"> + {pickedPath ? ( + <DocView + vault={vault} + path={pickedPath} + onSelect={setPickedPath} + onSaved={() => setReloadKey((k) => k + 1)} + initialEditing={initialEditing} + /> + ) : ( + <div className="flex-1 flex items-center justify-center text-[13px] text-gray-400 italic"> + 选一个文件,或点 + 新建 + </div> + )} + </main> + {showNewFile && <NewFileDialog vault={vault} onClose={() => setShowNewFile(false)} onCreate={onCreate} />} + {creating && ( + <CreateItemDialog + type={creating.type} + parentPath={creating.path} + value={newName} + onChange={setNewName} + onSubmit={handleCreate} + onCancel={() => setCreating(null)} + /> + )} + </div> + ) +} + +function SearchIcon() { + return ( + <svg viewBox="0 0 16 16" width="14" height="14" className="text-gray-400 shrink-0"> + <path + fill="none" + stroke="currentColor" + strokeWidth="1.4" + strokeLinecap="round" + d="M11 11l3 3M7 12.5a5.5 5.5 0 1 1 0-11 5.5 5.5 0 0 1 0 11z" + /> + </svg> + ) +} + +/** + * Anything under `.loopat/vaults/<vaultName>/...` is secret-bearing. + * + * "secrets folder" = the vault root and any directory inside it (gets the + * amber "encrypted" treatment in the tree). + * "secret file" = any file inside a vault (Context page redacts on read, + * lets the user overwrite blind on edit). + */ +function isSecretsFolder(vault: VaultId, path: string): boolean { + if (vault !== "personal") return false + if (!path.startsWith(".loopat/vaults/")) return false + const rest = path.slice(".loopat/vaults/".length) + return rest.length > 0 +} +function isSecretFile(vault: VaultId, path: string): boolean { + if (vault !== "personal") return false + if (!path.startsWith(".loopat/vaults/")) return false + const rest = path.slice(".loopat/vaults/".length) + return rest.includes("/") +} + +function CreateItemDialog({ type, parentPath, value, onChange, onSubmit, onCancel }: { + type: "file" | "folder"; + parentPath: string; + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + onCancel: () => void; +}) { + const label = type === "file" ? "New file" : "New folder" + const placeholder = type === "file" ? "filename.md" : "folder-name" + return ( + <div className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center" onClick={onCancel}> + <div + className="w-full max-w-[420px] mx-4 bg-white rounded-md shadow-xl border border-gray-200 p-4 md:p-5" + onClick={(e) => e.stopPropagation()} + > + <div className="text-base font-semibold text-gray-900 mb-3">{label} in <span className="font-mono text-[13px]">{parentPath || "root"}</span></div> + <input + autoFocus + type="text" + value={value} + onChange={(e) => onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) onSubmit() + if (e.key === "Escape") onCancel() + }} + placeholder={placeholder} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded outline-none focus:border-gray-500 font-mono" + /> + <div className="flex justify-end gap-2 mt-4"> + <button onClick={onCancel} className="px-3 h-8 text-sm rounded text-gray-700 hover:bg-gray-100"> + cancel + </button> + <button + onClick={() => value.trim() && onSubmit()} + disabled={!value.trim()} + className="px-3 h-8 text-sm rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50" + > + create + </button> + </div> + </div> + </div> + ) +} + +function CreateInline({ depth, type, value, onChange, onSubmit, onCancel }: { + depth: number; + type: "file" | "folder"; + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + onCancel: () => void; +}) { + const ref = useRef<HTMLInputElement>(null) + useEffect(() => { ref.current?.focus() }, []) + + return ( + <div className="flex items-center gap-1 py-0.5" style={{ paddingLeft: 8 + depth * 12 }}> + <span className="text-gray-400">{type === "file" ? "📄" : "📁"}</span> + <input + ref={ref} + value={value} + onChange={(e) => onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) onSubmit() + if (e.key === "Escape") onCancel() + }} + placeholder={type === "file" ? "filename.md" : "folder-name"} + className="flex-1 px-1.5 py-0.5 text-[12px] border border-gray-300 rounded outline-none focus:border-gray-900" + /> + <button onClick={onSubmit} className="text-[10px] text-emerald-600 hover:text-emerald-800 px-1">✓</button> + <button onClick={onCancel} className="text-[10px] text-gray-400 hover:text-gray-600 px-1">✕</button> + </div> + ) +} + +// ============================================================================ +// DocView: header (3 action buttons) + read mode (markdown + backlinks) + +// edit mode (split source / preview). Mirrors phase-1. +// ============================================================================ + +function isSecretPath(path: string): boolean { + // convention: anything under a `secrets/` segment is a secret + return /(^|\/)secrets\//.test(path) || path === "secrets" +} + +function DocView({ + vault, + path, + onSelect, + onSaved, + initialEditing, +}: { + vault: VaultId + path: string + onSelect: (path: string) => void + onSaved: () => void + initialEditing?: boolean +}) { + const ws = useWorkspace() + const navigate = useNavigate() + const [original, setOriginal] = useState("") + const [draft, setDraft] = useState("") + const [editing, setEditing] = useState(initialEditing ?? false) + const [saving, setSaving] = useState(false) + const [backlinks, setBacklinks] = useState<Backlink[]>([]) + const [lastCommit, setLastCommit] = useState<string | null>(null) + const [showBacklinks, setShowBacklinks] = useState(false) + const [milkdown, setMilkdown] = useState(false) + const [fullscreen, setFullscreen] = useState(false) + // Server flags secret files; the response never carries plaintext, so + // `original` stays empty and the only way to mutate is to type a new value. + const [secretFromServer, setSecretFromServer] = useState(false) + const isMobile = useIsMobile() + + const initialEditingRef = useRef(initialEditing) + + useEffect(() => { + if (initialEditingRef.current) { + initialEditingRef.current = false + } else { + setEditing(false) + } + setMilkdown(false) + setLastCommit(null) + setFullscreen(false) + setSecretFromServer(false) + vaultRead(vault, path).then((r) => { + const c = r?.content ?? "" + setOriginal(c) + setDraft(c) + setSecretFromServer(!!r?.secret) + }) + vaultBacklinks(vault, path).then(setBacklinks) + }, [vault, path]) + + const isSecret = isSecretPath(path) || secretFromServer + const isMd = path.endsWith(".md") + const allowDirectEdit = vault !== "knowledge" + const allowLoopEdit = !isSecret + const allowDistill = vault === "notes" && !isSecret + + // For secrets, the editor opens empty and a non-empty draft is always + // considered "dirty" (a replacement). For non-secrets, the diff between + // draft and the loaded original drives dirty state. + const dirty = isSecret ? draft.length > 0 : draft !== original + + const save = useCallback(async () => { + if (!dirty || saving) return + setSaving(true) + try { + const r = await vaultWrite(vault, path, draft) + if (r.ok) { + // For secrets, throw the just-typed value away from React state + // immediately after the write returns ok — otherwise re-entering + // edit would surface it. The server never reads it back to us. + if (isSecret) { + setOriginal("") + setDraft("") + } else { + setOriginal(draft) + } + setLastCommit(r.commit ?? null) + setEditing(false) + onSaved() + // refresh backlinks (links may have changed) + vaultBacklinks(vault, path).then(setBacklinks) + // notes + personal live on shared remotes and are UNGATED — push the + // save right away (ff-only; a real conflict is held back, the local edit + // kept). knowledge is GATED (promote via a distill loop), so it only + // commits locally here and is pushed via its own promote flow. + if (vault === "notes") { + const sync = await saveNotes() + if (!sync.ok) { + if (sync.conflict) { + alert(`Saved locally, but it conflicts with the remote (${(sync.files ?? []).join(", ")}). Your edit is kept — resolve by taking the remote, or in a loop.`) + } else { + alert(`Saved locally, but push to remote failed: ${sync.error ?? "unknown"}`) + } + } + } else if (vault === "personal") { + const sync = await pushPersonalVault() + if (!sync.ok) { + if (sync.conflict) { + alert(`Saved locally, but it conflicts with the remote (${(sync.files ?? []).join(", ")}). Your edit is kept — pull first (re-open Personal) or resolve in a loop.`) + } else { + alert(`Saved locally, but push to remote failed: ${sync.error ?? "unknown"}`) + } + } + } + } else { + alert(`save failed: ${r.error}`) + } + } finally { + setSaving(false) + } + }, [vault, path, draft, dirty, saving, onSaved, isSecret]) + + const startEdit = () => { setEditing(true); if (isMd) setMilkdown(true) } + + const cancelEdit = () => { + setDraft(original) + setEditing(false) + } + + const startEditByLoop = async () => { + const m = await ws.createLoop({ title: `edit ${vault}/${path}` }) + navigate(`/loop/${m.id}`) + } + + const startDistill = async () => { + const m = await ws.createLoop({ title: `distill ${path} → knowledge`, knowledgeRw: true }) + navigate(`/loop/${m.id}`) + } + + // wikilink target → file path: try `<target>.md` in same dir as current path, + // then `<target>.md` from vault root, then `<target>` literal + const onWikilink = (target: string) => { + const candidates = [ + path.replace(/[^/]+$/, "") + target + ".md", + target + ".md", + target, + ] + // try first candidate (we don't have a file-existence check here; just navigate + // and let read return null if missing — empty content for now) + onSelect(candidates[0].replace(/^\//, "")) + } + + return ( + <> + <header className="px-3 md:px-5 h-10 shrink-0 border-b border-gray-200 flex items-center justify-between"> + <div className="flex items-center gap-2 text-[13px] min-w-0"> + <span className="text-gray-500 shrink-0">{isSecret ? "🔒" : "📄"}</span> + <span className="font-mono text-[12px] text-gray-500 truncate">{path}</span> + {lastCommit && !dirty && !editing && ( + <span className="hidden md:inline text-[10px] text-emerald-700 font-mono">commit {lastCommit}</span> + )} + </div> + <div className="flex items-center gap-1 md:gap-2 text-xs text-gray-500"> + {editing ? ( + <> + <button + onClick={cancelEdit} + className="px-2.5 h-7 rounded text-xs text-gray-600 hover:bg-gray-100" + > + cancel + </button> + <button + onClick={save} + disabled={!dirty || saving} + className="px-2.5 h-7 rounded text-xs bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50" + > + {saving ? "saving…" : "save"} + </button> + </> + ) : ( + <> + {allowDistill && ( + <button + onClick={startDistill} + className="px-2.5 h-7 rounded text-xs bg-amber-100 text-amber-900 hover:bg-amber-200 flex items-center gap-1" + title="open a loop to distill this notes file into knowledge" + > + <span>↑</span> + <span>distill</span> + </button> + )} + {allowLoopEdit && ( + <button + onClick={startEditByLoop} + className={ + allowDistill + ? "px-2.5 h-7 rounded text-xs border border-gray-200 hover:bg-gray-100 text-gray-900 flex items-center gap-1" + : "px-2.5 h-7 rounded text-xs bg-gray-900 text-white hover:bg-gray-700 flex items-center gap-1" + } + title="open a new loop with AI assist for this file" + > + <span>↻</span> + <span>edit by loop</span> + </button> + )} + {allowDirectEdit && ( + <button + onClick={startEdit} + className="px-2.5 h-7 rounded text-xs border border-gray-200 hover:bg-gray-100 text-gray-900" + title="direct edit (fastpath)" + > + edit + </button> + )} + {isMobile && isMd && ( + <button + onClick={() => setShowBacklinks((v) => !v)} + className={`px-2.5 h-7 rounded text-xs border ${showBacklinks ? "bg-gray-900 text-white" : "border-gray-200 hover:bg-gray-100 text-gray-900"}`} + title="toggle backlinks" + > + ⇄ {backlinks.length} + </button> + )} + </> + )} + </div> + </header> + + {editing ? ( + isSecret ? ( + // edit mode for a secret: single full-width editor starting empty; + // save sends the typed value as a full replacement. + <div className={fullscreen ? "fixed inset-0 z-50 bg-white flex flex-col" : "flex-1 min-h-0 min-w-0 flex flex-col"}> + <div className="px-3 h-7 shrink-0 border-b border-gray-200 flex items-center gap-2 text-[11px] text-gray-500"> + <span className="text-amber-700">🔒</span> + <span>new value · saved encrypted</span> + <div className="flex-1" /> + <button + onClick={() => setFullscreen(!fullscreen)} + className="text-gray-400 hover:text-gray-700 px-1 rounded hover:bg-gray-100" + title={fullscreen ? "restore" : "maximize"} + > + {fullscreen ? <Minimize2 size={13} /> : <Maximize2 size={13} />} + </button> + </div> + <div className="flex-1 min-h-0 relative"> + <Suspense fallback={<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">Loading editor...</div>}> + <CodeEditor path={path} value={draft} onChange={setDraft} /> + </Suspense> + </div> + </div> + ) : isMd && !milkdown ? ( + // edit mode for markdown: split source / preview + <div className={fullscreen ? "fixed inset-0 z-50 bg-white flex flex-col md:flex-row" : "flex-1 min-h-0 min-w-0 flex flex-col md:flex-row"}> + <div className="flex-1 min-w-0 min-h-0 border-r border-gray-200 flex flex-col"> + <div className="px-3 h-7 shrink-0 border-b border-gray-200 flex items-center gap-2 text-[11px] text-gray-500"> + <span>source · markdown</span> + <button + type="button" + onClick={() => setMilkdown(true)} + className="px-2 h-5 rounded text-[10px] bg-gray-100 hover:bg-gray-200 text-gray-600" + title="Switch to WYSIWYG editor" + > + wysiwyg + </button> + <div className="flex-1" /> + <button + onClick={() => setFullscreen(!fullscreen)} + className="text-gray-400 hover:text-gray-700 px-1 rounded hover:bg-gray-100" + title={fullscreen ? "restore" : "maximize"} + > + {fullscreen ? <Minimize2 size={13} /> : <Maximize2 size={13} />} + </button> + </div> + <div className="flex-1 min-h-0 relative"> + <Suspense fallback={<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">Loading editor...</div>}><CodeEditor path={path} value={draft} onChange={setDraft} /></Suspense> + </div> + </div> + <div className="flex-1 min-w-0 min-h-0 flex flex-col"> + <div className="px-3 h-7 shrink-0 border-b border-gray-200 flex items-center text-[11px] text-gray-500"> + preview + </div> + <div className="flex-1 min-h-0 overflow-auto px-6 py-4"> + <div className="max-w-[760px]"> + <Suspense fallback={<div className="text-gray-400 text-sm">Loading preview...</div>}><Markdown text={draft} onWikilink={onWikilink} /></Suspense> + </div> + </div> + </div> + </div> + ) : isMd ? ( + // edit mode for markdown: WYSIWYG (Milkdown), no preview needed + <div className={fullscreen ? "fixed inset-0 z-50 bg-white flex flex-col" : "flex-1 min-h-0 min-w-0 flex flex-col"}> + <div className="px-3 h-7 shrink-0 border-b border-gray-200 flex items-center gap-2 text-[11px] text-gray-500"> + <span>wysiwyg · milkdown</span> + <button + type="button" + onClick={() => setMilkdown(false)} + className="px-2 h-5 rounded text-[10px] bg-gray-100 hover:bg-gray-200 text-gray-600" + title="Switch to source editor" + > + source + </button> + <div className="flex-1" /> + <button + onClick={() => setFullscreen(!fullscreen)} + className="text-gray-400 hover:text-gray-700 px-1 rounded hover:bg-gray-100" + title={fullscreen ? "restore" : "maximize"} + > + {fullscreen ? <Minimize2 size={13} /> : <Maximize2 size={13} />} + </button> + </div> + <div className="flex-1 min-h-0 relative"> + <Suspense fallback={<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">Loading editor...</div>}> + <MilkdownEditor value={draft} onChange={setDraft} /> + </Suspense> + </div> + </div> + ) : ( + // edit mode for non-markdown: full-width editor + <div className={fullscreen ? "fixed inset-0 z-50 bg-white flex flex-col" : "flex-1 min-h-0 min-w-0 flex flex-col"}> + <div className="px-3 h-7 shrink-0 border-b border-gray-200 flex items-center gap-2 text-[11px] text-gray-500"> + <span>source · {path.includes(".") ? path.split(".").pop() : "text"}</span> + {path.endsWith(".json") && ( + <button + type="button" + onClick={() => { + try { + const formatted = JSON.stringify(JSON.parse(draft), null, 2) + setDraft(formatted) + } catch (e: any) { + alert("Invalid JSON: " + (e?.message ?? "parse error")) + } + }} + className="px-2 h-5 rounded text-[10px] bg-gray-100 hover:bg-gray-200 text-gray-600" + title="Format JSON" + > + format + </button> + )} + <div className="flex-1" /> + <button + onClick={() => setFullscreen(!fullscreen)} + className="text-gray-400 hover:text-gray-700 px-1 rounded hover:bg-gray-100" + title={fullscreen ? "restore" : "maximize"} + > + {fullscreen ? <Minimize2 size={13} /> : <Maximize2 size={13} />} + </button> + </div> + <div className="flex-1 min-h-0 relative"> + <Suspense fallback={<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">Loading editor...</div>}><CodeEditor path={path} value={draft} onChange={setDraft} /></Suspense> + </div> + </div> + ) + ) : ( + // read mode: article + backlinks + <div className="flex flex-1 min-h-0 min-w-0 overflow-hidden"> + <article className="flex-1 min-h-0 overflow-auto px-4 md:px-8 py-4 md:py-6"> + <div className="max-w-[760px]"> + {isSecret ? ( + <div className="font-sans text-[13px] text-gray-600 leading-relaxed"> + <div className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-amber-50 border border-amber-200 text-amber-800 text-[12px] font-medium"> + encrypted · value hidden by design + </div> + <div className="mt-3 text-[12px] text-gray-500"> + Click <b>edit</b> to overwrite. The current value is never + returned by the server, so editing means typing a new + replacement — there is no decrypt-and-view. + </div> + </div> + ) : isMd ? ( + <Suspense fallback={<div className="text-gray-400 text-sm">Loading...</div>}><Markdown text={original} onWikilink={onWikilink} /></Suspense> + ) : ( + <pre className="font-mono text-[12.5px] leading-relaxed whitespace-pre-wrap text-gray-900"> + {original || <span className="text-gray-400 italic">(empty)</span>} + </pre> + )} + </div> + </article> + {isMd && !isMobile && ( + <aside className="w-64 shrink-0 border-l border-gray-200 bg-gray-50 overflow-auto"> + <div className="px-3 h-9 flex items-center border-b border-gray-200"> + <span className="text-[11px] text-gray-500">Backlinks</span> + <span className="ml-auto text-[11px] text-gray-500">{backlinks.length}</span> + </div> + {backlinks.length === 0 ? ( + <div className="px-3 py-4 text-xs text-gray-500">No documents link here yet.</div> + ) : ( + <ul className="py-2"> + {backlinks.map((b) => ( + <li key={b.path}> + <button + type="button" + onClick={() => onSelect(b.path)} + className="w-full px-3 py-2 text-left hover:bg-gray-100" + > + <div className="text-xs font-medium text-gray-900 truncate">{b.path}</div> + <div className="text-[11px] text-gray-500 mt-0.5 leading-snug line-clamp-2">{b.preview}</div> + </button> + </li> + ))} + </ul> + )} + </aside> + )} + {isMobile && isMd && showBacklinks && ( + <div className="fixed inset-0 z-30" onClick={() => setShowBacklinks(false)}> + <div className="absolute inset-0 bg-black/30" /> + <div className="absolute right-0 top-0 bottom-0 w-64 max-w-[80vw] bg-gray-50 border-l border-gray-200 shadow-xl overflow-auto" onClick={(e) => e.stopPropagation()}> + <div className="px-3 h-9 flex items-center border-b border-gray-200"> + <span className="text-[11px] text-gray-500">Backlinks</span> + <span className="ml-auto text-[11px] text-gray-500">{backlinks.length}</span> + <button + onClick={() => setShowBacklinks(false)} + className="ml-2 text-gray-400 hover:text-gray-700 px-1" + title="close backlinks" + > + ✕ + </button> + </div> + {backlinks.length === 0 ? ( + <div className="px-3 py-4 text-xs text-gray-500">No documents link here yet.</div> + ) : ( + <ul className="py-2"> + {backlinks.map((b) => ( + <li key={b.path}> + <button + type="button" + onClick={() => { + onSelect(b.path) + setShowBacklinks(false) + }} + className="w-full px-3 py-2 text-left hover:bg-gray-100" + > + <div className="text-xs font-medium text-gray-900 truncate">{b.path}</div> + <div className="text-[11px] text-gray-500 mt-0.5 leading-snug line-clamp-2">{b.preview}</div> + </button> + </li> + ))} + </ul> + )} + </div> + </div> + )} + </div> + )} + </> + ) +} + +function NewFileDialog({ + vault, + onClose, + onCreate, +}: { + vault: VaultId + onClose: () => void + onCreate: (path: string) => void +}) { + const [path, setPath] = useState("") + return ( + <div className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center" onClick={onClose}> + <div + className="w-full max-w-[420px] mx-4 bg-white rounded-md shadow-xl border border-gray-200 p-4 md:p-5" + onClick={(e) => e.stopPropagation()} + > + <div className="text-base font-semibold text-gray-900 mb-3">new file in {vault}/</div> + <input + autoFocus + type="text" + value={path} + onChange={(e) => setPath(e.target.value)} + placeholder="loopat/new-doc.md" + className="w-full px-3 py-2 text-sm border border-gray-300 rounded outline-none focus:border-gray-500 font-mono" + /> + <div className="text-[11px] text-gray-400 mt-1">relative path. directories created as needed.</div> + <div className="flex justify-end gap-2 mt-4"> + <button onClick={onClose} className="px-3 h-8 text-sm rounded text-gray-700 hover:bg-gray-100"> + cancel + </button> + <button + onClick={() => path.trim() && onCreate(path.trim())} + disabled={!path.trim()} + className="px-3 h-8 text-sm rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50" + > + create + </button> + </div> + </div> + </div> + ) +} + +// ============================================================================ +// ReposPane: the repo roster — PERSONAL, in personal/<user>/.loopat/config.json. +// Repos are cloned on demand at loop creation; this page just edits the roster. +// Saving writes straight to the user's own config.json. +// ============================================================================ + +function ReposPane() { + const [roster, setRoster] = useState<ContextRepoRoster | null>(null) + const [repos, setRepos] = useState<ContextRepoSpec[]>([]) + const [saving, setSaving] = useState(false) + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null) + + const load = useCallback(async () => { + const r = await getContextRepos() + setRoster(r) + setRepos(r.repos) + }, []) + useEffect(() => { load() }, [load]) + + const dirty = + roster !== null && JSON.stringify(repos) !== JSON.stringify(roster.repos) + + const addRow = () => setRepos((rs) => [...rs, { name: "", git: "" }]) + const setRow = (i: number, k: "name" | "git", v: string) => + setRepos((rs) => rs.map((r, idx) => (idx === i ? { ...r, [k]: v } : r))) + const delRow = (i: number) => setRepos((rs) => rs.filter((_, idx) => idx !== i)) + + const save = async () => { + setSaving(true) + setMsg(null) + const cleaned = repos + .map((r) => ({ name: r.name.trim(), git: r.git.trim() })) + .filter((r) => r.name && r.git) + const r = await putContextRepos({ repos: cleaned }) + setSaving(false) + if (r.ok) { + setMsg({ ok: true, text: "Saved." }) + await load() + } else { + setMsg({ ok: false, text: "Save failed: " + (r.error ?? "") }) + } + } + + if (!roster) return <div className="p-6 text-sm text-gray-400">loading…</div> + + return ( + <article className="flex-1 min-w-0 overflow-auto p-5"> + <div className="max-w-2xl flex flex-col gap-5"> + <div> + <h2 className="text-sm font-semibold text-gray-900">Repo roster</h2> + <p className="text-[12px] text-gray-500 leading-relaxed mt-1"> + Personal — saved straight to your own{" "} + <code className="bg-gray-100 px-1 rounded text-[11px]">.loopat/config.json</code>. Repos are + cloned on demand when a loop selects one. + </p> + </div> + + <div className="flex flex-col gap-2"> + <span className="text-[12px] font-medium text-gray-700">repos</span> + {repos.length === 0 && <div className="text-[12px] text-gray-400">No repos yet.</div>} + {repos.map((r, i) => ( + <div key={i} className="flex items-center gap-2"> + <input + value={r.name} + onChange={(e) => setRow(i, "name", e.target.value)} + placeholder="name" + className="w-40 px-2 py-1.5 text-sm border border-gray-300 rounded outline-none focus:border-gray-500" + /> + <input + value={r.git} + onChange={(e) => setRow(i, "git", e.target.value)} + placeholder="git@…/repo.git" + className="flex-1 px-2 py-1.5 text-sm font-mono border border-gray-300 rounded outline-none focus:border-gray-500" + /> + <button + type="button" + onClick={() => delRow(i)} + className="px-2 py-1.5 text-gray-400 hover:text-red-600" + title="remove" + > + ✕ + </button> + </div> + ))} + <button + type="button" + onClick={addRow} + className="self-start text-[12px] text-gray-600 hover:text-gray-900 border border-gray-200 rounded px-2 py-1" + > + + add repo + </button> + </div> + + {msg && ( + <div + className={`text-[12px] rounded px-2 py-1.5 border ${ + msg.ok + ? "text-emerald-700 bg-emerald-50 border-emerald-200" + : "text-red-700 bg-red-50 border-red-200" + }`} + > + {msg.text} + </div> + )} + + <div className="flex items-center gap-3"> + <button + type="button" + onClick={save} + disabled={!dirty || saving} + className="px-3 h-9 text-sm rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-40" + > + {saving ? "Saving…" : "Save"} + </button> + {dirty && <span className="text-[11px] text-amber-600">unsaved changes</span>} + </div> + </div> + </article> + ) +} diff --git a/web/src/pages/KanbanPage.tsx b/web/src/pages/KanbanPage.tsx new file mode 100644 index 00000000..3e95475a --- /dev/null +++ b/web/src/pages/KanbanPage.tsx @@ -0,0 +1,231 @@ +import { useState, useCallback, useRef, useEffect } from "react" +import { useNavigate, useParams } from "react-router-dom" +import { KanbanBoard, type KanbanBoardHandle } from "../components/kanban/KanbanBoard" +import { CardDetailDialog } from "../components/kanban/CardDetailDialog" +import { moveKanbanCard, createKanbanColumn, listBoards, createBoard, renameBoard, saveNotes, notesBehind, refreshNotes, type KanbanCard } from "../api" + +type UndoState = { cid: string; card: KanbanCard; fromFile: string; toFile: string } | null +const ARCHIVE_FILE = "archived.md" + +export function KanbanPage() { + const { board = "default" } = useParams<{ board: string }>() + const navigate = useNavigate() + const [selected, setSelected] = useState<{ card: KanbanCard; filename: string } | null>(null) + const [undo, setUndo] = useState<UndoState>(null) + const [showArchived, setShowArchived] = useState(false) + const [boards, setBoards] = useState<string[]>([]) + const [showNewBoard, setShowNewBoard] = useState(false) + const [newBoardName, setNewBoardName] = useState("") + const [renamingBoard, setRenamingBoard] = useState("") + const [renameValue, setRenameValue] = useState("") + const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null) + const boardRef = useRef<KanbanBoardHandle>(null) + + const refresh = useCallback(() => boardRef.current?.refresh(), []) + + // notes/kanban share the user's notes worktree. Poll how far behind origin we + // are every 5s (a hint), pull on Refresh, push on Save. + const [behind, setBehind] = useState(0) + const [syncing, setSyncing] = useState(false) + useEffect(() => { + const tick = () => notesBehind().then(setBehind).catch(() => {}) + tick() + const id = setInterval(tick, 5000) + return () => clearInterval(id) + }, []) + const doRefresh = useCallback(async () => { + setSyncing(true) + await refreshNotes().catch(() => {}) + setSyncing(false) + setBehind(0) + boardRef.current?.refresh() + }, []) + const doSave = useCallback(async () => { + setSyncing(true) + const r = await saveNotes() + setSyncing(false) + if (!r.ok) { + if (r.conflict) alert(`保存了本地,但和远端冲突 (${(r.files ?? []).join(", ")})。本地保留——去 Notes 里 take remote 或手动解决。`) + else alert(`推送到远端失败:${r.error ?? "unknown"}`) + } + }, []) + + useEffect(() => { + listBoards().then(setBoards) + }, [board]) + + function clearUndo() { setUndo(null); if (undoTimer.current) clearTimeout(undoTimer.current) } + function setUndoWithTimeout(s: UndoState) { clearUndo(); setUndo(s); undoTimer.current = setTimeout(clearUndo, 10000) } + useEffect(() => { return () => { if (undoTimer.current) clearTimeout(undoTimer.current) } }, []) + + async function handleArchive(card: KanbanCard, colFilename: string) { + // ensure archived column exists + await createKanbanColumn(board, "archived") + const ok = await moveKanbanCard(board, colFilename, card.cid, ARCHIVE_FILE) + if (ok) { setUndoWithTimeout({ cid: card.cid, card, fromFile: colFilename, toFile: ARCHIVE_FILE }); refresh() } + } + + async function handleUndo() { + if (!undo) return + await moveKanbanCard(board, undo.toFile, undo.cid, undo.fromFile) + clearUndo(); refresh() + } + + async function handleCreateBoard() { + const name = newBoardName.trim() + if (!name) return + const ok = await createBoard(name) + if (ok) { + setShowNewBoard(false) + setNewBoardName("") + setBoards(await listBoards()) + navigate(`/kanban/${encodeURIComponent(name)}`) + } + } + + async function handleRename(oldName: string) { + const name = renameValue.trim() + if (!name || name === oldName) { setRenamingBoard(""); return } + const ok = await renameBoard(oldName, name) + if (ok) { + setRenamingBoard("") + setRenameValue("") + const updated = await listBoards() + setBoards(updated) + navigate(`/kanban/${encodeURIComponent(name)}`) + } + } + + return ( + <div className="flex flex-col h-full w-full bg-white"> + {/* Board tabs */} + <div className="h-9 shrink-0 flex items-center gap-1 px-3 border-b border-gray-200 overflow-x-auto"> + {boards.map((b) => ( + <div key={b} className="relative group"> + <button + onClick={() => navigate(`/kanban/${encodeURIComponent(b)}`)} + className={`shrink-0 h-7 px-3 rounded text-[12px] transition-colors whitespace-nowrap ${ + b === board + ? "bg-gray-900 text-white" + : "text-gray-500 hover:text-gray-900 hover:bg-gray-100" + }`} + > + {b} + </button> + {b === board && ( + <button + onClick={(e) => { e.stopPropagation(); setRenamingBoard(b); setRenameValue(b) }} + className="absolute -top-0.5 -right-1 w-4 h-4 rounded-full bg-white border border-gray-300 text-[9px] text-gray-400 hover:text-gray-700 hidden group-hover:flex items-center justify-center shadow-sm" + title="Rename board" + >✎</button> + )} + </div> + ))} + {showNewBoard ? ( + <span className="inline-flex items-center gap-1 ml-1 shrink-0"> + <input + type="text" + value={newBoardName} + onChange={(e) => setNewBoardName(e.target.value)} + autoFocus + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) handleCreateBoard(); if (e.key === "Escape") { setShowNewBoard(false); setNewBoardName("") } }} + onBlur={() => { if (!newBoardName.trim()) { setShowNewBoard(false); setNewBoardName("") } }} + className="w-24 h-7 px-2 text-[12px] border border-gray-300 rounded outline-none focus:border-gray-500" + placeholder="board name" + /> + </span> + ) : ( + <button + onClick={() => setShowNewBoard(true)} + className="shrink-0 h-7 px-2 rounded text-[12px] text-gray-400 hover:text-gray-600 hover:bg-gray-100 ml-1" + title="New board" + >+</button> + )} + <div className="flex-1" /> + <button onClick={doSave} disabled={syncing} + className="shrink-0 text-[11px] px-2 py-0.5 rounded bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-50 transition-colors" + title="保存并推送到远端"> + {syncing ? "…" : "保存"} + </button> + {behind > 0 && ( + <span className="shrink-0 text-[11px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-800" title="远端有更新,点 ↻ 拉取"> + 远端 +{behind} + </span> + )} + <button onClick={doRefresh} disabled={syncing} + className="shrink-0 text-[11px] px-2 py-0.5 rounded border border-gray-200 text-gray-500 hover:text-gray-700 transition-colors disabled:opacity-50" + title="拉取远端最新并刷新"> + ↻ + </button> + <button onClick={() => setShowArchived((v) => !v)} + className={`text-[11px] px-2 py-0.5 rounded border transition-colors shrink-0 ${showArchived ? "border-gray-400 bg-gray-200 text-gray-700" : "border-gray-200 text-gray-500 hover:text-gray-700"}`}> + {showArchived ? "Hide archived" : "Archived"} + </button> + <button + onClick={() => navigate(`/context/notes?file=focus/boards/${encodeURIComponent(board)}`)} + className="shrink-0 text-[11px] text-gray-500 hover:text-gray-900 ml-2" + title="edit files in notes/focus/" + > + <code className="text-gray-700">notes/focus/boards/{board}/</code> + <span> ↗</span> + </button> + </div> + + {/* Rename board dialog */} + {renamingBoard && ( + <div className="fixed inset-0 z-50 bg-black/30 flex items-center justify-center" onClick={() => setRenamingBoard("")}> + <div className="bg-white rounded-md shadow-xl border border-gray-200 w-full max-w-xs mx-4" onClick={(e) => e.stopPropagation()}> + <div className="px-4 py-3 border-b border-gray-100"> + <span className="text-sm font-medium text-gray-900">Rename board</span> + </div> + <div className="px-4 py-3 space-y-3"> + <input + type="text" + value={renameValue} + onChange={(e) => setRenameValue(e.target.value)} + autoFocus + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) handleRename(renamingBoard); if (e.key === "Escape") setRenamingBoard("") }} + className="w-full text-[13px] border border-gray-300 rounded px-2 py-1.5 outline-none focus:border-gray-500" + /> + <div className="flex items-center gap-2"> + <button onClick={() => handleRename(renamingBoard)} disabled={!renameValue.trim()} + className="px-2.5 h-7 rounded text-[11px] bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-40">Rename</button> + <button onClick={() => setRenamingBoard("")} + className="px-2.5 h-7 rounded text-[11px] text-gray-500 hover:bg-gray-200">Cancel</button> + </div> + </div> + </div> + </div> + )} + + <main className="flex-1 min-h-0 overflow-hidden relative" key={board}> + <KanbanBoard + ref={boardRef} + board={board} + onCardClick={(card, filename) => setSelected({ card, filename })} + onCardArchive={handleArchive} + showArchived={showArchived} + /> + </main> + + {selected && ( + <CardDetailDialog + board={board} + card={selected.card} + colFilename={selected.filename} + onClose={() => setSelected(null)} + onSaved={refresh} + onDeleted={() => { setSelected(null); refresh() }} + /> + )} + + {undo && ( + <div className="fixed bottom-4 left-4 z-50 bg-white border border-gray-300 rounded-lg shadow-lg px-4 py-2.5 flex items-center gap-3"> + <span className="text-[12px] text-gray-600">Card archived</span> + <button onClick={handleUndo} className="text-[12px] text-blue-600 hover:text-blue-800 font-medium">Undo</button> + <button onClick={clearUndo} className="text-gray-400 hover:text-gray-600 text-sm">✕</button> + </div> + )} + </div> + ) +} diff --git a/web/src/pages/LoopPage.tsx b/web/src/pages/LoopPage.tsx new file mode 100644 index 00000000..3536c513 --- /dev/null +++ b/web/src/pages/LoopPage.tsx @@ -0,0 +1,1239 @@ +/** + * Loop tab — AI chat with Claude Code-like experience. + * Chat area uses assistant-ui runtime with custom claudecodeui-styled components. + */ +import { useEffect, useState, useMemo, useRef, Fragment } from "react" +import { createPortal } from "react-dom" +import { useParams, useNavigate, Navigate, useLocation } from "react-router-dom" +import { AssistantRuntimeProvider } from "@assistant-ui/react" +import { PanelLeftClose, PanelLeftOpen, Archive, ArchiveRestore, GitBranch, Globe, Lock, Copy, Check, ChevronDown, Hand, FlaskConical, Maximize2, Minimize2 } from "lucide-react" +import { Panel, Group, Separator } from "react-resizable-panels" +import ChatInterface from "@/components/chat/ChatInterface" +import { useWorkspace } from "../ctx" +import { useLoopRuntime, LoopRuntimeProvider } from "../useLoopRuntime" +import { getContext, distillLoop, listProfiles, type ContextMount, type LoopMeta, markLoopViewed, getServeConfig, type ServeConfig } from "../api" +import { SharePage } from "./SharePage" +import { useIsMobile } from "../lib/useIsMobile" +import { useLoopStatus } from "../useLoopStatus" +import { FileTree } from "../FileTree" +import { GitDiffSidebar } from "../components/GitDiffSidebar" +import { ShareArtifactDialog } from "../components/ShareArtifactDialog" +import { lazy, Suspense } from "react" +const Editor = lazy(() => import("../Editor").then(m => ({ default: m.Editor }))) +const Terminal = lazy(() => import("../Terminal").then(m => ({ default: m.Terminal }))) + +type RightMode = "info" | "workdir" | "editor" | "terminal" | "git" + +export function LoopPage() { + const { id } = useParams<{ id: string }>() + const ws = useWorkspace() + + if (!id) return <Navigate to={`/loop`} replace /> + + // Anonymous on /loop/:id → read-only share view. Server gates by meta.public. + // ws.loops is empty for anonymous visitors (the list endpoint requires auth), + // so we don't even look in it; the share view fetches meta on its own. + if (!ws.currentUser) { + return <SharePage /> + } + + const meta = ws.loops.find((l) => l.id === id) + // Wait for loops to finish loading before redirecting — prevents a race + // where ws.loops is still the initial empty array, causing an incorrect + // redirect to /loop → first loop. + if (ws.loopsLoading) return null + if (!meta) { + // Loop not in current filtered list. Most common cause: user just archived + // it (and showArchived is off). Fall back to LoopRedirect, which jumps to + // the first available loop or shows the empty state. + return <Navigate to="/loop" replace /> + } + + return ( + <div className="h-full w-full flex min-h-0"> + <LoopsList currentId={id} /> + <LoopMain key={id} meta={meta} /> + </div> + ) +} + +// ============================================================================ +// Loops list (col 1) — ported from phase1 LoopsList +// ============================================================================ + +function LoopsList({ currentId }: { currentId: string }) { + const ws = useWorkspace() + const navigate = useNavigate() + const [scope, setScope] = useState<"mine" | "all" | "rfd">("mine") + const [search, setSearch] = useState("") + const [collapsed, setCollapsed] = useState(() => localStorage.getItem("loopat:loopsList:collapsed") === "1") + const isMobile = useIsMobile() + const loopIds = useMemo(() => ws.loops.map(l => l.id), [ws.loops]) + const statusMap = useLoopStatus(loopIds) + + // Auto-mark the current loop as viewed when its status transitions to Done + // while the user is already on that page (prevents stale yellow dot). + useEffect(() => { + const entry = statusMap[currentId] + if (entry?.status === "Done" && entry?.viewed === false) { + markLoopViewed(currentId) + } + }, [statusMap, currentId]) + + const userId = ws.currentUser?.id + const filtered = useMemo(() => { + const q = search.toLowerCase().trim() + return ws.loops.filter((loop) => { + const effective = loop.driver ?? loop.createdBy + if (scope === "mine" && loop.createdBy !== userId && effective !== userId) return false + if (scope === "rfd" && !loop.rfdRequestedAt) return false + if (q && !loop.title.toLowerCase().includes(q) && !loop.id.toLowerCase().includes(q)) return false + return true + }) + }, [ws.loops, scope, userId, search]) + + const setCollapsedPersist = (v: boolean) => { + setCollapsed(v) + localStorage.setItem("loopat:loopsList:collapsed", v ? "1" : "0") + } + + const sidebarContent = ( + <aside className="w-60 shrink-0 border-r border-gray-200 bg-white flex flex-col h-full"> + <div className="px-2 pt-1.5 pb-1 space-y-1 border-b border-gray-200"> + <div className="flex items-center gap-1"> + {(["mine", "all", "rfd"] as const).map((s) => ( + <button + key={s} + type="button" + onClick={() => setScope(s)} + className={ + scope === s + ? s === "rfd" + ? "px-2 h-6 rounded text-[11px] flex items-center gap-1 bg-amber-600 text-white" + : "px-2 h-6 rounded text-[11px] bg-gray-900 text-white" + : s === "rfd" + ? "px-2 h-6 rounded text-[11px] flex items-center gap-1 text-amber-700 hover:bg-amber-50" + : "px-2 h-6 rounded text-[11px] text-gray-500 hover:bg-gray-100" + } + > + {s === "mine" ? "mine" : s === "all" ? "all" : "RFD"} + </button> + ))} + <span className="text-[11px] text-gray-400 ml-auto pr-1">{filtered.length}</span> + <button + type="button" + onClick={() => ws.setShowArchived(!ws.showArchived)} + className={ + ws.showArchived + ? "w-6 h-6 flex items-center justify-center text-gray-700 bg-gray-100 rounded" + : "w-6 h-6 flex items-center justify-center text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded" + } + title={ws.showArchived ? "hide archived" : "show archived"} + > + <Archive size={13} /> + </button> + <button + type="button" + onClick={() => setCollapsedPersist(true)} + className="w-6 h-6 flex items-center justify-center text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded" + title="collapse sidebar" + > + <PanelLeftClose size={14} /> + </button> + </div> + <input + type="text" + name="loop-search" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="search loops…" + className="w-full h-6 rounded px-1.5 text-[11px] bg-gray-100 border border-transparent focus:border-gray-300 focus:bg-white focus:outline-none text-gray-600 placeholder-gray-400" + /> + </div> + <div className="flex-1 min-h-0 overflow-auto py-2"> + {filtered.map((loop) => { + const sel = currentId === loop.id + const archived = loop.archived === true + // Server rejects PATCH from non-owners with 403 — surface that + // upfront so users don't think the button is broken. + const isOwner = ws.currentUser?.id === loop.createdBy + const entry = statusMap[loop.id] + const isDone = entry?.status === "Done" || entry?.status === "Ready" + const isRunning = entry !== undefined && !isDone + return ( + <div + key={loop.id} + className={ + "group/row relative flex items-stretch " + + (sel ? "bg-gray-100" : "hover:bg-gray-50") + } + > + <button + type="button" + onClick={() => { + markLoopViewed(loop.id) + navigate(`/loop/${loop.id}`) + if (isMobile) setCollapsedPersist(true) + }} + className={ + "flex-1 min-w-0 px-3 py-1.5 flex items-center gap-2 text-left " + + (archived ? "opacity-60" : "") + } + > + <span className={ + "w-1.5 h-1.5 rounded-full shrink-0 mt-0.5 " + + (archived ? "bg-gray-400" : isRunning ? "bg-blue-500 animate-pulse" : isDone && !entry?.viewed ? "bg-yellow-500" : isDone ? "bg-emerald-500" : "bg-gray-300") + } /> + <div className="flex-1 min-w-0"> + <div className="text-[13px] text-gray-900 truncate flex items-center gap-1.5"> + {archived && <Archive size={10} className="text-gray-400 shrink-0" />} + {loop.rfdRequestedAt && ( + <span className="shrink-0 text-[9px] px-1 rounded bg-amber-100 text-amber-800 font-medium tracking-wide">RFD</span> + )} + <span className="truncate">{loop.title}</span> + {entry && ( + <span className={"shrink-0 text-[10px] font-medium " + + (isRunning ? "text-blue-500" : isDone ? "text-emerald-500" : "text-gray-400")}> + {entry.status} + </span> + )} + </div> + <div className="text-[11px] text-gray-500 truncate flex items-center gap-1 mt-0.5"> + <span>{loop.driver ?? loop.createdBy}</span> + <span className="text-gray-300">·</span> + <span className="font-mono text-[10px] text-gray-400">{loop.id.slice(0, 6)}</span> + </div> + </div> + </button> + <button + type="button" + disabled={!isOwner} + onClick={(e) => { + e.stopPropagation() + if (isOwner) ws.setLoopArchived(loop.id, !archived) + }} + className={ + (isMobile ? "opacity-100" : "opacity-0 group-hover/row:opacity-100") + " transition-opacity w-7 flex items-center justify-center " + + (isOwner + ? "text-gray-400 hover:text-gray-700" + : "text-gray-300 cursor-not-allowed") + } + title={ + isOwner + ? (archived ? "unarchive" : "archive (hide + read-only)") + : `only ${loop.createdBy} can ${archived ? "unarchive" : "archive"} this loop` + } + > + {archived ? <ArchiveRestore size={13} /> : <Archive size={13} />} + </button> + </div> + ) + })} + {filtered.length === 0 && ( + <div className="px-3 py-4 text-[12px] text-gray-400 italic">no loops · click "+ New Loop"</div> + )} + </div> + </aside> + ) + + if (collapsed) { + return ( + <aside className="w-9 shrink-0 border-r border-gray-200 bg-white flex flex-col items-center pt-2"> + <button + type="button" + onClick={() => setCollapsedPersist(false)} + className="w-7 h-7 flex items-center justify-center text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded" + title="expand sidebar" + > + <PanelLeftOpen size={16} /> + </button> + </aside> + ) + } + + // On mobile, render expanded sidebar as a fixed overlay + if (isMobile) { + return ( + <> + <aside className="w-9 shrink-0 border-r border-gray-200 bg-white flex flex-col items-center pt-2"> + <button + type="button" + onClick={() => setCollapsedPersist(true)} + className="w-7 h-7 flex items-center justify-center text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded" + title="collapse sidebar" + > + <PanelLeftClose size={16} /> + </button> + </aside> + <div className="fixed inset-0 z-40" onClick={() => setCollapsedPersist(true)}> + <div className="absolute inset-0 bg-black/30" /> + <div className="absolute left-0 top-0 bottom-0 w-64 max-w-[80vw] shadow-xl" onClick={(e) => e.stopPropagation()}> + {sidebarContent} + </div> + </div> + </> + ) + } + + return sidebarContent +} + +// ============================================================================ +// Loop main (chat + header + right panel) +// ============================================================================ + +function LoopMain({ meta }: { meta: LoopMeta }) { + const ws = useWorkspace() + const isMobile = useIsMobile() + const [openPanels, setOpenPanels] = useState<RightMode[]>([]) + const [fullscreenPanel, setFullscreenPanel] = useState<RightMode | null>(null) + const [pickedFile, setPickedFile] = useState<string | null>(null) + const [mounts, setMounts] = useState<ContextMount[]>([]) + // sandboxInfo + refresh-sandbox UI removed — profile model re-composes every spawn, + // so there's nothing to "refresh" mid-loop. + const [shareOpen, setShareOpen] = useState(false) + const [serveCfg, setServeCfg] = useState<ServeConfig | null>(null) + useEffect(() => { getServeConfig().then(setServeCfg) }, []) + const [editorSelection, setEditorSelection] = useState<{ from: number; to: number } | null>(null) + const openFile = (path: string) => { + setPickedFile(path) + setEditorSelection(null) + setOpenPanels((prev) => prev.includes("editor") ? prev : [...prev, "editor"]) + } + const { runtime, connected, reconnecting, running, viewers, extra, queue, onClearQueue } = useLoopRuntime(meta.id, ws.currentUser?.id ?? "", openFile) + + // Sandbox-prep gate: on first use, the per-loop image builds (mise toolchain + // install / base-image pull). While it runs, terminal + chat aren't usable — + // block them behind an overlay so the user doesn't type into a shell that + // isn't there or fire a chat turn that just queues. Driven by the loop-status + // `phase` the server sets around ensureContainer. + const statusIds = useMemo(() => [meta.id], [meta.id]) + const loopStatus = useLoopStatus(statusIds) + const preparing = loopStatus[meta.id]?.phase === "preparing" + const prepDetail = loopStatus[meta.id]?.status + useEffect(() => { + getContext(meta.id).then(setMounts) + markLoopViewed(meta.id) + if (ws.currentUser?.id) { + localStorage.setItem(`loopat:lastLoop:${ws.currentUser.id}`, meta.id) + } + }, [meta.id]) + + // Kickoff message: when navigated here with router state { kickoff: "..." }, + // auto-send once the WS is connected. Used by the Welcome card to fire the + // onboarding flow (sends "/loopat:onboarding"). Clear state to prevent re- + // sending on refresh/back-nav. + const location = useLocation() + const navigate = useNavigate() + const kickoff = (location.state as { kickoff?: string } | null)?.kickoff + useEffect(() => { + if (!kickoff || !connected) return + extra.enqueueMessage(kickoff) + navigate(location.pathname, { replace: true, state: null }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [kickoff, connected]) + + const toggleMode = (m: RightMode) => { + setOpenPanels((prev) => { + if (prev.includes(m)) return prev.filter((p) => p !== m) + return [...prev, m] + }) + } + + // Mobile quick-access buttons: exclusive — opening one closes others. + const exclusiveToggleMode = (m: RightMode) => { + setOpenPanels((prev) => { + if (prev.includes(m)) return prev.filter((p) => p !== m) + return [m] + }) + } + + const closePanel = (m: RightMode) => { + setOpenPanels((prev) => prev.filter((p) => p !== m)) + setFullscreenPanel((prev) => prev === m ? null : prev) + } + + const hasPanels = openPanels.length > 0 + const editorPanels = openPanels.filter((m) => m === "editor" || m === "terminal") + const otherPanels = openPanels.filter((m) => m !== "editor" && m !== "terminal") + const hasEditorCol = editorPanels.length > 0 + const hasOtherCol = otherPanels.length > 0 + + // defaultSize values — used by the library for proportional distribution + // when panels first mount. Persisted from onLayoutChange after user drags. + const [chatSize, setChatSize] = useState(() => { + const n = parseInt(localStorage.getItem("loopat:chatPct") || "", 10) + return (!isNaN(n) && n >= 10 && n <= 90) ? n : 55 + }) + const [otherSize, setOtherSize] = useState(() => { + const n = parseInt(localStorage.getItem("loopat:otherPct") || "", 10) + return (!isNaN(n) && n >= 5 && n <= 50) ? n : 18 + }) + // Read persisted sizes back when panel closes so state stays in sync. + useEffect(() => { + if (!hasOtherCol) { + const n = parseInt(localStorage.getItem("loopat:otherPct") || "", 10) + if (!isNaN(n) && n >= 5 && n <= 50) setOtherSize(n) + } + if (!hasEditorCol && !hasOtherCol) { + const n = parseInt(localStorage.getItem("loopat:chatPct") || "", 10) + if (!isNaN(n) && n >= 10 && n <= 90) setChatSize(n) + } + }, [hasEditorCol, hasOtherCol]) + + const persistLayout = (layout: Record<string, number>) => { + // Write to localStorage only — no setState during drag to avoid re-render + // breaking the gesture. State is synced back via useEffect on panel close. + if (!isNaN(layout.chat) && layout.chat > 0) { + localStorage.setItem("loopat:chatPct", String(Math.round(layout.chat))) + } + if (!isNaN(layout.otherCol) && layout.otherCol > 0) { + localStorage.setItem("loopat:otherPct", String(Math.round(layout.otherCol))) + } + } + const renderPanel = (mode: RightMode) => { + const isFullscreen = fullscreenPanel === mode + return ( + <RightPanel + key={mode} + loopId={meta.id} + meta={meta} + mode={mode} + onClose={() => closePanel(mode)} + pickedFile={pickedFile} + onPickFile={openFile} + currentUserId={ws.currentUser?.id ?? ""} + isFullscreen={isFullscreen} + onToggleFullscreen={() => setFullscreenPanel(isFullscreen ? null : mode)} + onEditorSelection={setEditorSelection} + /> + ) + } + + return ( + <div className="flex-1 min-w-0 min-h-0 flex flex-col"> + <LoopHeader + meta={meta} + mounts={mounts} + connected={connected} + reconnecting={reconnecting} + running={running} + viewers={viewers} + queue={queue} + onClearQueue={onClearQueue} + openPanels={openPanels} + toggleMode={toggleMode} + onShareWork={() => setShareOpen(true)} + showShareButton={!serveCfg || serveCfg.serveEnabled || serveCfg.serveDynamicEnabled || serveCfg.serveEphemeralEnabled} + /> + {meta.contextWarnings && meta.contextWarnings.length > 0 && ( + <div className="shrink-0 border-b border-amber-200 bg-amber-50 px-4 py-2 text-[12px] text-amber-800 leading-relaxed"> + <span className="font-semibold">⚠ Context not fully loaded.</span>{" "} + This loop's knowledge/notes couldn't be cloned — it runs with an empty context until the access is fixed (check the key has access to the repo): + <ul className="mt-1 ml-4 list-disc"> + {meta.contextWarnings.map((w, i) => ( + <li key={i} className="font-mono text-[11px] break-all">{w}</li> + ))} + </ul> + </div> + )} + <div className="relative flex-1 min-h-0 flex flex-col"> + {preparing && <PreparingOverlay detail={prepDetail} />} + {isMobile ? ( + <div className="flex-1 min-h-0"> + <LoopRuntimeProvider extra={extra}> + <AssistantRuntimeProvider runtime={runtime}> + <ChatInterface + archived={meta.archived === true} + onUnarchive={() => ws.setLoopArchived(meta.id, false)} + repo={meta.repo} + branch={meta.branch} + title={meta.title} + driver={meta.driver ?? meta.createdBy} + driverHistory={meta.driverHistory} + rfdRequestedAt={meta.rfdRequestedAt} + rfdRequestedBy={meta.rfdRequestedBy} + onTakeDrive={() => ws.takeDrive(meta.id)} + pickedFile={pickedFile} + editorSelection={editorSelection} + /> + </AssistantRuntimeProvider> + </LoopRuntimeProvider> + </div> + ) : hasPanels ? ( + <Group orientation="horizontal" className="flex-1 min-w-0 min-h-0" + onLayoutChange={persistLayout} + > + <Panel id="chat" minSize={20} defaultSize={chatSize} + className="flex flex-col min-h-0 min-w-0" + > + <LoopRuntimeProvider extra={extra}> + <AssistantRuntimeProvider runtime={runtime}> + <ChatInterface + archived={meta.archived === true} + onUnarchive={() => ws.setLoopArchived(meta.id, false)} + repo={meta.repo} + branch={meta.branch} + title={meta.title} + driver={meta.driver ?? meta.createdBy} + driverHistory={meta.driverHistory} + rfdRequestedAt={meta.rfdRequestedAt} + rfdRequestedBy={meta.rfdRequestedBy} + onTakeDrive={() => ws.takeDrive(meta.id)} + pickedFile={pickedFile} + editorSelection={editorSelection} + /> + </AssistantRuntimeProvider> + </LoopRuntimeProvider> + </Panel> + + {(hasEditorCol || hasOtherCol) && <Separator className="relative w-4 -mx-1.5 cursor-col-resize group flex items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-gray-200 after:transition-colors hover:after:bg-blue-400"> + <div className="absolute top-1/2 -translate-y-1/2 h-8 w-1.5 rounded-full bg-gray-300 group-hover:bg-blue-400 transition-colors" /> + </Separator>} + + {hasEditorCol && ( + <Panel id="editorCol" minSize={15} defaultSize={30} className="flex flex-col min-h-0 min-w-0"> + {editorPanels.length > 1 ? ( + <Group orientation="vertical" className="flex-1 min-h-0"> + {editorPanels.map((mode, i) => ( + <Fragment key={mode}> + {i > 0 && <Separator className="relative h-4 -my-1.5 cursor-row-resize group flex items-center justify-center after:absolute after:left-0 after:right-0 after:top-1/2 after:h-px after:-translate-y-1/2 after:bg-gray-200 after:transition-colors hover:after:bg-blue-400"> + <div className="absolute left-1/2 -translate-x-1/2 w-8 h-1.5 rounded-full bg-gray-300 group-hover:bg-blue-400 transition-colors" /> + </Separator>} + <Panel id={mode} minSize={10} defaultSize={100 / editorPanels.length} className="flex flex-col min-h-0 min-w-0"> + {renderPanel(mode)} + </Panel> + </Fragment> + ))} + </Group> + ) : ( + renderPanel(editorPanels[0]) + )} + </Panel> + )} + + {(hasEditorCol && hasOtherCol) && <Separator className="relative w-4 -mx-1.5 cursor-col-resize group flex items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-gray-200 after:transition-colors hover:after:bg-blue-400"> + <div className="absolute top-1/2 -translate-y-1/2 h-8 w-1.5 rounded-full bg-gray-300 group-hover:bg-blue-400 transition-colors" /> + </Separator>} + + {hasOtherCol && ( + <Panel id="otherCol" minSize={15} defaultSize={otherSize} + className="flex flex-col min-h-0 min-w-0" + > + {otherPanels.length > 1 ? ( + <Group orientation="vertical" className="flex-1 min-h-0"> + {otherPanels.map((mode, i) => ( + <Fragment key={mode}> + {i > 0 && <Separator className="relative h-4 -my-1.5 cursor-row-resize group flex items-center justify-center after:absolute after:left-0 after:right-0 after:top-1/2 after:h-px after:-translate-y-1/2 after:bg-gray-200 after:transition-colors hover:after:bg-blue-400"> + <div className="absolute left-1/2 -translate-x-1/2 w-8 h-1.5 rounded-full bg-gray-300 group-hover:bg-blue-400 transition-colors" /> + </Separator>} + <Panel id={mode} minSize={10} defaultSize={100 / otherPanels.length} className="flex flex-col min-h-0 min-w-0"> + {renderPanel(mode)} + </Panel> + </Fragment> + ))} + </Group> + ) : ( + renderPanel(otherPanels[0]) + )} + </Panel> + )} + </Group> + ) : ( + <div className="flex-1 min-h-0"> + <LoopRuntimeProvider extra={extra}> + <AssistantRuntimeProvider runtime={runtime}> + <ChatInterface + archived={meta.archived === true} + onUnarchive={() => ws.setLoopArchived(meta.id, false)} + repo={meta.repo} + branch={meta.branch} + title={meta.title} + driver={meta.driver ?? meta.createdBy} + driverHistory={meta.driverHistory} + rfdRequestedAt={meta.rfdRequestedAt} + rfdRequestedBy={meta.rfdRequestedBy} + onTakeDrive={() => ws.takeDrive(meta.id)} + pickedFile={pickedFile} + editorSelection={editorSelection} + /> + </AssistantRuntimeProvider> + </LoopRuntimeProvider> + </div> + )} + </div> + {hasPanels && isMobile && openPanels.map((mode) => ( + <Fragment key={mode}> + {renderPanel(mode)} + </Fragment> + ))} + {isMobile && ( + <div className="fixed bottom-0 left-0 z-30 w-9 flex flex-col items-center gap-0.5 pb-2"> + {(["terminal", "editor", "workdir", "git"] as const).map((m) => { + const active = openPanels.includes(m) + const sym = m === "terminal" ? "▷" : m === "editor" ? "✎" : m === "workdir" ? "▤" : "⑂" + return ( + <button + key={m} + type="button" + onClick={() => exclusiveToggleMode(m)} + className={`w-7 h-7 flex items-center justify-center rounded text-xs ${active ? "bg-gray-200 text-gray-900" : "text-gray-400 hover:text-gray-700 hover:bg-gray-100"}`} + title={m} + > + {sym} + </button> + ) + })} + </div> + )} + <ShareArtifactDialog loop={meta} open={shareOpen} onClose={() => setShareOpen(false)} onSaved={async () => { await ws.refresh() }} /> + </div> + ) +} + +// ============================================================================ +// Loop header (driver state + context chips + mode toggles) — phase1 LoopHeader +// ============================================================================ + +function LoopHeader({ + meta, + mounts, + connected, + reconnecting, + running, + viewers, + queue, + onClearQueue, + openPanels, + toggleMode, + onShareWork, + showShareButton, +}: { + meta: LoopMeta + mounts: ContextMount[] + connected: boolean + reconnecting: boolean + running: boolean + viewers: number + queue: string[] + onClearQueue: () => void + openPanels: RightMode[] + toggleMode: (m: RightMode) => void + onShareWork: () => void + showShareButton?: boolean +}) { + const isMobile = useIsMobile() + const navigate = useNavigate() + const ws = useWorkspace() + const [collapsed, setCollapsed] = useState(isMobile) + const [distilling, setDistilling] = useState(false) + const [editingTitle, setEditingTitle] = useState(false) + const [titleDraft, setTitleDraft] = useState(meta.title) + const canEditTitle = ws.currentUser?.id === meta.createdBy + const onDistill = async () => { + if (distilling) return + setDistilling(true) + try { + const child = await distillLoop(meta.id) + if (child) navigate(`/loop/${child.id}`) + } finally { + setDistilling(false) + } + } + const saveTitle = async () => { + const next = titleDraft.trim() + if (!next || next === meta.title) { setEditingTitle(false); setTitleDraft(meta.title); return } + setEditingTitle(false) + const updated = await ws.setLoopTitle(meta.id, next) + if (!updated) setTitleDraft(meta.title) + } + const modeBtn = (label: string, m: RightMode) => ( + <button + key={m} + className={ + openPanels.includes(m) + ? "px-2 py-0.5 rounded bg-gray-100 text-gray-900" + : "px-2 py-0.5 rounded text-gray-500 hover:text-gray-900 hover:bg-gray-50" + } + onClick={() => toggleMode(m)} + > + {label} + </button> + ) + return ( + <header className={"group/header px-3 md:px-5 shrink-0 border-b border-gray-200 " + (collapsed ? "py-1.5" : "pt-3 pb-2")}> + <div className="flex items-center gap-2 flex-wrap"> + <button + type="button" + onClick={() => setCollapsed((v) => !v)} + className="text-gray-400 hover:text-gray-700 -ml-1 p-0.5 rounded hover:bg-gray-100" + title={collapsed ? "expand header" : "collapse header"} + > + <ChevronDown size={14} className={"transition-transform " + (collapsed ? "-rotate-90" : "")} /> + </button> + {editingTitle ? ( + <input + type="text" + value={titleDraft} + autoFocus + onChange={(e) => setTitleDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) { e.preventDefault(); saveTitle() } + else if (e.key === "Escape") { e.preventDefault(); setEditingTitle(false); setTitleDraft(meta.title) } + }} + onBlur={saveTitle} + maxLength={200} + className="text-[14px] md:text-[15px] font-medium text-gray-900 bg-white border border-blue-400 rounded px-1 py-0 outline-none min-w-[120px]" + /> + ) : ( + <span + className={"text-[14px] md:text-[15px] font-medium text-gray-900 " + (canEditTitle ? "cursor-pointer hover:bg-gray-100 rounded px-1 -mx-1" : "")} + onClick={() => { if (canEditTitle) { setTitleDraft(meta.title); setEditingTitle(true) } }} + title={canEditTitle ? "click to rename" : undefined} + > + {meta.title} + </span> + )} + <span className="text-xs text-gray-500"> + driver: <span className="text-gray-900">{meta.driver ?? meta.createdBy}</span> + </span> + {meta.rfdRequestedAt && ( + <span + className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-200 font-medium tracking-wide" + title={`released for drive by ${meta.rfdRequestedBy ?? "?"} at ${meta.rfdRequestedAt}`} + > + RFD + </span> + )} + {/* Only surface WS state when something's wrong — green-when-fine is noise. */} + {!connected && reconnecting && ( + <span className="text-[11px] text-amber-600">reconnecting…</span> + )} + {running && <span className="text-[11px] text-blue-600">running</span>} + {viewers > 1 && ( + <span + title="people watching" + className="text-[11px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-800 border border-emerald-200" + > + 👥 {viewers} + </span> + )} + <div className="flex-1" /> + <button + type="button" + onClick={onDistill} + disabled={distilling} + className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] border bg-white text-violet-700 border-violet-300 hover:bg-violet-50 disabled:opacity-50" + title="Spawn a child loop seeded with this loop's conversation, for sedimenting reusable insights into knowledge/" + > + <FlaskConical size={11} /> + {distilling ? "Distilling…" : "Distill"} + </button> + {showShareButton !== false && ( + <button + onClick={onShareWork} + className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] border ${meta.shareEnabled ? "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-100"}`} + title="Share workspace artifact (static files or port forward)" + > + <Globe size={11} /> + Share Artifact + </button> + )} + <DriveToggle meta={meta} /> + <ShareToggle meta={meta} /> + </div> + + {!collapsed && ( + <> + {/* workdir + branch + mode toggles */} + <div className="text-xs text-gray-500 mt-1.5 flex items-center gap-2 flex-wrap"> + <span className="font-mono">~/.loopat/loops/{meta.id.slice(0, 8)}/workdir</span> + <span>·</span> + <span>main</span> + {viewers > 0 && ( + <> + <span>·</span> + <span>{viewers} viewing</span> + </> + )} + <div className="flex-1" /> + <div className="flex items-center gap-1 text-[11px]"> + {modeBtn("ℹ info", "info")} + {modeBtn("▤ workdir", "workdir")} + {modeBtn("✎ editor", "editor")} + {modeBtn("▷ terminal", "terminal")} + <button + className={ + openPanels.includes("git") + ? "px-2 py-0.5 rounded bg-gray-100 text-gray-900" + : "px-2 py-0.5 rounded text-gray-500 hover:text-gray-900 hover:bg-gray-50" + } + onClick={() => toggleMode("git")} + title="git changes" + > + <GitBranch size={14} /> + </button> + </div> + </div> + + {/* context chips */} + <div className="mt-2 flex items-center gap-1.5 flex-wrap text-[11px]"> + <span className="text-gray-400">context:</span> + {mounts.map((m) => ( + <ContextChip key={m.path} label={m.name} value={m.name === "knowledge" ? "ro" : "rw"} /> + ))} + </div> + + {/* profile row — which profiles are active. Profile model re-composes + on every spawn, so no "update available" prompt is needed. Each + chip shows the profile's description (from CLAUDE.md frontmatter + or first heading) as a hover tooltip. */} + {meta.config?.profiles && meta.config.profiles.length > 0 && ( + <ProfileChipsRow profiles={meta.config.profiles as string[]} /> + )} + + {/* vault row — which credential bundle was bound into this loop's sandbox. + Always shows: "default" is the implicit value when meta omits it. */} + <div className="mt-1 flex items-center gap-1.5 flex-wrap text-[11px]"> + <span className="text-gray-400">vault:</span> + <ContextChip + label={meta.config?.vault ?? "default"} + value="loaded" + /> + </div> + </> + )} + </header> + ) +} + +/** + * Toggles meta.public on the loop. When on, shows the /share/<id> URL with a + * copy button. Only the loop's `createdBy` is allowed to flip the flag — + * server enforces this too; the button is hidden for non-owners since they + * can't change it anyway. + */ +function ShareToggle({ meta }: { meta: LoopMeta }) { + const ws = useWorkspace() + const [copied, setCopied] = useState(false) + const [busy, setBusy] = useState(false) + const isOwner = ws.currentUser?.id === meta.createdBy + const isPublic = meta.public === true + if (!ws.currentUser) return null + + const shareUrl = `${location.origin}/loop/${meta.id}` + + const onToggle = async () => { + if (!isOwner || busy) return + setBusy(true) + try { + await ws.setLoopPublic(meta.id, !isPublic) + } finally { + setBusy(false) + } + } + + const onCopy = async () => { + try { + await navigator.clipboard.writeText(shareUrl) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch {} + } + + if (!isOwner) { + // Non-owner: just show a static badge so collaborators can see the state. + return ( + <span + title={isPublic ? "this loop is shared publicly" : "this loop is private"} + className={ + "inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] " + + (isPublic + ? "bg-emerald-50 text-emerald-800 border border-emerald-200" + : "bg-gray-100 text-gray-600") + } + > + {isPublic ? <Globe size={11} /> : <Lock size={11} />} + {isPublic ? "public" : "private"} + </span> + ) + } + + return ( + <div className="inline-flex items-center gap-1"> + {isPublic && ( + <button + type="button" + onClick={onCopy} + title={shareUrl} + className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] text-gray-600 hover:bg-gray-100" + > + {copied ? <Check size={11} /> : <Copy size={11} />} + <span className="hidden md:inline">{copied ? "copied" : "copy link"}</span> + </button> + )} + <button + type="button" + onClick={onToggle} + disabled={busy} + title={isPublic ? "stop sharing" : "share publicly (anyone with the link can view)"} + className={ + "inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] border disabled:opacity-50 " + + (isPublic + ? "bg-emerald-50 text-emerald-800 border-emerald-200 hover:bg-emerald-100" + : "bg-white text-gray-700 border-gray-300 hover:bg-gray-100") + } + > + {isPublic ? <Globe size={11} /> : <Lock size={11} />} + {isPublic ? "shared" : "share"} + </button> + </div> + ) +} + +// "Request For Drive" / "Drive" — driver handoff. Driver releases the loop +// for grabs (sandbox is torn down server-side, history kept); any authed +// user can then claim it. The new driver's personal config (apiKey, vault) +// takes over on the next user message. +function DriveToggle({ meta }: { meta: LoopMeta }) { + const ws = useWorkspace() + const [busy, setBusy] = useState(false) + if (!ws.currentUser) return null + const effectiveDriver = meta.driver ?? meta.createdBy + const isDriver = ws.currentUser.id === effectiveDriver + const isRfd = !!meta.rfdRequestedAt + + if (isRfd) { + return ( + <button + type="button" + disabled={busy} + onClick={async () => { + setBusy(true) + try { await ws.takeDrive(meta.id) } finally { setBusy(false) } + }} + title={`released for drive by ${meta.rfdRequestedBy ?? "?"} — click to take over`} + className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] border bg-amber-500 text-white border-amber-600 hover:bg-amber-600 disabled:opacity-50" + > + <Hand size={11} /> + Drive + </button> + ) + } + + if (!isDriver) return null + + return ( + <button + type="button" + disabled={busy} + onClick={async () => { + if (!window.confirm("Request For Drive: this will tear down the sandbox and release the loop so anyone else can take over. Continue?")) return + setBusy(true) + try { await ws.requestDrive(meta.id) } finally { setBusy(false) } + }} + title="Release this loop so someone else can drive it" + className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] border bg-white text-amber-700 border-amber-300 hover:bg-amber-50 disabled:opacity-50" + > + <Hand size={11} /> + Request For Drive + </button> + ) +} + +/** + * Blocking overlay shown while the loop's sandbox image is building on first + * use (mise toolchain install / base-image pull). Covers the chat + panels and + * captures pointer events so the user can't type into a not-yet-ready terminal + * or fire a chat turn that would just queue. Clears as soon as the server flips + * the loop's status `phase` back to "ready". + */ +function PreparingOverlay({ detail }: { detail?: string }) { + return ( + <div className="absolute inset-0 z-30 flex items-center justify-center bg-white/70 backdrop-blur-sm"> + <div className="max-w-md mx-6 px-6 py-5 rounded-xl border border-blue-200 bg-white shadow-lg text-center"> + <div className="flex items-center justify-center gap-2 mb-2"> + <span className="inline-block w-4 h-4 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" /> + <span className="text-sm font-medium text-gray-900">Preparing this loop’s sandbox…</span> + </div> + <p className="text-[12px] text-gray-600 leading-relaxed"> + Installing the toolchain from <code className="font-mono text-[11px] bg-gray-100 px-1 rounded">mise.toml</code>. + Terminal and chat are paused until tools are ready — this only happens the first time. + </p> + {detail && detail !== "Ready" && ( + <p className="mt-2 text-[11px] font-mono text-blue-700 break-words">{detail}</p> + )} + </div> + </div> + ) +} + +function ContextChip({ label, value, title }: { label: string; value: string; title?: string }) { + return ( + <span + className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-gray-100 text-[11px]" + title={title} + > + <span className="text-gray-500">{label}:</span> + <span className="text-gray-900 font-medium">{value}</span> + </span> + ) +} + +/** Profile chips row — fetches descriptions once and surfaces them via + * native hover tooltips on each chip. */ +function ProfileChipsRow({ profiles }: { profiles: string[] }) { + const [desc, setDesc] = useState<Record<string, string>>({}) + useEffect(() => { + let cancelled = false + listProfiles().then((all) => { + if (cancelled) return + const map: Record<string, string> = {} + for (const p of all) { + if (p.description) map[p.name] = p.description + } + setDesc(map) + }).catch(() => {}) + return () => { cancelled = true } + }, []) + return ( + <div className="mt-1 flex items-center gap-1.5 flex-wrap text-[11px]"> + <span className="text-gray-400">profiles:</span> + {profiles.map((p) => ( + <ContextChip key={p} label={p} value="active" title={desc[p]} /> + ))} + </div> + ) +} + +// ============================================================================ +// Right panel (info / workdir / editor / terminal) — phase1 RightPanel +// ============================================================================ + +function RightPanel({ + loopId, + meta, + mode, + onClose, + pickedFile, + onPickFile, + currentUserId, + isFullscreen, + onToggleFullscreen, + onEditorSelection, +}: { + loopId: string + meta: LoopMeta + mode: RightMode + onClose: () => void + pickedFile: string | null + onPickFile: (path: string) => void + currentUserId: string + isFullscreen?: boolean + onToggleFullscreen?: () => void + onEditorSelection?: (sel: { from: number; to: number } | null) => void +}) { + const isMobile = useIsMobile() + const [mobilePct, setMobilePct] = useState(() => { + const saved = localStorage.getItem("loopat:mobilePanelPct") + return saved ? Math.max(20, Math.min(95, parseInt(saved) || 55)) : 55 + }) + const dragRef = useRef<{ startY: number; startPct: number } | null>(null) + + const persistPct = (pct: number) => { + localStorage.setItem("loopat:mobilePanelPct", String(pct)) + } + + // shared drag logic + const startDrag = (clientY: number) => { + dragRef.current = { startY: clientY, startPct: mobilePct } + if (isFullscreen) { + const pct = Math.round((clientY / window.innerHeight) * 100) + const clamped = Math.max(20, Math.min(95, pct)) + setMobilePct(clamped) + persistPct(clamped) + onToggleFullscreen?.() + } + } + + const moveDrag = (clientY: number) => { + if (!dragRef.current) return + const dy = clientY - dragRef.current.startY + const dpct = Math.round((dy / window.innerHeight) * 100) + const pct = Math.max(20, Math.min(95, dragRef.current.startPct + dpct)) + setMobilePct(pct) + persistPct(pct) + } + + const endDrag = () => { + dragRef.current = null + } + + // touch handlers (mobile) + const onMobileTouchStart = (e: React.TouchEvent) => { + e.preventDefault() + e.stopPropagation() + startDrag(e.touches[0].clientY) + } + const onMobileTouchMove = (e: React.TouchEvent) => { + e.preventDefault() + moveDrag(e.touches[0].clientY) + } + const onMobileTouchEnd = (e: React.TouchEvent) => { + e.preventDefault() + endDrag() + } + + // pointer handlers (mouse on desktop) + const onMobilePointerDown = (e: React.PointerEvent) => { + if (e.pointerType === "touch") return // let touch handlers handle it + e.preventDefault() + ;(e.target as HTMLElement).setPointerCapture(e.pointerId) + startDrag(e.clientY) + } + const onMobilePointerMove = (e: React.PointerEvent) => { + if (e.pointerType === "touch" || !dragRef.current) return + moveDrag(e.clientY) + } + const onMobilePointerUp = (e: React.PointerEvent) => { + if (e.pointerType === "touch") return + endDrag() + } + + const header = ( + <header className="px-3 h-8 shrink-0 border-b border-gray-200 flex items-center gap-1 text-[11px] text-gray-500"> + <span className="capitalize">{mode}</span> + {mode === "editor" && ( + <span className="ml-2 truncate text-gray-700">{pickedFile || "(no file)"}</span> + )} + <div className="flex-1" /> + {(mode === "editor" || mode === "terminal") && ( + <button + className="text-gray-400 hover:text-gray-700 px-1 rounded hover:bg-gray-100" + onClick={onToggleFullscreen} + title={isFullscreen ? "restore" : "maximize"} + > + {isFullscreen ? <Minimize2 size={13} /> : <Maximize2 size={13} />} + </button> + )} + <button + className="text-gray-500 hover:text-gray-900 px-1 rounded hover:bg-gray-100" + onClick={onClose} + title="close panel" + > + ✕ + </button> + </header> + ) + + const panel = ( + <aside className="flex-1 min-w-0 bg-white flex flex-col"> + {header} + + {mode === "info" && <InfoPanel meta={meta} />} + + {mode === "workdir" && ( + <> + <FileTree loopId={loopId} onPick={onPickFile} picked={pickedFile} /> + <div className="border-t border-gray-200 px-3 py-2 text-[11px] text-gray-500"> + ⑂ <span className="text-gray-900">main</span> + </div> + </> + )} + + <Suspense fallback={<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">Loading...</div>}> + {mode === "editor" && <Editor loopId={loopId} path={pickedFile} onSelectionChange={onEditorSelection} />} + {mode === "terminal" && ( + <div className="flex-1 min-h-0 bg-[#1a1c20] overflow-hidden"> + <Terminal loopId={loopId} currentUserId={currentUserId} /> + </div> + )} + {mode === "git" && ( + <GitDiffSidebar loopId={loopId} onClose={onClose} onPickFile={onPickFile} /> + )} + </Suspense> + </aside> + ) + + if (isMobile) { + return ( + <div + className="fixed inset-x-0 top-0 z-40 bg-white rounded-b-xl shadow-xl pointer-events-auto overflow-hidden" + style={{ height: isFullscreen ? "100vh" : `${mobilePct}vh` }} + > + <div className="absolute inset-0 bottom-7 overflow-y-auto flex flex-col"> + {panel} + </div> + <div + className="absolute inset-x-0 bottom-0 h-7 z-10 flex items-center justify-center cursor-row-resize active:bg-gray-50 rounded-b-xl" + style={{ touchAction: "none" }} + onTouchStart={onMobileTouchStart} + onTouchMove={onMobileTouchMove} + onTouchEnd={onMobileTouchEnd} + onPointerDown={onMobilePointerDown} + onPointerMove={onMobilePointerMove} + onPointerUp={onMobilePointerUp} + > + <div className="w-8 h-1 rounded-full bg-gray-300" /> + </div> + </div> + ) + } + + if (isFullscreen) { + return createPortal( + <div className="fixed inset-0 z-50 flex flex-col"> + {panel} + </div>, + document.body, + ) + } + + return panel +} + +function InfoPanel({ meta }: { meta: LoopMeta }) { + const ws = useWorkspace() + const isMine = ws.currentUser?.id === meta.createdBy + return ( + <div className="flex-1 min-h-0 overflow-auto px-5 py-4 text-[13px] text-gray-900"> + <Section label="basics"> + <Row label="title" value={meta.title} /> + <Row label="created" value={new Date(meta.createdAt).toLocaleString()} /> + <Row label="status" value="active" /> + <Row label="driver" value={isMine ? `${meta.createdBy} (you)` : meta.createdBy} /> + <Row label="sharing" value={meta.public ? `public — /loop/${meta.id.slice(0, 8)}…` : "private"} /> + </Section> + <Section label="workdir"> + <Row label="id" value={meta.id} mono /> + <Row label="path" value={`~/.loopat/loops/${meta.id.slice(0, 8)}/workdir`} mono /> + <Row label="branch" value="main" mono /> + </Section> + <Section label="context"> + <Row label="knowledge" value="all (ro)" /> + <Row label="notes" value="all (rw)" /> + <Row label="personal" value={`${meta.createdBy} (rw)`} /> + </Section> + </div> + ) +} + +function Section({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <section className="mb-5"> + <h3 className="text-[11px] uppercase tracking-wide text-gray-400 mb-2">{label}</h3> + <div className="flex flex-col gap-1.5">{children}</div> + </section> + ) +} + +function Row({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( + <div className="flex items-baseline gap-2"> + <span className="text-[11px] text-gray-500 w-24 shrink-0">{label}</span> + <span className={mono ? "font-mono text-[12px] text-gray-800 truncate" : "text-[13px] text-gray-900 truncate"}> + {value} + </span> + </div> + ) +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx new file mode 100644 index 00000000..dfdc2dcf --- /dev/null +++ b/web/src/pages/SettingsPage.tsx @@ -0,0 +1,1123 @@ +/** + * Full-screen Settings page — the UX-friendly editor for + * `personal/<user>/.loopat/config.json`. Sections: Providers, Shell, MCP, + * Personal Repo. Env vars and home mounts are no longer declared here — + * they're conventional, derived from `vaults/<v>/envs/*` and + * `vaults/<v>/mounts/home/*` filesystem layout. + * + * Gating: everything except Personal Repo is grayed out until the user + * has both `personalRepo` set AND `imported: true` (per /api/personal/status). + * The Personal Repo section embeds the existing PersonalRepoPanel so users + * can complete setup in place. + */ +import { useCallback, useEffect, useState, type ReactNode } from "react" +import { useNavigate, useParams } from "react-router-dom" +import { + getPersonalStatus, + getPersonalDisk, + savePersonalDisk, + writeVaultEnv, + pushPersonalVault, + pullPersonalVault, + testProviderConnection, + listApiTokens, + createApiToken, + revokeApiToken, + type PersonalConfigDisk, + type ProviderDisk, + type RefExistsMap, + type ModelEntry, + type ApiTokenEntry, +} from "../api" +import { PersonalRepoPanel } from "../components/dialog/PersonalRepoPanel" +import { A2ASection } from "../components/settings/A2ASection" +import { UsersPanel, WorkspacePanel as AdminWorkspacePanel, ServePanel } from "../components/dialog/AdminDialog" +import { ClaudeConfigPanel } from "../components/settings/ClaudeConfigPanel" +import { MiseConfigPanel } from "../components/settings/MiseConfigPanel" +import { PresetsPanel } from "../components/settings/PresetsPanel" +import { getAdminPresets, type ProviderPreset } from "../api" +import { TokenUsagePage } from "./TokenUsagePage" +import { useWorkspace } from "@/ctx" +import { ArrowLeft, Plus, Trash2, RefreshCw, Check, AlertCircle, Lock, FileCode2, Search, User, Cpu, Terminal, Layers, BarChart3, Users, Globe, Share2, KeyRound, Copy, Wrench, Bookmark } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +const inputClass = "w-full px-2.5 py-1.5 border border-gray-300 rounded text-[13px] outline-none bg-white focus:border-gray-900 focus:ring-1 focus:ring-gray-900 transition-colors disabled:bg-gray-50 disabled:text-gray-400" +const inputClassSm = "w-full px-2 py-1 border border-gray-300 rounded text-[11px] outline-none bg-white focus:border-gray-900 focus:ring-1 focus:ring-gray-900 transition-colors" + +/** Mirror of server `providerEnvVarName` — keep the two in sync. + * "Anthropic" → "ANTHROPIC_API_KEY"; "DeepSeek" → "DEEPSEEK_API_KEY". */ +function providerEnvVarName(providerName: string): string { + const sanitized = providerName.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase() + return `${sanitized || "PROVIDER"}_API_KEY` +} + +type TabId = "personal-repo" | "providers" | "claude-config" | "mise-config" | "token-usage" | "api-tokens" | "admin-users" | "admin-workspace" | "admin-serve" | "admin-presets" + +const TABS: { id: TabId; label: string; gated: boolean; description: string; icon: typeof User }[] = [ + { id: "personal-repo", label: "Personal Repo", gated: false, description: "Your private repo carrying credentials + dotfiles.", icon: User }, + { id: "providers", label: "AI Providers", gated: true, description: "Models, base URLs, API keys. Pick a default.", icon: Cpu }, + { id: "claude-config", label: "Claude Config", gated: true, description: "Compose your .claude/ tiers — plugins, MCP servers, settings per tier.", icon: Layers }, + { id: "mise-config", label: "Mise Config", gated: true, description: "Configure mise toolchain tools per tier — mise.toml for each tier.", icon: Wrench }, + { id: "token-usage", label: "Token Usage", gated: false, description: "Token consumption across models, loops, and time.",icon: BarChart3 }, + { id: "api-tokens", label: "API Tokens", gated: false, description: "Bearer tokens for external programs to drive your loops via the Loop API. See API docs below.", icon: KeyRound }, + { id: "admin-users", label: "Users", gated: false, description: "Manage workspace members — activate, promote, remove.", icon: Users }, + { id: "admin-workspace",label: "Workspace AI Providers", gated: false, description: "Shared workspace provider configuration.", icon: Globe }, + { id: "admin-serve", label: "Share Artifact Serve", gated: false, description: "Public share domain and HTTPS settings.", icon: Share2 }, + { id: "admin-presets", label: "Presets", gated: false, description: "Manage preset defaults — AI providers and Mise tool suggestions.", icon: Bookmark }, +] + +// ──────────────────────────────────────────────────────────────────────────── +// Page shell +// ──────────────────────────────────────────────────────────────────────────── + +export function SettingsPage() { + const navigate = useNavigate() + const ws = useWorkspace() + const isAdmin = ws.currentUser?.role === "admin" + const { tab } = useParams<{ tab: string }>() + const active = (TABS.some((t) => t.id === tab) ? tab : "personal-repo") as TabId + + const [loading, setLoading] = useState(true) + const [statusReady, setStatusReady] = useState(false) + const [statusReason, setStatusReason] = useState<string>("") + const [disk, setDisk] = useState<PersonalConfigDisk | null>(null) + const [refExists, setRefExists] = useState<RefExistsMap>({}) + + const refresh = useCallback(async () => { + setLoading(true) + const [status, diskRes] = await Promise.all([getPersonalStatus(), getPersonalDisk()]) + const ready = !!status && !!status.personalRepo && status.imported + setStatusReady(ready) + setStatusReason( + !status || !status.personalRepo + ? "no personal repo configured" + : !status.imported + ? "personal repo not imported yet" + : "", + ) + if (diskRes) { + setDisk(diskRes.disk) + setRefExists(diskRes.refExists) + } + setLoading(false) + }, []) + + // On opening Settings, pull personal from the remote once (best-effort) so + // config edits made on another host show up, then load. + useEffect(() => { + pullPersonalVault().catch(() => {}).finally(() => { refresh() }) + }, [refresh]) + + // If the active tab is gated and personal repo isn't ready, bounce to the + // personal-repo tab so users see the unlock path instead of a dead pane. + useEffect(() => { + if (!loading && !statusReady && TABS.find((t) => t.id === active)?.gated && !active.startsWith("admin-")) { + navigate(`/settings/personal-repo`, { replace: true }) + } + }, [loading, statusReady, active, navigate]) + + const activeMeta = TABS.find((t) => t.id === active) ?? TABS[0] + const isGatedAndLocked = activeMeta.gated && !statusReady && !active.startsWith("admin-") + + const [search, setSearch] = useState("") + const visibleTabs = TABS.filter((t) => { + if (t.id.startsWith("admin-") && !isAdmin) return false + return true + }) + const regularTabs = visibleTabs.filter((t) => !t.id.startsWith("admin-")) + const adminTabs = visibleTabs.filter((t) => t.id.startsWith("admin-")) + + const filteredTabs = search.trim() === "" + ? visibleTabs + : visibleTabs.filter((t) => + t.label.toLowerCase().includes(search.toLowerCase()) || + t.description.toLowerCase().includes(search.toLowerCase()) || + t.id.toLowerCase().includes(search.toLowerCase()), + ) + + return ( + <div className="h-full overflow-hidden bg-gray-50 flex flex-col"> + <header className="shrink-0 border-b border-gray-200 bg-white px-4 sm:px-6 h-12 flex items-center gap-3"> + <button + type="button" + onClick={() => navigate(-1)} + className="w-8 h-8 flex items-center justify-center rounded text-gray-500 hover:text-gray-900 hover:bg-gray-100" + title="back" + > + <ArrowLeft size={16} /> + </button> + <h1 className="text-[15px] font-semibold text-gray-900">Settings</h1> + <div className="flex-1" /> + <button + type="button" + onClick={() => navigate("/context/personal?file=.loopat/config.json&edit=1")} + className="h-7 px-2.5 rounded text-xs border border-gray-200 hover:bg-gray-100 text-gray-700 flex items-center gap-1.5" + title="open raw config.json in Context tab" + > + <FileCode2 size={12} /> + <span className="hidden sm:inline">edit raw config.json</span> + </button> + <button + type="button" + onClick={refresh} + disabled={loading} + className="h-7 px-2.5 rounded text-xs border border-gray-200 hover:bg-gray-100 text-gray-700 flex items-center gap-1.5 disabled:opacity-50" + title="reload" + > + <RefreshCw size={12} className={loading ? "animate-spin" : ""} /> + <span className="hidden sm:inline">reload</span> + </button> + </header> + + <div className="flex-1 min-h-0 flex flex-col sm:flex-row"> + {/* tab nav: horizontal on mobile, vertical sidebar on desktop */} + <nav className="sm:w-56 shrink-0 sm:border-r border-b sm:border-b-0 border-gray-200 bg-white flex flex-col"> + {/* search */} + <div className="hidden sm:block px-3 py-3 border-b border-gray-100"> + <div className="relative"> + <Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400" /> + <input + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="search settings" + className="w-full pl-7 pr-2 py-1.5 text-[12px] border border-gray-200 rounded outline-none focus:border-gray-400 bg-gray-50 focus:bg-white" + /> + </div> + </div> + <ul className="flex sm:flex-col gap-0.5 sm:gap-0 p-2 sm:py-2 overflow-x-auto sm:overflow-x-visible"> + {search.trim() + ? filteredTabs.map((t) => { + const isActive = t.id === active + const locked = t.gated && !loading && !statusReady + return ( + <li key={t.id} className="shrink-0 sm:px-1"> + <button + type="button" + onClick={() => navigate(`/settings/${t.id}`)} + className={ + "w-full text-left px-2.5 py-1.5 rounded text-[13px] flex items-center gap-2 transition-colors whitespace-nowrap " + + (isActive ? "bg-gray-100 text-gray-900 font-medium" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900") + } + > + <t.icon size={14} className={`shrink-0 ${isActive ? "text-gray-600" : "text-gray-400"}`} /> + <span className="flex-1 truncate">{t.label}</span> + {locked && <span title="locked"><Lock size={11} className="text-gray-400 shrink-0" /></span>} + </button> + </li> + ) + }) + : ( + <> + {regularTabs.map((t) => { + const isActive = t.id === active + const locked = t.gated && !loading && !statusReady + return ( + <li key={t.id} className="shrink-0 sm:px-1"> + <button + type="button" + onClick={() => navigate(`/settings/${t.id}`)} + className={ + "w-full text-left px-2.5 py-1.5 rounded text-[13px] flex items-center gap-2 transition-colors whitespace-nowrap " + + (isActive ? "bg-gray-100 text-gray-900 font-medium" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900") + } + > + <t.icon size={14} className={`shrink-0 ${isActive ? "text-gray-600" : "text-gray-400"}`} /> + <span className="flex-1 truncate">{t.label}</span> + {locked && <span title="locked"><Lock size={11} className="text-gray-400 shrink-0" /></span>} + </button> + </li> + ) + })} + {adminTabs.length > 0 && ( + <> + <li className="px-3 pt-3 pb-1 text-[10px] font-semibold text-gray-400 uppercase tracking-wider"> + Admin Settings + </li> + {adminTabs.map((t) => { + const isActive = t.id === active + return ( + <li key={t.id} className="shrink-0 sm:px-1"> + <button + type="button" + onClick={() => navigate(`/settings/${t.id}`)} + className={ + "w-full text-left px-2.5 py-1.5 rounded text-[13px] flex items-center gap-2 transition-colors whitespace-nowrap " + + (isActive ? "bg-gray-100 text-gray-900 font-medium" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900") + } + > + <t.icon size={14} className={`shrink-0 ${isActive ? "text-gray-600" : "text-gray-400"}`} /> + <span className="flex-1 truncate">{t.label}</span> + </button> + </li> + ) + })} + </> + )} + </> + ) + } + {filteredTabs.length === 0 && ( + <li className="px-3 py-2 text-[11px] text-gray-400 italic">no match</li> + )} + </ul> + </nav> + + {/* tab content */} + <main className="flex-1 min-w-0 min-h-0 overflow-auto"> + <div className="max-w-4xl mx-auto px-4 sm:px-6 py-5"> + {loading && !disk ? ( + <div className="text-[13px] text-gray-400 italic py-12 text-center">loading…</div> + ) : ( + <> + <div className="mb-4"> + <h2 className="text-base font-semibold text-gray-900 flex items-center gap-2"> + <activeMeta.icon size={18} className="text-gray-400" /> + {activeMeta.label} + </h2> + <p className="text-xs text-gray-500 mt-0.5">{activeMeta.description}</p> + </div> + + {isGatedAndLocked && ( + <div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 flex items-start gap-2"> + <AlertCircle size={16} className="text-amber-700 mt-0.5 shrink-0" /> + <div className="text-[13px] text-amber-900 flex-1"> + <div className="font-medium">Set up your personal repo first</div> + <div className="text-[12px] text-amber-800/90 mt-0.5"> + These settings live in your personal repo. Complete setup in + the <button onClick={() => navigate("/settings/personal-repo")} className="underline">Personal Repo</button> tab; this section will unlock automatically. + <span className="text-amber-700/70"> ({statusReason})</span> + </div> + </div> + </div> + )} + + <div className="relative"> + {isGatedAndLocked && ( + <div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-white/60 backdrop-blur-[1px] rounded-lg"> + <Lock size={24} className="text-gray-300" /> + <span className="text-[13px] text-gray-500 font-medium">Set up your personal repo first</span> + </div> + )} + <div className={isGatedAndLocked ? "opacity-30" : ""}> + {active === "personal-repo" && ( + <div className="rounded-lg border border-gray-200 bg-white p-5"><PersonalRepoPanel onDone={refresh} /></div> + )} + {active === "providers" && ( + <ProvidersSection disk={disk} refExists={refExists} onChanged={refresh} disabled={isGatedAndLocked} /> + )} + {active === "claude-config" && ( + <ClaudeConfigPanel disabled={isGatedAndLocked} /> + )} + {active === "mise-config" && ( + <MiseConfigPanel disabled={isGatedAndLocked} /> + )} + {active === "token-usage" && ( + <TokenUsagePage /> + )} + {active === "api-tokens" && ( + <ApiTokensSection /> + )} + {active === "admin-users" && ( + <div className="rounded-lg border border-gray-200 bg-white p-5"><UsersPanel currentUserId={ws.currentUser?.id ?? ""} /></div> + )} + {active === "admin-workspace" && ( + <AdminWorkspacePanel /> + )} + {active === "admin-serve" && ( + <div className="rounded-lg border border-gray-200 bg-white p-5"><ServePanel /></div> + )} + {active === "admin-presets" && ( + <PresetsPanel /> + )} + </div> + </div> + </> + )} + </div> + </main> + </div> + </div> + ) +} + +// ──────────────────────────────────────────────────────────────────────────── +// Providers +// ──────────────────────────────────────────────────────────────────────────── + +type ProvidersDraft = { + default: string + providers: Record<string, { + models: ModelEntry[] + baseUrl: string + maxContextTokens: string + enabled: boolean + apiKeyNewValue: string + apiKeyStored: boolean + }> +} + +function ProvidersSection({ disk, refExists, onChanged, disabled }: { + disk: PersonalConfigDisk | null + refExists: RefExistsMap + onChanged: () => void + disabled?: boolean +}) { + const [draft, setDraft] = useState<ProvidersDraft | null>(null) + const [saving, setSaving] = useState(false) + const [err, setErr] = useState<string | null>(null) + const [saved, setSaved] = useState(false) + const [newName, setNewName] = useState("") + const [adding, setAdding] = useState(false) + const [newModelName, setNewModelName] = useState<Record<string, string>>({}) + const [addingModel, setAddingModel] = useState<Record<string, boolean>>({}) + const [editingProvName, setEditingProvName] = useState<string | null>(null) + const [provRenameValue, setProvRenameValue] = useState("") + const [editingModelKey, setEditingModelKey] = useState<string | null>(null) + const [newModelIdValue, setNewModelIdValue] = useState("") + const [testingModel, setTestingModel] = useState<Record<string, string>>({}) + const [testError, setTestError] = useState<Record<string, string>>({}) + const [providerPresets, setProviderPresets] = useState<ProviderPreset[]>([]) + + useEffect(() => { getAdminPresets().then(d => setProviderPresets(d.providerPresets)).catch(() => {}) }, []) + + useEffect(() => { + if (!disk) { setDraft(null); setSaved(false); return } + const next: ProvidersDraft = { default: "", providers: {} } + for (const [name, val] of Object.entries(disk.providers)) { + if (name === "default") { + if (typeof val === "string") next.default = val + continue + } + if (val && typeof val === "object") { + const p = val as ProviderDisk + const refInfo = refExists[`providers.${name}.apiKey`] + next.providers[name] = { + models: (p.models && p.models.length > 0) + ? p.models.map(m => ({ id: m.id, enabled: m.enabled !== false })) + : (p.model ? [{ id: p.model, enabled: true }] : []), + baseUrl: p.baseUrl ?? "", + maxContextTokens: p.maxContextTokens ? String(p.maxContextTokens) : "", + enabled: p.enabled !== false, + apiKeyNewValue: "", + apiKeyStored: !!refInfo?.exists, + } + } + } + setDraft(next) + }, [disk, refExists]) + + const names = draft ? Object.keys(draft.providers) : [] + + const updateProv = (name: string, patch: Partial<ProvidersDraft["providers"][string]>) => { + setDraft((d) => { + if (!d || !d.providers[name]) return d + return { ...d, providers: { ...d.providers, [name]: { ...d.providers[name], ...patch } } } + }) + } + + const remove = (name: string) => { + setDraft((d) => { + if (!d) return d + const { [name]: _, ...rest } = d.providers + const clearDefault = d.default === name || d.default.startsWith(`${name}/`) + return { ...d, providers: rest, default: clearDefault ? "" : d.default } + }) + } + + const updateModel = (provName: string, modelId: string, patch: Partial<ModelEntry>) => { + setDraft((d) => { + if (!d || !d.providers[provName]) return d + const models = d.providers[provName].models.map(m => + m.id === modelId ? { ...m, ...patch } : m, + ) + return { ...d, providers: { ...d.providers, [provName]: { ...d.providers[provName], models } } } + }) + } + + const toggleModel = (provName: string, modelId: string) => { + setDraft((d) => { + if (!d || !d.providers[provName]) return d + const models = d.providers[provName].models.map(m => + m.id === modelId ? { ...m, enabled: !m.enabled } : m, + ) + return { ...d, providers: { ...d.providers, [provName]: { ...d.providers[provName], models } } } + }) + } + + const removeModel = (provName: string, modelId: string) => { + setDraft((d) => { + if (!d || !d.providers[provName]) return d + const models = d.providers[provName].models.filter(m => m.id !== modelId) + const clearDefault = d.default === `${provName}/${modelId}` + return { ...d, default: clearDefault ? "" : d.default, providers: { ...d.providers, [provName]: { ...d.providers[provName], models } } } + }) + } + + const addModel = (provName: string) => { + const id = (newModelName[provName] ?? "").trim() + if (!id) return + setDraft((d) => { + if (!d || !d.providers[provName]) return d + if (d.providers[provName].models.some(m => m.id === id)) return d + return { + ...d, + providers: { + ...d.providers, + [provName]: { + ...d.providers[provName], + models: [...d.providers[provName].models, { id, enabled: true }], + }, + }, + } + }) + setNewModelName((p) => ({ ...p, [provName]: "" })) + setAddingModel((p) => ({ ...p, [provName]: false })) + } + + const renameModel = (provName: string, oldId: string) => { + const newId = newModelIdValue.trim() + if (!newId || newId === oldId) { setEditingModelKey(null); return } + setDraft((d) => { + if (!d || !d.providers[provName]) return d + if (d.providers[provName].models.some(m => m.id === newId)) return d + const models = d.providers[provName].models.map(m => + m.id === oldId ? { ...m, id: newId } : m, + ) + const prevDefault = d.default === `${provName}/${oldId}` ? `${provName}/${newId}` : d.default + return { ...d, default: prevDefault, providers: { ...d.providers, [provName]: { ...d.providers[provName], models } } } + }) + setEditingModelKey(null) + } + + const renameProvider = (oldName: string) => { + const newName = provRenameValue.trim() + if (!newName || newName === oldName || newName === "default") { setEditingProvName(null); return } + setDraft((d) => { + if (!d || !d.providers[oldName]) return d + if (d.providers[newName]) return d + const { [oldName]: prov, ...rest } = d.providers + // Update default: if default starts with "oldName/" or equals oldName, rewrite to newName + let newDefault = d.default + if (d.default === oldName) { + newDefault = newName + } else if (d.default.startsWith(`${oldName}/`)) { + newDefault = newName + d.default.slice(oldName.length) + } + return { ...d, default: newDefault, providers: { ...rest, [newName]: prov } } + }) + setEditingProvName(null) + } + + const addProvider = () => { + const n = newName.trim() + if (!n) return + if (n === "default") { setErr("'default' is reserved"); return } + setDraft((d) => { + if (!d) return d + if (d.providers[n]) return d + return { ...d, providers: { ...d.providers, [n]: { + models: [], baseUrl: "", maxContextTokens: "", enabled: false, + apiKeyNewValue: "", apiKeyStored: false, + } } } + }) + setNewName("") + setAdding(false) + setErr(null) + } + + const save = async () => { + if (!draft) return + setSaving(true) + setErr(null) + // Each provider derives a deterministic env var name: ANTHROPIC → ANTHROPIC_API_KEY etc. + // If the user typed a new value, write it to vault envs/<VAR>; the apiKey + // field becomes "${VAR}" so config.json never carries the literal value. + for (const [name, p] of Object.entries(draft.providers)) { + if (!p.apiKeyNewValue.trim()) continue + const varName = providerEnvVarName(name) + const r = await writeVaultEnv(varName, p.apiKeyNewValue.trim()) + if (!r.ok) { setErr(`apiKey write failed for "${name}": ${r.error}`); setSaving(false); return } + } + const providersOut: Record<string, ProviderDisk | string> = {} + if (draft.default) providersOut.default = draft.default + for (const [name, p] of Object.entries(draft.providers)) { + const models: ModelEntry[] = p.models + .filter(m => m.id.trim()) + .map(m => ({ + id: m.id.trim(), + ...(m.enabled ? {} : { enabled: false }), + ...(m.maxContextTokens && m.maxContextTokens > 0 ? { maxContextTokens: m.maxContextTokens } : {}), + })) + providersOut[name] = { + baseUrl: p.baseUrl, + apiKey: `\${${providerEnvVarName(name)}}`, + ...(models.length > 0 ? { models } : {}), + ...(p.maxContextTokens ? { maxContextTokens: Number(p.maxContextTokens) } : {}), + ...(p.enabled ? {} : { enabled: false }), + } + } + const r = await savePersonalDisk({ providers: providersOut }) + if (!r.ok) { setSaving(false); setErr(r.error ?? "save failed"); return } + // Write-through: personal is a per-user repo — push the change to the remote + // right away. Best-effort: the save already landed locally; if the remote + // moved ahead, ask the user to pull rather than failing the save. + const pushRes = await pushPersonalVault() + setSaving(false) + if (!pushRes.ok) { + setErr(pushRes.conflict + ? "Saved locally, but it conflicts with the remote — your edit is kept, not lost. Resolve it in Settings → Personal repo (take remote, or in a loop)." + : pushRes.needsPull + ? "Saved locally — remote moved while pushing. Save again to retry." + : `Saved locally, but push to remote failed: ${pushRes.error ?? "unknown"}`) + } + setSaved(true) + setTimeout(() => setSaved(false), 2500) + onChanged() + } + + if (!draft) return <div className="text-[12px] text-gray-400 italic">no providers yet</div> + + return ( + <div className="flex flex-col gap-3"> + {names.map((name) => { + const p = draft.providers[name] + const isAddingModel = addingModel[name] ?? false + const hasKey = p.apiKeyStored || p.apiKeyNewValue.trim() !== "" + return ( + <div key={name} className="bg-white border border-gray-200 rounded-lg overflow-hidden transition-shadow hover:shadow-sm"> + {/* Provider header */} + <div className="flex items-center gap-3 px-4 py-2.5 bg-gray-50/50 border-b border-gray-100"> + <label className="flex items-center gap-2.5 flex-1 min-w-0 select-none"> + <Switch + checked={p.enabled} + onCheckedChange={(v) => hasKey ? updateProv(name, { enabled: v }) : undefined} + disabled={!hasKey} + size="sm" + /> + {editingProvName === name ? ( + <input + autoFocus + value={provRenameValue} + onChange={(e) => setProvRenameValue(e.target.value)} + onBlur={() => renameProvider(name)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) renameProvider(name) + if (e.key === "Escape") setEditingProvName(null) + }} + className={cn(inputClass, "text-[13px] font-semibold flex-1 min-w-0")} + /> + ) : ( + <button + type="button" + onClick={() => { setEditingProvName(name); setProvRenameValue(name) }} + className={`text-[13px] font-semibold truncate text-left hover:underline ${p.enabled ? "text-gray-900" : "text-gray-400"}`} + title="click to rename" + > + {name} + </button> + )} + {!p.enabled && ( + <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-100 text-gray-400 font-medium shrink-0">disabled</span> + )} + </label> + <button + type="button" + onClick={() => remove(name)} + className="text-[11px] text-gray-400 hover:text-red-500 transition-colors shrink-0" + > + remove + </button> + </div> + + {/* Fields */} + <div className="p-4"> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3"> + <Labeled label="Base URL"> + <input + value={p.baseUrl} + onChange={(e) => updateProv(name, { baseUrl: e.target.value })} + placeholder="https://api.example.com" + className={inputClass} + /> + </Labeled> + <Labeled label="Max context tokens"> + <input + type="number" + value={p.maxContextTokens} + onChange={(e) => updateProv(name, { maxContextTokens: e.target.value })} + placeholder="auto" + className={inputClass} + /> + </Labeled> + <Labeled label={p.apiKeyStored ? "API key (set — type to overwrite)" : "API key"} className="sm:col-span-2"> + <input + type="password" + value={p.apiKeyNewValue} + onChange={(e) => updateProv(name, { apiKeyNewValue: e.target.value })} + placeholder={p.apiKeyStored ? "•••••• stored encrypted in vault" : "paste API key"} + className={inputClass} + /> + </Labeled> + </div> + + {/* Model list */} + <div className="border-t border-gray-100 pt-3"> + <div className="flex items-center justify-between mb-2"> + <span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider"> + Models ({p.models.length}) + </span> + <button + type="button" + onClick={() => setAddingModel((a) => ({ ...a, [name]: !isAddingModel }))} + className="text-[11px] text-gray-500 hover:text-gray-900 inline-flex items-center gap-1 transition-colors" + > + <Plus size={11} /> add model + </button> + </div> + + {isAddingModel && ( + <div className="flex items-center gap-1.5 mb-2"> + <input + autoFocus + value={newModelName[name] ?? ""} + onChange={(e) => setNewModelName((a) => ({ ...a, [name]: e.target.value }))} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) addModel(name); if (e.key === "Escape") setAddingModel((a) => ({ ...a, [name]: false })) }} + placeholder="model ID (e.g. claude-sonnet-4-20250514)" + className={cn(inputClass, "flex-1 text-[11px]")} + /> + <Button size="xs" onClick={() => addModel(name)}>add</Button> + <Button variant="ghost" size="xs" onClick={() => setAddingModel((a) => ({ ...a, [name]: false }))}>cancel</Button> + </div> + )} + + {p.models.length === 0 && !isAddingModel && ( + <div className="text-[11px] text-gray-400 italic py-2">no models — add one above</div> + )} + <div className="-mx-1"> + {p.models.map((m) => { + const isDefaultModel = draft.default === `${name}/${m.id}` + const editKey = `${name}::${m.id}` + const isEditing = editingModelKey === editKey + const tmState = testingModel[`${name}::${m.id}`] + const tmErr = testError[`${name}::${m.id}`] + return ( + <div key={m.id} className="flex items-center gap-1.5 px-1 py-1.5 rounded group hover:bg-gray-50 transition-colors"> + {/* Default model star */} + <button + type="button" + onClick={() => setDraft((d) => d ? { ...d, default: isDefaultModel ? "" : `${name}/${m.id}` } : d)} + className={`shrink-0 text-[13px] leading-none transition-colors ${isDefaultModel ? "text-amber-500" : "text-gray-200 hover:text-amber-400"}`} + title={isDefaultModel ? "current default model" : "set as default model"} + > + ★ + </button> + <label className="flex items-center shrink-0 cursor-pointer"> + <input + type="checkbox" + checked={m.enabled !== false} + onChange={() => toggleModel(name, m.id)} + className="h-3.5 w-3.5 rounded border-gray-300 text-gray-900 accent-gray-900" + /> + </label> + <div className="flex-1 min-w-0 flex items-center gap-1"> + {isEditing ? ( + <input + autoFocus + value={newModelIdValue} + onChange={(e) => setNewModelIdValue(e.target.value)} + onBlur={() => renameModel(name, m.id)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) renameModel(name, m.id) + if (e.key === "Escape") setEditingModelKey(null) + }} + className={cn(inputClass, "text-[11px] flex-1 min-w-0")} + /> + ) : ( + <code + className={`text-[12px] truncate cursor-pointer hover:bg-gray-100 px-0.5 rounded ${ + m.enabled !== false ? "text-gray-700" : "text-gray-300 line-through" + }`} + onClick={() => { setEditingModelKey(editKey); setNewModelIdValue(m.id) }} + title="click to edit model ID" + > + {m.id} + </code> + )} + {m.enabled === false && ( + <span className="text-[10px] text-gray-300 font-medium shrink-0">off</span> + )} + </div> + <input + type="number" + value={m.maxContextTokens ?? ""} + onChange={(e) => updateModel(name, m.id, { maxContextTokens: e.target.value ? Number(e.target.value) : undefined })} + placeholder="auto" + className={`w-24 px-1.5 py-0.5 border border-gray-200 rounded text-[10px] outline-none focus:border-gray-400 shrink-0 ${m.maxContextTokens ? "" : "opacity-0 group-hover:opacity-100 transition-opacity"}`} + title="max context tokens (empty = auto)" + /> + {/* Test button */} + <button + type="button" + onClick={async () => { + const newKey = p.apiKeyNewValue.trim() + const tk = `${name}::${m.id}` + if (!newKey && !p.apiKeyStored) { + setTestingModel((t) => ({ ...t, [tk]: "error" })) + setTestError((t) => ({ ...t, [tk]: "enter an API key first" })) + setTimeout(() => { + setTestingModel((t) => { const { [tk]: _, ...rest } = t; return rest }) + setTestError((t) => { const { [tk]: _, ...rest } = t; return rest }) + }, 3000) + return + } + setTestingModel((t) => ({ ...t, [tk]: "testing" })) + setTestError((t) => ({ ...t, [tk]: "" })) + try { + const result = newKey + ? await testProviderConnection(p.baseUrl, newKey, m.id) + : await testProviderConnection(p.baseUrl, "", m.id, name, "personal") + setTestingModel((t) => ({ ...t, [tk]: result.ok ? "ok" : "error" })) + if (!result.ok) setTestError((t) => ({ ...t, [tk]: result.error ?? "unknown error" })) + } catch (e: any) { + setTestingModel((t) => ({ ...t, [tk]: "error" })) + setTestError((t) => ({ ...t, [tk]: e?.message ?? "connection failed" })) + } + setTimeout(() => { + setTestingModel((t) => { const { [tk]: _, ...rest } = t; return rest }) + setTestError((t) => { const { [tk]: _, ...rest } = t; return rest }) + }, 4000) + }} + disabled={tmState === "testing" || (!p.apiKeyStored && !p.apiKeyNewValue.trim())} + className={`shrink-0 text-[10px] px-1 py-0 rounded transition-colors ${ + !p.apiKeyStored && !p.apiKeyNewValue.trim() ? "opacity-0 group-hover:opacity-100 text-gray-300" : + tmState === "ok" ? "bg-emerald-100 text-emerald-700" : + tmState === "error" ? "bg-red-100 text-red-700" : + tmState === "testing" ? "bg-gray-100 text-gray-400 animate-pulse" : + "text-gray-400 hover:text-gray-700 opacity-0 group-hover:opacity-100" + }`} + title={tmErr || (p.apiKeyNewValue.trim() ? "test connection" : p.apiKeyStored ? "test connection" : "enter an API key first")} + > + {tmState === "ok" ? "OK" : tmState === "error" ? "FAIL" : tmState === "testing" ? "..." : "test"} + </button> + <button + type="button" + onClick={() => removeModel(name, m.id)} + className="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" + title="remove model" + > + <Trash2 size={11} /> + </button> + </div> + ) + })} + </div> + </div> + </div> + </div> + ) + })} + + {/* Preset provider shortcuts */} + <div className="flex flex-wrap items-center gap-1.5"> + {providerPresets.filter((p) => !draft.providers[p.name]).map((p) => ( + <button + key={p.name} + type="button" + onClick={() => { + setDraft((d) => { + if (!d || d.providers[p.name]) return d + return { + ...d, + providers: { + ...d.providers, + [p.name]: { + models: p.models.map((id) => ({ id, enabled: true })), + baseUrl: p.baseUrl, + maxContextTokens: "", + enabled: false, + apiKeyNewValue: "", + apiKeyStored: false, + }, + }, + } + }) + }} + className="px-2 py-0.5 rounded border border-gray-200 bg-white text-[10px] text-gray-500 hover:text-gray-900 hover:border-gray-400 transition-colors" + title={`Add ${p.name} preset`} + > + + {p.name} + </button> + ))} + </div> + + {adding ? ( + <div className="flex items-center gap-2"> + <input + autoFocus + value={newName} + onChange={(e) => setNewName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && !e.nativeEvent.isComposing) addProvider(); if (e.key === "Escape") setAdding(false) }} + placeholder="provider name" + className={cn(inputClass, "flex-1")} + /> + <Button size="sm" onClick={addProvider}>add</Button> + <Button variant="ghost" size="sm" onClick={() => { setAdding(false); setNewName("") }}>cancel</Button> + </div> + ) : ( + <button + onClick={() => setAdding(true)} + className="self-start text-xs text-gray-500 hover:text-gray-900 inline-flex items-center gap-1" + > + <Plus size={12} /> add custom provider + </button> + )} + + <div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-gray-100"> + {err && <span className="text-xs text-red-600">{err}</span>} + {saved && !err && <span className="text-xs text-emerald-700 flex items-center gap-1"><Check size={12} /> saved</span>} + <Button size="sm" onClick={save} disabled={saving || disabled}> + {saving ? "saving…" : "save providers"} + </Button> + </div> + + </div> + ) +} + +function Labeled({ label, children, className }: { label: string; children: ReactNode; className?: string }) { + return ( + <label className={`flex flex-col gap-1 ${className ?? ""}`}> + <span className="text-[11px] font-medium text-gray-500">{label}</span> + {children} + </label> + ) +} + + +// ──────────────────────────────────────────────────────────────────────────── +// Shell +// ──────────────────────────────────────────────────────────────────────────── + +const SHELL_PRESETS: Array<{ value: string; label: string; description: string }> = [ + { value: "", label: "Default (bash)", description: "POSIX-guaranteed; works everywhere" }, + { value: "fish", label: "fish", description: "Friendly interactive shell with autosuggestions" }, + { value: "zsh", label: "zsh", description: "Common default on macOS-style setups" }, +] + +function ApiTokensSection() { + const [tokens, setTokens] = useState<ApiTokenEntry[]>([]) + const [loading, setLoading] = useState(true) + const [label, setLabel] = useState("") + const [creating, setCreating] = useState(false) + const [newToken, setNewToken] = useState<string | null>(null) + const [copied, setCopied] = useState(false) + const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null) + const [error, setError] = useState<string | null>(null) + + const refresh = useCallback(async () => { + setLoading(true) + setError(null) + const list = await listApiTokens() + setTokens(list) + setLoading(false) + }, []) + + useEffect(() => { refresh() }, [refresh]) + + const handleCreate = async () => { + if (creating) return + setCreating(true) + setError(null) + const result = await createApiToken(label.trim() || "default") + setCreating(false) + if (result) { + setNewToken(result.token) + setLabel("") + setCopied(false) + refresh() + } else { + setError("Failed to create token") + } + } + + const handleRevoke = async (tokenId: string) => { + setError(null) + const ok = await revokeApiToken(tokenId) + setDeleteConfirm(null) + if (ok) refresh() + else setError("Failed to revoke token") + } + + const handleCopy = () => { + if (!newToken) return + navigator.clipboard.writeText(newToken).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + return ( + <div className="space-y-4"> + {/* Error banner */} + {error && ( + <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[12px] text-red-700 flex items-center gap-2"> + <AlertCircle size={14} /> {error} + </div> + )} + + {/* new token reveal banner */} + {newToken && ( + <div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4"> + <div className="flex items-center gap-2 mb-2"> + <Check size={14} className="text-emerald-600" /> + <span className="text-[13px] font-medium text-emerald-900">Token created — copy it now, it won't be shown again</span> + </div> + <div className="flex items-center gap-2"> + <code className="flex-1 text-[12px] bg-white border border-emerald-200 rounded px-3 py-2 font-mono text-gray-800 select-all break-all"> + {newToken} + </code> + <button + type="button" + onClick={handleCopy} + className="shrink-0 h-8 px-3 rounded text-xs border border-emerald-300 hover:bg-emerald-100 text-emerald-800 flex items-center gap-1.5" + > + {copied ? <Check size={12} /> : <Copy size={12} />} + {copied ? "copied" : "copy"} + </button> + </div> + <button + type="button" + onClick={() => setNewToken(null)} + className="mt-2 text-[11px] text-emerald-700 hover:text-emerald-900 underline" + > + dismiss + </button> + </div> + )} + + {/* docs link */} + <div className="rounded-lg border border-gray-200 bg-white px-4 py-3 flex items-center justify-between"> + <div> + <h3 className="text-[13px] font-medium text-gray-900">Loop API documentation</h3> + <p className="text-[11px] text-gray-500 mt-0.5"> + Interactive reference for the endpoints these tokens authenticate against. + </p> + </div> + <a + href="/api/v1/docs" + target="_blank" + rel="noopener noreferrer" + className="shrink-0 inline-flex items-center gap-1.5 h-8 px-3 rounded text-[12px] border border-gray-300 hover:bg-gray-50 text-gray-700" + > + Open docs → + </a> + </div> + + {/* token list */} + <div className="rounded-lg border border-gray-200 bg-white"> + <div className="px-4 py-3 border-b border-gray-100"> + <h3 className="text-[13px] font-medium text-gray-900">Your tokens</h3> + <p className="text-[11px] text-gray-500 mt-0.5"> + External programs use these tokens to call loopat under your identity — your providers, API keys, and vault. + </p> + </div> + + {loading ? ( + <div className="px-4 py-8 text-center text-[12px] text-gray-400 italic">loading…</div> + ) : tokens.length === 0 ? ( + <div className="px-4 py-8 text-center text-[12px] text-gray-400"> + No API tokens yet. Create one below. + </div> + ) : ( + <table className="w-full text-[13px]"> + <thead> + <tr className="border-b border-gray-100 text-left text-[11px] text-gray-500 uppercase tracking-wider"> + <th className="px-4 py-2 font-medium">Label</th> + <th className="px-4 py-2 font-medium">Token</th> + <th className="px-4 py-2 font-medium">Created</th> + <th className="px-4 py-2 font-medium w-10"></th> + </tr> + </thead> + <tbody> + {tokens.map((t) => ( + <tr key={t.tokenId} className="border-b border-gray-50 hover:bg-gray-50"> + <td className="px-4 py-2.5 text-gray-900">{t.label}</td> + <td className="px-4 py-2.5 font-mono text-[12px] text-gray-500">{t.tokenId}</td> + <td className="px-4 py-2.5 text-gray-500 text-[12px]">{new Date(t.createdAt).toLocaleDateString()}</td> + <td className="px-4 py-2.5"> + {deleteConfirm === t.tokenId ? ( + <div className="flex items-center gap-1"> + <button + type="button" + onClick={() => handleRevoke(t.tokenId)} + className="text-[11px] text-red-600 hover:text-red-800 font-medium" + > + confirm + </button> + <button + type="button" + onClick={() => setDeleteConfirm(null)} + className="text-[11px] text-gray-400 hover:text-gray-600" + > + cancel + </button> + </div> + ) : ( + <button + type="button" + onClick={() => setDeleteConfirm(t.tokenId)} + className="w-7 h-7 flex items-center justify-center rounded text-gray-400 hover:text-red-600 hover:bg-red-50" + title="revoke token" + > + <Trash2 size={13} /> + </button> + )} + </td> + </tr> + ))} + </tbody> + </table> + )} + </div> + + {/* create form */} + <div className="rounded-lg border border-gray-200 bg-white p-4"> + <h3 className="text-[13px] font-medium text-gray-900 mb-2">Create new token</h3> + <div className="flex items-center gap-2"> + <input + value={label} + onChange={(e) => setLabel(e.target.value)} + placeholder="label (e.g. dingtalk-bot)" + className="flex-1 px-2.5 py-1.5 border border-gray-300 rounded text-[13px] outline-none bg-white focus:border-gray-900 focus:ring-1 focus:ring-gray-900" + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + /> + <button + type="button" + onClick={handleCreate} + disabled={creating} + className="h-8 px-3 rounded text-xs bg-gray-900 hover:bg-gray-800 text-white flex items-center gap-1.5 disabled:opacity-50" + > + <Plus size={12} /> + {creating ? "creating…" : "create token"} + </button> + </div> + </div> + + {/* usage hint */} + <div className="rounded-lg border border-gray-100 bg-gray-50 px-4 py-3"> + <p className="text-[11px] text-gray-500 leading-relaxed"> + <strong className="text-gray-700">Usage:</strong> pass the token as{" "} + <code className="bg-white border border-gray-200 rounded px-1 py-0.5 text-[10px]">Authorization: Bearer &lt;token&gt;</code>{" "} + when calling{" "} + <code className="bg-white border border-gray-200 rounded px-1 py-0.5 text-[10px]">POST /api/runtime/v1/turn/stream</code>. + The request will run under your identity with your configured providers and API keys. + </p> + </div> + + <A2ASection /> + </div> + ) +} + diff --git a/web/src/pages/SharePage.tsx b/web/src/pages/SharePage.tsx new file mode 100644 index 00000000..5dee7c13 --- /dev/null +++ b/web/src/pages/SharePage.tsx @@ -0,0 +1,97 @@ +/** + * Read-only public view of a loop. Rendered by LoopPage when the visitor is + * anonymous on /loop/:id, in which case Shell drops its chrome (no tabs, no + * login button) and this page renders only the loop title + chat thread. + * + * Server-side, GET /api/loops/:id and /ws/loop/:id allow anonymous reads only + * when meta.public === true. If the loop isn't public and the visitor isn't + * logged in, the API returns 401 — this page renders an unavailable notice. + */ +import { useEffect, useState } from "react" +import { useParams } from "react-router-dom" +import { AssistantRuntimeProvider } from "@assistant-ui/react" +import ChatInterface from "@/components/chat/ChatInterface" +import { useLoopRuntime, LoopRuntimeProvider } from "../useLoopRuntime" +import { getLoopMeta, type LoopMeta } from "../api" + +export function SharePage() { + const { id } = useParams<{ id: string }>() + const [meta, setMeta] = useState<LoopMeta | null | "unauthorized">(null) + + useEffect(() => { + if (!id) return + let cancelled = false + ;(async () => { + const m = await getLoopMeta(id) + if (cancelled) return + setMeta(m ?? "unauthorized") + })() + return () => { + cancelled = true + } + }, [id]) + + useEffect(() => { + if (meta && typeof meta === "object") document.title = `${meta.title} · shared` + }, [meta]) + + if (!id) return <Unavailable reason="missing loop id" /> + if (meta === null) { + return <Loading /> + } + if (meta === "unauthorized") { + return <Unavailable reason="this loop is private or does not exist" /> + } + + return <SharedLoop meta={meta} /> +} + +function SharedLoop({ meta }: { meta: LoopMeta }) { + const { runtime, connected, reconnecting, extra } = useLoopRuntime(meta.id, "") + return ( + <div className="h-full w-full flex flex-col bg-white text-gray-900"> + <header className="h-12 shrink-0 border-b border-gray-200 bg-white flex items-center px-3 md:px-5 gap-2"> + <span className="text-lg leading-none">🧶</span> + <span className="text-[14px] font-medium text-gray-900 truncate">{meta.title}</span> + <span className="text-[11px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-800 border border-emerald-200"> + view only + </span> + <div className="flex-1" /> + <span + className={ + "text-[11px] " + + (connected ? "text-emerald-600" : reconnecting ? "text-amber-500" : "text-red-500") + } + title={connected ? "connected" : reconnecting ? "reconnecting…" : "disconnected"} + > + ● + </span> + </header> + <main className="flex-1 min-h-0"> + <LoopRuntimeProvider extra={extra}> + <AssistantRuntimeProvider runtime={runtime}> + <ChatInterface readOnly /> + </AssistantRuntimeProvider> + </LoopRuntimeProvider> + </main> + </div> + ) +} + +function Loading() { + return ( + <div className="h-full w-full flex items-center justify-center text-gray-400 text-sm"> + loading… + </div> + ) +} + +function Unavailable({ reason }: { reason: string }) { + return ( + <div className="h-full w-full flex flex-col items-center justify-center text-gray-500 gap-2 px-4 text-center"> + <span className="text-lg">🧶</span> + <div className="text-sm">Loop unavailable</div> + <div className="text-xs text-gray-400">{reason}</div> + </div> + ) +} diff --git a/web/src/pages/TokenUsagePage.tsx b/web/src/pages/TokenUsagePage.tsx new file mode 100644 index 00000000..6eb7d782 --- /dev/null +++ b/web/src/pages/TokenUsagePage.tsx @@ -0,0 +1,572 @@ +import { useState, useEffect, useMemo } from "react" +import { useNavigate } from "react-router-dom" +import { BarChart3, Hash, MessageSquare, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react" +import { getDailyTokenUsage, getLoopTokenUsage, type DailyUsage, type LoopTokenUsage } from "@/api" + +type ViewMode = "models" | "daily" | "loops" +type TimeRange = "today" | "7d" | "30d" | "all" +type SortDir = "asc" | "desc" | null + +function formatTokens(n: number): string { + if (n < 1000) return String(n) + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k` + return `${(n / 1_000_000).toFixed(1)}M` +} + +function fmtDate(d: string) { + return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric" }) +} + +function fmtDateFull(d: string) { + return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +} + +function daysAgo(n: number): string { + const d = new Date() + d.setDate(d.getDate() - n) + return d.toISOString().slice(0, 10) +} + +function nextSortDir(current: SortDir): SortDir { + if (!current) return "desc" + if (current === "desc") return "asc" + return null +} + +function SortableTh({ label, field, sortField, sortDir, onSort, className }: { + label: string + field: string + sortField: string | null + sortDir: SortDir + onSort: (f: string) => void + className?: string +}) { + const active = sortField === field + return ( + <th + className={`px-4 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider cursor-pointer select-none hover:text-gray-700 transition-colors ${className ?? ""}`} + onClick={() => onSort(field)} + > + <span className="inline-flex items-center gap-1"> + {label} + {active && sortDir === "desc" ? <ArrowDown className="h-3 w-3" /> + : active && sortDir === "asc" ? <ArrowUp className="h-3 w-3" /> + : <ArrowUpDown className="h-3 w-3 opacity-30" />} + </span> + </th> + ) +} + +// ── Simple SVG horizontal bar chart ── + +function HBarChart({ data, maxVal, height = 24, gap = 6 }: { + data: { label: string; input: number; output: number }[] + maxVal: number + height?: number + gap?: number +}) { + const w = 280 + const totalH = data.length * (height + gap) + return ( + <svg width={w} height={totalH} className="shrink-0"> + {data.map((d, i) => { + const y = i * (height + gap) + const inputW = maxVal > 0 ? (d.input / maxVal) * w : 0 + const outputW = maxVal > 0 ? (d.output / maxVal) * w : 0 + return ( + <g key={d.label}> + <rect x={0} y={y} width={inputW} height={height} rx={3} fill="var(--color-blue-500)" opacity={0.7} /> + <rect x={inputW} y={y} width={outputW} height={height} rx={3} fill="var(--color-green-500)" opacity={0.7} /> + <text x={inputW + outputW + 4} y={y + height / 2} dominantBaseline="central" className="fill-gray-500 text-[10px]">{d.label}</text> + </g> + ) + })} + </svg> + ) +} + +// ── Simple SVG area chart ── + +function AreaChart({ data, w = 600, h = 180 }: { + data: { date: string; input: number; output: number }[] + w?: number + h?: number +}) { + const pad = { top: 10, right: 10, bottom: 24, left: 50 } + const pw = w - pad.left - pad.right + const ph = h - pad.top - pad.bottom + const maxVal = Math.max(1, ...data.map(d => d.input + d.output)) + + const xScale = (i: number) => pad.left + (data.length > 1 ? (i / (data.length - 1)) * pw : pw / 2) + const yScale = (v: number) => pad.top + ph - (v / maxVal) * ph + + const inputPoints = data.map((d, i) => `${xScale(i)},${yScale(d.input)}`).join(" ") + const totalPoints = data.map((d, i) => `${xScale(i)},${yScale(d.input + d.output)}`).join(" ") + const bottomLine = `${xScale(data.length - 1)},${pad.top + ph} ${xScale(0)},${pad.top + ph}` + + // Y axis ticks + const yTicks = [0, maxVal / 2, maxVal].map(v => ({ + y: yScale(v), + label: formatTokens(v), + })) + + return ( + <svg width={w} height={h} className="w-full" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="xMidYMid meet"> + {/* Grid lines */} + {yTicks.map(t => ( + <g key={t.label}> + <line x1={pad.left} y1={t.y} x2={w - pad.right} y2={t.y} stroke="var(--color-gray-200)" strokeDasharray="3,2" /> + <text x={pad.left - 6} y={t.y} textAnchor="end" dominantBaseline="central" className="fill-gray-400 text-[9px]">{t.label}</text> + </g> + ))} + {/* Area: output (stacked on top of input) */} + <polygon points={`${totalPoints} ${bottomLine}`} fill="var(--color-green-500)" opacity={0.2} /> + <polyline points={totalPoints} fill="none" stroke="var(--color-green-500)" strokeWidth={1.5} opacity={0.6} /> + {/* Area: input only */} + <polygon points={`${inputPoints} ${bottomLine}`} fill="var(--color-blue-500)" opacity={0.2} /> + <polyline points={inputPoints} fill="none" stroke="var(--color-blue-500)" strokeWidth={1.5} /> + {/* Date labels */} + {data.filter((_, i) => data.length <= 14 || i % Math.ceil(data.length / 14) === 0 || i === data.length - 1).map((d, i) => { + const origIdx = data.indexOf(d) + return ( + <text key={d.date} x={xScale(origIdx)} y={h - 4} textAnchor="middle" className="fill-gray-400 text-[9px]"> + {fmtDate(d.date)} + </text> + ) + })} + </svg> + ) +} + +// ── Model summary view ── + +function ModelsView({ dailyUsage, timeRange }: { dailyUsage: DailyUsage; timeRange: TimeRange }) { + const [sortField, setSortField] = useState<string | null>("total") + const [sortDir, setSortDir] = useState<SortDir>("desc") + const [showAll, setShowAll] = useState(false) + const LIMIT = 10 + + const rawEntries = useMemo(() => { + const cutoff = timeRange === "today" ? daysAgo(0) + : timeRange === "7d" ? daysAgo(7) + : timeRange === "30d" ? daysAgo(30) + : "" + const modelMap: Record<string, { inputTokens: number; outputTokens: number; cacheReadInputTokens: number }> = {} + for (const [model, dates] of Object.entries(dailyUsage)) { + for (const [date, u] of Object.entries(dates)) { + if (cutoff && date < cutoff) continue + const entry = modelMap[model] ?? { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 } + entry.inputTokens += u.inputTokens + entry.outputTokens += u.outputTokens + entry.cacheReadInputTokens += u.cacheReadInputTokens ?? 0 + modelMap[model] = entry + } + } + return Object.entries(modelMap) + .map(([model, u]) => ({ model, ...u, total: u.inputTokens + u.outputTokens, cacheRate: (u.inputTokens + u.cacheReadInputTokens) > 0 ? Math.round(u.cacheReadInputTokens / (u.inputTokens + u.cacheReadInputTokens) * 100) : 0 })) + }, [dailyUsage, timeRange]) + + const entries = useMemo(() => { + const sorted = [...rawEntries] + if (sortField && sortDir) { + sorted.sort((a: any, b: any) => { + const va = sortField === "model" ? a.model : a[sortField] ?? 0 + const vb = sortField === "model" ? b.model : b[sortField] ?? 0 + if (typeof va === "string" && typeof vb === "string") return sortDir === "desc" ? vb.localeCompare(va) : va.localeCompare(vb) + return sortDir === "desc" ? (vb as number) - (va as number) : (va as number) - (vb as number) + }) + } + return sorted + }, [rawEntries, sortField, sortDir]) + + const handleSort = (f: string) => { + if (sortField === f) { + const next = nextSortDir(sortDir) + if (!next) { setSortField(null); setSortDir(null) } + else setSortDir(next) + } else { + setSortField(f) + setSortDir("desc") + } + } + + const grandTotal = entries.reduce((s, e) => s + e.total, 0) + const maxTotal = Math.max(...entries.map(e => e.total), 1) + const displayEntries = showAll ? entries : entries.slice(0, LIMIT) + const chartData = displayEntries.map(e => ({ label: e.model, input: e.inputTokens, output: e.outputTokens })) + + return ( + <div> + {/* Summary cards */} + <div className="grid grid-cols-4 gap-3 mb-6"> + <div className="bg-white border border-gray-200 rounded-lg p-4"> + <div className="text-[11px] text-gray-500 mb-1">Total Tokens</div> + <div className="text-xl font-semibold text-gray-900">{formatTokens(grandTotal)}</div> + </div> + <div className="bg-white border border-gray-200 rounded-lg p-4"> + <div className="text-[11px] text-gray-500 mb-1">Prompt</div> + <div className="text-xl font-semibold text-blue-600">{formatTokens(entries.reduce((s, e) => s + e.inputTokens, 0))}</div> + </div> + <div className="bg-white border border-gray-200 rounded-lg p-4"> + <div className="text-[11px] text-gray-500 mb-1">Completion</div> + <div className="text-xl font-semibold text-green-600">{formatTokens(entries.reduce((s, e) => s + e.outputTokens, 0))}</div> + </div> + <div className="bg-white border border-gray-200 rounded-lg p-4"> + <div className="text-[11px] text-gray-500 mb-1">Cached Tokens</div> + <div className="text-xl font-semibold text-purple-600">{formatTokens(entries.reduce((s, e) => s + (e as any).cacheReadInputTokens, 0))}</div> + <div className="text-[11px] text-purple-400 mt-0.5">{(() => { + const totalCache = entries.reduce((s, e) => s + (e as any).cacheReadInputTokens, 0) + const totalInput = entries.reduce((s, e) => s + e.inputTokens, 0) + return (totalInput + totalCache) > 0 ? `${Math.round(totalCache / (totalInput + totalCache) * 100)}% of potential input` : "—" + })()}</div> + </div> + </div> + + {/* Bar chart + legend */} + {displayEntries.length > 0 && ( + <div className="bg-white border border-gray-200 rounded-lg p-4 mb-4"> + <div className="flex items-center gap-4 mb-3"> + <span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wider">Token Distribution</span> + <span className="flex items-center gap-1 text-[10px] text-gray-500"><span className="w-2.5 h-2.5 rounded-sm bg-blue-500 opacity-70" /> Input</span> + <span className="flex items-center gap-1 text-[10px] text-gray-500"><span className="w-2.5 h-2.5 rounded-sm bg-green-500 opacity-70" /> Output</span> + </div> + <div className="overflow-x-auto"> + <HBarChart data={chartData} maxVal={maxTotal} /> + </div> + </div> + )} + + {/* Model table */} + <div className="bg-white border border-gray-200 rounded-lg overflow-hidden"> + <table className="w-full"> + <thead> + <tr className="border-b border-gray-200 bg-gray-50/50"> + <SortableTh label="Model" field="model" sortField={sortField} sortDir={sortDir} onSort={handleSort} className="text-left" /> + <SortableTh label="Input" field="inputTokens" sortField={sortField} sortDir={sortDir} onSort={handleSort} className="text-right" /> + <SortableTh label="Output" field="outputTokens" sortField={sortField} sortDir={sortDir} onSort={handleSort} className="text-right" /> + <SortableTh label="Total" field="total" sortField={sortField} sortDir={sortDir} onSort={handleSort} className="text-right" /> + <SortableTh label="Cached" field="cacheRate" sortField={sortField} sortDir={sortDir} onSort={handleSort} className="text-right" /> + <th className="text-right px-4 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider">%</th> + </tr> + </thead> + <tbody> + {displayEntries.map((e) => ( + <tr key={e.model} className="border-b border-gray-100 hover:bg-gray-50/50 transition-colors"> + <td className="px-4 py-2.5"> + <code className="text-[12px] text-gray-800">{e.model}</code> + </td> + <td className="px-4 py-2.5 text-right text-[12px] text-blue-600 tabular-nums">{formatTokens(e.inputTokens)}</td> + <td className="px-4 py-2.5 text-right text-[12px] text-green-600 tabular-nums">{formatTokens(e.outputTokens)}</td> + <td className="px-4 py-2.5 text-right text-[12px] text-gray-900 font-medium tabular-nums">{formatTokens(e.total)}</td> + <td className="px-4 py-2.5 text-right text-[12px] text-purple-600 tabular-nums">{formatTokens((e as any).cacheReadInputTokens)} <span className="text-[10px] text-purple-400">({(e as any).cacheRate}%)</span></td> + <td className="px-4 py-2.5 text-right text-[11px] text-gray-400 tabular-nums"> + {grandTotal > 0 ? ((e.total / grandTotal) * 100).toFixed(1) : 0}% + </td> + </tr> + ))} + {displayEntries.length === 0 && ( + <tr> + <td colSpan={6} className="px-4 py-8 text-center text-[12px] text-gray-400 italic">No token usage data</td> + </tr> + )} + </tbody> + </table> + {entries.length > LIMIT && ( + <div className="px-4 py-2 border-t border-gray-100 bg-gray-50/50"> + <button + onClick={() => setShowAll(!showAll)} + className="text-[11px] text-gray-500 hover:text-gray-900 transition-colors" + > + {showAll ? "Show less" : `Show all ${entries.length} models`} + </button> + </div> + )} + </div> + </div> + ) +} + +// ── Daily breakdown view ── + +function DailyView({ dailyUsage, timeRange }: { dailyUsage: DailyUsage; timeRange: TimeRange }) { + const { dates, models, data, chartData } = useMemo(() => { + const modelSet = new Set(Object.keys(dailyUsage)) + const dateSet = new Set<string>() + for (const m of Object.keys(dailyUsage)) { + for (const d of Object.keys(dailyUsage[m])) dateSet.add(d) + } + const sortedDates = Array.from(dateSet).sort() + const sortedModels = Array.from(modelSet).sort() + + const cutoff = timeRange === "today" ? daysAgo(0) + : timeRange === "7d" ? daysAgo(7) + : timeRange === "30d" ? daysAgo(30) + : "" + const filteredDates = cutoff ? sortedDates.filter(d => d >= cutoff) : sortedDates + + const data: Record<string, Record<string, number>> = {} + for (const model of sortedModels) { + data[model] = {} + for (const date of filteredDates) { + data[model][date] = (dailyUsage[model]?.[date]?.inputTokens ?? 0) + (dailyUsage[model]?.[date]?.outputTokens ?? 0) + } + } + + const chartData = filteredDates.map(date => { + let input = 0; let output = 0 + for (const m of sortedModels) { + input += dailyUsage[m]?.[date]?.inputTokens ?? 0 + output += dailyUsage[m]?.[date]?.outputTokens ?? 0 + } + return { date, input, output } + }) + + return { dates: filteredDates, models: sortedModels, data, chartData } + }, [dailyUsage, timeRange]) + + if (dates.length === 0) { + return <div className="text-center py-8 text-[12px] text-gray-400 italic">No daily data for this time range</div> + } + + return ( + <div> + {/* Area chart */} + <div className="bg-white border border-gray-200 rounded-lg p-4 mb-4"> + <div className="flex items-center gap-4 mb-2"> + <span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wider">Daily Trend</span> + <span className="flex items-center gap-1 text-[10px] text-gray-500"><span className="w-2.5 h-2.5 rounded-sm bg-blue-500 opacity-70" /> Input</span> + <span className="flex items-center gap-1 text-[10px] text-gray-500"><span className="w-2.5 h-2.5 rounded-sm bg-green-500 opacity-20" /> Output</span> + </div> + <AreaChart data={chartData} /> + </div> + + {/* Pivot table */} + <div className="bg-white border border-gray-200 rounded-lg overflow-hidden"> + <div className="overflow-x-auto"> + <table className="w-full min-w-[500px]"> + <thead> + <tr className="border-b border-gray-200 bg-gray-50/50"> + <th className="text-left px-4 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider sticky left-0 bg-gray-50/50">Date</th> + <th className="text-right px-4 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider">Total</th> + {models.map(m => ( + <th key={m} className="text-right px-3 py-2.5 text-[10px] font-medium text-gray-400 uppercase tracking-wider max-w-[120px]"> + <span className="truncate block">{m}</span> + </th> + ))} + </tr> + </thead> + <tbody> + {dates.map(d => ( + <tr key={d} className="border-b border-gray-100 hover:bg-gray-50/50 transition-colors"> + <td className="px-4 py-2 text-[12px] text-gray-700 sticky left-0 bg-white">{fmtDate(d)}</td> + <td className="px-4 py-2 text-right text-[12px] text-gray-900 font-medium tabular-nums"> + {formatTokens(models.reduce((s, m) => s + (data[m]?.[d] ?? 0), 0))} + </td> + {models.map(m => ( + <td key={m} className="px-3 py-2 text-right text-[11px] text-gray-500 tabular-nums"> + {data[m]?.[d] ? formatTokens(data[m][d]) : ""} + </td> + ))} + </tr> + ))} + </tbody> + </table> + </div> + </div> + </div> + ) +} + +// ── Per-loop view ── + +function LoopsView({ loopUsage, timeRange }: { loopUsage: LoopTokenUsage[]; timeRange: TimeRange }) { + const [sortField, setSortField] = useState<string | null>("lastActivity") + const [sortDir, setSortDir] = useState<SortDir>("desc") + + const cutoff = timeRange === "today" ? daysAgo(0) + : timeRange === "7d" ? daysAgo(7) + : timeRange === "30d" ? daysAgo(30) + : "" + const filtered = useMemo(() => { + let list = cutoff + ? loopUsage.filter(l => (l.lastActivity ?? "").slice(0, 10) >= cutoff) + : [...loopUsage] + if (sortField && sortDir) { + list.sort((a: any, b: any) => { + let va: any, vb: any + if (sortField === "total") { + va = a.inputTokens + a.outputTokens + vb = b.inputTokens + b.outputTokens + } else if (sortField === "title" || sortField === "lastActivity") { + va = a[sortField] ?? "" + vb = b[sortField] ?? "" + } else { + va = a[sortField] ?? 0 + vb = b[sortField] ?? 0 + } + if (typeof va === "string" && typeof vb === "string") return sortDir === "desc" ? vb.localeCompare(va) : va.localeCompare(vb) + return sortDir === "desc" ? (vb as number) - (va as number) : (va as number) - (vb as number) + }) + } + return list + }, [loopUsage, cutoff, sortField, sortDir]) + + const handleSort = (f: string) => { + if (sortField === f) { + const next = nextSortDir(sortDir) + if (!next) { setSortField(null); setSortDir(null) } + else setSortDir(next) + } else { + setSortField(f) + setSortDir("desc") + } + } + + const grandTotal = filtered.reduce((s, l) => s + l.inputTokens + l.outputTokens, 0) + const navigate = useNavigate() + + return ( + <div> + {/* Summary row */} + <div className="flex items-center gap-4 mb-4 px-1"> + <span className="text-[13px] text-gray-600"> + <span className="font-semibold text-gray-900">{filtered.length}</span> loops ·{" "} + <span className="font-semibold text-gray-900">{formatTokens(grandTotal)}</span> total tokens + </span> + </div> + + <div className="bg-white border border-gray-200 rounded-lg overflow-hidden"> + <table className="w-full"> + <thead> + <tr className="border-b border-gray-200 bg-gray-50/50"> + <SortableTh label="Loop" field="title" sortField={sortField} sortDir={sortDir} onSort={handleSort} className="text-left" /> + <th className="hidden sm:table-cell text-left px-4 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider">Models</th> + <SortableTh label="Input" field="inputTokens" sortField={sortField} sortDir={sortDir} onSort={handleSort} className="text-right" /> + <SortableTh label="Output" field="outputTokens" sortField={sortField} sortDir={sortDir} onSort={handleSort} className="text-right" /> + <SortableTh label="Total" field="total" sortField={sortField} sortDir={sortDir} onSort={handleSort} className="text-right" /> + <th className="text-right px-4 py-2.5 text-[11px] font-semibold text-gray-500 uppercase tracking-wider">Cached</th> + <SortableTh label="Last active" field="lastActivity" sortField={sortField} sortDir={sortDir} onSort={handleSort} className="text-right hidden sm:table-cell" /> + </tr> + </thead> + <tbody> + {filtered.map((l) => { + const total = l.inputTokens + l.outputTokens + const cacheRate = (l.inputTokens + l.cacheReadInputTokens) > 0 ? Math.round(l.cacheReadInputTokens / (l.inputTokens + l.cacheReadInputTokens) * 100) : 0 + return ( + <tr + key={l.loopId} + className="border-b border-gray-100 hover:bg-gray-50/50 transition-colors cursor-pointer" + onClick={() => navigate(`/loop/${l.loopId}`)} + > + <td className="px-4 py-2.5"> + <span className="text-[12px] text-gray-800 font-medium hover:text-blue-600">{l.title}</span> + </td> + <td className="hidden sm:table-cell px-4 py-2.5"> + <code className="text-[11px] text-gray-500">{l.model}</code> + </td> + <td className="px-4 py-2.5 text-right text-[12px] text-blue-600 tabular-nums">{formatTokens(l.inputTokens)}</td> + <td className="px-4 py-2.5 text-right text-[12px] text-green-600 tabular-nums">{formatTokens(l.outputTokens)}</td> + <td className="px-4 py-2.5 text-right text-[12px] text-gray-900 font-medium tabular-nums">{formatTokens(total)}</td> + <td className="px-4 py-2.5 text-right text-[12px] text-purple-600 tabular-nums">{formatTokens(l.cacheReadInputTokens)} <span className="text-[10px] text-purple-400">({cacheRate}%)</span></td> + <td className="hidden sm:table-cell px-4 py-2.5 text-right text-[11px] text-gray-400">{fmtDateFull(l.lastActivity)}</td> + </tr> + ) + })} + {filtered.length === 0 && ( + <tr> + <td colSpan={6} className="px-4 py-8 text-center text-[12px] text-gray-400 italic"> + No loops with token usage{cutoff ? " in this range" : ""} + </td> + </tr> + )} + </tbody> + </table> + </div> + </div> + ) +} + +// ── Page ── + +const TIME_RANGES: { id: TimeRange; label: string }[] = [ + { id: "today", label: "Today" }, + { id: "7d", label: "7 days" }, + { id: "30d", label: "30 days" }, + { id: "all", label: "All time" }, +] + +const VIEWS: { id: ViewMode; label: string; icon: React.ReactNode }[] = [ + { id: "models", label: "By Model", icon: <Hash className="h-3.5 w-3.5" /> }, + { id: "daily", label: "Daily", icon: <BarChart3 className="h-3.5 w-3.5" /> }, + { id: "loops", label: "By Loop", icon: <MessageSquare className="h-3.5 w-3.5" /> }, +] + +export function TokenUsagePage() { + const navigate = useNavigate() + const [view, setView] = useState<ViewMode>("models") + const [timeRange, setTimeRange] = useState<TimeRange>("30d") + const [dailyUsage, setDailyUsage] = useState<DailyUsage>({}) + const [loopUsage, setLoopUsage] = useState<LoopTokenUsage[]>([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + setLoading(true) + Promise.all([ + getDailyTokenUsage().then(setDailyUsage), + getLoopTokenUsage().then(setLoopUsage), + ]).finally(() => setLoading(false)) + }, []) + + if (loading) { + return ( + <div className="h-full flex items-center justify-center bg-gray-50"> + <div className="text-[13px] text-gray-400">loading token data…</div> + </div> + ) + } + + return ( + <div> + {/* View tabs + time filter */} + <div className="flex items-center gap-1 mb-6"> + {VIEWS.map(v => ( + <button + key={v.id} + type="button" + onClick={() => setView(v.id)} + className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded text-[12px] font-medium transition-colors ${ + view === v.id + ? "bg-white border border-gray-200 text-gray-900 shadow-sm" + : "text-gray-500 hover:text-gray-700" + }`} + > + {v.icon} + {v.label} + </button> + ))} + <div className="flex-1" /> + <div className="flex items-center gap-0.5"> + {TIME_RANGES.map(tr => ( + <button + key={tr.id} + type="button" + onClick={() => setTimeRange(tr.id)} + className={`px-2 py-1 rounded text-[11px] font-medium transition-colors ${ + timeRange === tr.id + ? "bg-white border border-gray-200 text-gray-900 shadow-sm" + : "text-gray-500 hover:text-gray-700" + }`} + > + {tr.label} + </button> + ))} + </div> + </div> + + {/* Content */} + {view === "models" && <ModelsView dailyUsage={dailyUsage} timeRange={timeRange} />} + {view === "daily" && <DailyView dailyUsage={dailyUsage} timeRange={timeRange} />} + {view === "loops" && <LoopsView loopUsage={loopUsage} timeRange={timeRange} />} + </div> + ) +} diff --git a/web/src/pages/TopicView.tsx b/web/src/pages/TopicView.tsx new file mode 100644 index 00000000..cc2ba2ad --- /dev/null +++ b/web/src/pages/TopicView.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react" +import { useNavigate, useParams } from "react-router-dom" +import { listTopics, type TopicAggregate } from "../api" +import { TopicChip } from "../components/TopicChip" + +export function TopicView() { + const { name } = useParams<{ name: string }>() + const decoded = decodeURIComponent(name ?? "") + const navigate = useNavigate() + const [topic, setTopic] = useState<TopicAggregate | null>(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + listTopics().then((all) => { + if (cancelled) return + const found = all.find((t) => t.name === decoded.toLowerCase()) ?? null + setTopic(found) + setLoading(false) + }) + return () => { cancelled = true } + }, [decoded]) + + return ( + <div className="flex flex-col h-full w-full bg-white"> + <header className="h-10 shrink-0 flex items-center gap-3 px-6 border-b border-gray-200"> + <button + type="button" + onClick={() => navigate(-1)} + className="text-[11px] text-gray-500 hover:text-gray-900" + > + ← back + </button> + <TopicChip name={decoded} size="md" /> + <div className="flex-1" /> + </header> + + <main className="flex-1 min-w-0 flex flex-col overflow-auto"> + <div className="px-4 md:px-8 py-4 md:py-8 mx-auto w-full max-w-[760px] flex flex-col gap-5"> + {loading ? ( + <div className="text-[12px] text-gray-400 italic">loading…</div> + ) : !topic || topic.loops.length === 0 ? ( + <div className="text-[12px] text-gray-400 italic"> + no entities tagged with <code>#{decoded}</code> + </div> + ) : ( + <section> + <div className="text-[12px] uppercase tracking-wider text-gray-500 mb-2 px-1"> + Loops ({topic.loops.length}) + </div> + <div className="flex flex-col gap-1"> + {topic.loops.map((l) => ( + <button + key={l.id} + type="button" + onClick={() => navigate(`/loop/${l.id}`)} + className="w-full text-left px-3 py-2 rounded border border-gray-200 hover:border-gray-400 hover:bg-gray-50 text-[13px] text-gray-900" + > + {l.title} + <span className="ml-2 text-[10px] font-mono text-gray-400"> + {l.id.slice(0, 6)} + </span> + </button> + ))} + </div> + </section> + )} + </div> + </main> + </div> + ) +} diff --git a/web/src/state.ts b/web/src/state.ts new file mode 100644 index 00000000..b966f20e --- /dev/null +++ b/web/src/state.ts @@ -0,0 +1,186 @@ +import { useEffect, useState, useCallback } from "react" +import { + listLoops, + createLoop as apiCreateLoop, + setLoopArchived as apiSetLoopArchived, + setLoopPublic as apiSetLoopPublic, + setLoopTitle as apiSetLoopTitle, + requestDrive as apiRequestDrive, + takeDrive as apiTakeDrive, + getMe, + login as apiLogin, + register as apiRegister, + logout as apiLogout, + type LoopMeta, + type User, +} from "./api" + +export type KanbanCreateCtx = { board: string; filename: string; cid: string } + +export type WorkspaceState = { + loops: LoopMeta[] + loopsLoading: boolean + showArchived: boolean + setShowArchived: (b: boolean) => void + refresh: () => Promise<void> + createLoop: (opts: { title: string; repo?: string; profiles?: string[]; vault?: string; knowledgeRw?: boolean; mountAllLoops?: boolean }) => Promise<LoopMeta> + setLoopArchived: (id: string, archived: boolean) => Promise<void> + setLoopPublic: (id: string, isPublic: boolean) => Promise<void> + setLoopTitle: (id: string, title: string) => Promise<LoopMeta | null> + requestDrive: (id: string) => Promise<void> + takeDrive: (id: string) => Promise<void> + newLoopDialogOpen: boolean + newLoopDialogTitle: string + kanbanCreateCtx: KanbanCreateCtx | null + setNewLoopDialogOpen: (open: boolean, title?: string, kanbanCtx?: KanbanCreateCtx) => void + + // auth + currentUser: User | null + authLoading: boolean + login: (username: string, password: string) => Promise<{ error?: string }> + register: (input: { username: string; password: string; personalRepo?: string }) => Promise<{ + error?: string + user?: User + publicKey?: string | null + personalRepo?: string | null + needsImport?: boolean + }> + logout: () => Promise<void> +} + +export function useWorkspaceState(): WorkspaceState { + const [loops, setLoops] = useState<LoopMeta[]>([]) + const [loopsLoading, setLoopsLoading] = useState(true) + const [showArchived, setShowArchived] = useState(false) + const [newLoopDialogOpen, setNewLoopDialogOpenRaw] = useState(false) + const [newLoopDialogTitle, setNewLoopDialogTitle] = useState("") + const [kanbanCreateCtx, setKanbanCreateCtx] = useState<KanbanCreateCtx | null>(null) + const [currentUser, setCurrentUser] = useState<User | null>(null) + const [authLoading, setAuthLoading] = useState(true) + + const refresh = useCallback(async () => { + setLoopsLoading(true) + setLoops(await listLoops(showArchived ? "all" : "active")) + setLoopsLoading(false) + }, [showArchived]) + + // bootstrap: who am I? + useEffect(() => { + let cancelled = false + getMe().then((u) => { + if (cancelled) return + setCurrentUser(u) + setAuthLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + // load loops on mount; re-load when showArchived or auth state changes + useEffect(() => { + refresh() + }, [showArchived, currentUser]) + + const createLoop = useCallback(async (opts: { title: string; repo?: string; profiles?: string[]; vault?: string; knowledgeRw?: boolean; mountAllLoops?: boolean }) => { + const m = await apiCreateLoop(opts) + setLoops((prev) => [m, ...prev]) + return m + }, []) + + const setLoopArchived = useCallback(async (id: string, archived: boolean) => { + const updated = await apiSetLoopArchived(id, archived) + if (!updated) return + setLoops((prev) => { + // If we're not showing archived and we just archived, drop from list. + if (!showArchived && updated.archived) return prev.filter((l) => l.id !== id) + return prev.map((l) => (l.id === id ? updated : l)) + }) + }, [showArchived]) + + const setLoopPublic = useCallback(async (id: string, isPublic: boolean) => { + const updated = await apiSetLoopPublic(id, isPublic) + if (!updated) return + setLoops((prev) => prev.map((l) => (l.id === id ? updated : l))) + }, []) + + const setLoopTitle = useCallback(async (id: string, title: string) => { + const updated = await apiSetLoopTitle(id, title) + if (!updated) return null + setLoops((prev) => prev.map((l) => (l.id === id ? updated : l))) + return updated + }, []) + + const requestDrive = useCallback(async (id: string) => { + const updated = await apiRequestDrive(id) + if (!updated) return + setLoops((prev) => prev.map((l) => (l.id === id ? updated : l))) + }, []) + + const takeDrive = useCallback(async (id: string) => { + const updated = await apiTakeDrive(id) + if (!updated) return + setLoops((prev) => prev.map((l) => (l.id === id ? updated : l))) + }, []) + + const login = useCallback(async (username: string, password: string) => { + const r = await apiLogin(username, password) + if (r.user) setCurrentUser(r.user) + return { error: r.error } + }, []) + + const register = useCallback( + async (input: { username: string; password: string; personalRepo?: string }) => { + const r = await apiRegister(input) + // Only seed currentUser when registration actually established a session. + // Pending accounts (everyone except the very first user) must wait for + // an admin to activate before they can log in. + if (r.user && r.user.status === "active") setCurrentUser(r.user) + return { + error: r.error, + user: r.user, + publicKey: r.publicKey, + personalRepo: r.personalRepo, + needsImport: r.needsImport, + } + }, + [], + ) + + const logout = useCallback(async () => { + await apiLogout() + setCurrentUser(null) + }, []) + + const setNewLoopDialogOpen = useCallback( + (open: boolean, title?: string, kanbanCtx?: KanbanCreateCtx) => { + setNewLoopDialogOpenRaw(open) + setNewLoopDialogTitle(title ?? "") + setKanbanCreateCtx(kanbanCtx ?? null) + }, + [], + ) + + return { + loops, + loopsLoading, + showArchived, + setShowArchived, + refresh, + createLoop, + setLoopArchived, + setLoopPublic, + setLoopTitle, + requestDrive, + takeDrive, + newLoopDialogOpen, + newLoopDialogTitle, + kanbanCreateCtx, + setNewLoopDialogOpen, + currentUser, + authLoading, + login, + register, + logout, + } +} diff --git a/web/src/theme.tsx b/web/src/theme.tsx new file mode 100644 index 00000000..ac89267f --- /dev/null +++ b/web/src/theme.tsx @@ -0,0 +1,63 @@ +import { createContext, useContext, useLayoutEffect, useState, useCallback, type ReactNode } from "react" + +type Theme = "light" | "dark" + +interface ThemeCtxValue { + theme: Theme + toggle: () => void +} + +const ThemeCtx = createContext<ThemeCtxValue>({ theme: "light", toggle: () => {} }) + +export function useTheme() { + return useContext(ThemeCtx) +} + +const STORAGE_KEY = "loopat:theme" + +function getStoredTheme(): Theme { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored === "dark" || stored === "light") return stored + } catch {} + if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) return "dark" + return "light" +} + +function applyTheme(theme: Theme) { + const root = document.documentElement + if (theme === "dark") { + root.classList.add("dark") + } else { + root.classList.remove("dark") + } +} + +// Apply theme synchronously before first paint to avoid flash +applyTheme(getStoredTheme()) + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState<Theme>(getStoredTheme) + + // Sync on mount in case the module-level call raced + useLayoutEffect(() => { + applyTheme(theme) + }, [theme]) + + const toggle = useCallback(() => { + setTheme((prev) => { + const next: Theme = prev === "light" ? "dark" : "light" + applyTheme(next) + try { + localStorage.setItem(STORAGE_KEY, next) + } catch {} + return next + }) + }, []) + + return ( + <ThemeCtx.Provider value={{ theme, toggle }}> + {children} + </ThemeCtx.Provider> + ) +} diff --git a/web/src/useChatUnreadTitle.ts b/web/src/useChatUnreadTitle.ts new file mode 100644 index 00000000..5b22fb9e --- /dev/null +++ b/web/src/useChatUnreadTitle.ts @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from "react" +import { listChatConversations } from "./api" + +/** + * Drive the browser tab title from total chat unread. + * + * no unread: "<workspace> · loopat" + * N unread: "(N) <workspace> · loopat" + * + * Owns document.title fully when `enabled` — App should not also set it. + * + * Why a second /ws/chat connection (vs. reusing ChatPage's): ChatPage isn't + * mounted on /loop/* etc., but the title needs to update everywhere. Two + * subscriptions are cheap; the server fans out by membership, not connection. + * + * Mark-read sync: ChatPage dispatches a "loopat:chat-read" window event + * after calling markChatRead so we can zero the conv immediately without + * waiting for a refetch / focus. + */ +export function useChatUnreadTitle(workspaceName: string, enabled: boolean, me: string) { + const [unreadByConv, setUnreadByConv] = useState<Record<string, number>>({}) + const wsRef = useRef<WebSocket | null>(null) + + useEffect(() => { + if (!enabled) return + let cancelled = false + + const refetch = async () => { + const convs = await listChatConversations() + if (cancelled) return + const next: Record<string, number> = {} + for (const c of convs) next[c.id] = c.unread + setUnreadByConv(next) + } + + const openWs = (convIds: string[]) => { + const proto = location.protocol === "https:" ? "wss:" : "ws:" + const ws = new WebSocket(`${proto}//${location.host}/ws/chat`) + wsRef.current = ws + ws.onopen = () => { + for (const id of convIds) ws.send(JSON.stringify({ type: "subscribe", convId: id })) + } + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) + if (msg.type === "message") { + if (msg.message.author !== me) { + setUnreadByConv((prev) => ({ + ...prev, + [msg.message.convId]: (prev[msg.message.convId] ?? 0) + 1, + })) + } + } else if (msg.type === "conv_created") { + ws.send(JSON.stringify({ type: "subscribe", convId: msg.conv.id })) + } + } catch {} + } + ws.onclose = () => { + if (cancelled) return + // Reconnect after a delay; refetch to recover any drift. + setTimeout(() => { if (!cancelled) bootstrap() }, 3000) + } + ws.onerror = () => { try { ws.close() } catch {} } + } + + const bootstrap = async () => { + const convs = await listChatConversations() + if (cancelled) return + const next: Record<string, number> = {} + for (const c of convs) next[c.id] = c.unread + setUnreadByConv(next) + openWs(convs.map((c) => c.id)) + } + + bootstrap() + + const onMarkRead = (e: Event) => { + const detail = (e as CustomEvent).detail as { convId: string } | undefined + if (!detail?.convId) return + setUnreadByConv((prev) => ( + prev[detail.convId] === 0 ? prev : { ...prev, [detail.convId]: 0 } + )) + } + const onFocus = () => { refetch().catch(() => {}) } + + window.addEventListener("loopat:chat-read", onMarkRead) + window.addEventListener("focus", onFocus) + + return () => { + cancelled = true + window.removeEventListener("loopat:chat-read", onMarkRead) + window.removeEventListener("focus", onFocus) + try { wsRef.current?.close() } catch {} + wsRef.current = null + } + }, [enabled, me]) + + useEffect(() => { + if (!enabled) return + let total = 0 + for (const k in unreadByConv) total += unreadByConv[k] + const suffix = `${workspaceName} · loopat` + document.title = total > 0 ? `(${total}) ${suffix}` : suffix + }, [unreadByConv, workspaceName, enabled]) +} diff --git a/web/src/useChatWebSocket.ts b/web/src/useChatWebSocket.ts new file mode 100644 index 00000000..963d25c1 --- /dev/null +++ b/web/src/useChatWebSocket.ts @@ -0,0 +1,108 @@ +import { useEffect, useRef, useCallback, useState } from "react" +import type { ChatConversation, ChatMessage } from "./api" +import { getVersion, getBuildInfo } from "./api" + +export type ChatWsEvent = + | { type: "message"; message: ChatMessage } + | { type: "conv_created"; conv: ChatConversation } + | { type: "conv_deleted"; convId: string } + +/** + * /ws/chat connection. Client subscribes to specific conversations; server + * fans out messages only to subscribers (and respects DM-party permissions). + * + * Caller passes a stable `onEvent` callback; this hook keeps the latest + * callback in a ref so reconnection doesn't churn. + */ +export function useChatWebSocket(onEvent: (e: ChatWsEvent) => void) { + const wsRef = useRef<WebSocket | null>(null) + const [connected, setConnected] = useState(false) + const onEventRef = useRef(onEvent) + onEventRef.current = onEvent + // Re-subscribe on reconnect — store the current subscription set in a ref + // so connect() can replay it without depending on React state. + const subsRef = useRef<Set<string>>(new Set()) + + const sendIfOpen = useCallback((obj: object) => { + const ws = wsRef.current + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(obj)) + } + }, []) + + const subscribe = useCallback((convId: string) => { + if (subsRef.current.has(convId)) return + subsRef.current.add(convId) + sendIfOpen({ type: "subscribe", convId }) + }, [sendIfOpen]) + + const unsubscribe = useCallback((convId: string) => { + if (!subsRef.current.has(convId)) return + subsRef.current.delete(convId) + sendIfOpen({ type: "unsubscribe", convId }) + }, [sendIfOpen]) + + const connect = useCallback(() => { + const proto = location.protocol === "https:" ? "wss:" : "ws:" + const ws = new WebSocket(`${proto}//${location.host}/ws/chat`) + wsRef.current = ws + + ws.onopen = () => { + setConnected(true) + // Re-subscribe to anything that was previously subscribed + for (const cid of subsRef.current) { + try { ws.send(JSON.stringify({ type: "subscribe", convId: cid })) } catch {} + } + // Check if server version differs from the frontend build + getVersion().then((v) => { + const build = getBuildInfo() + if (v.commit !== "unknown" && build.commit !== "unknown" && v.commit !== build.commit) { + window.dispatchEvent(new CustomEvent("loopat:version-mismatch", { detail: { commit: v.commit } })) + } + }).catch(() => {}) + } + + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) + if (msg.type === "chat_connected") return + if (msg.type === "message" || msg.type === "conv_created" || msg.type === "conv_deleted") { + onEventRef.current(msg as ChatWsEvent) + } + } catch {} + } + + ws.onclose = () => { + setConnected(false) + wsRef.current = null + setTimeout(connect, 2000 + Math.random() * 3000) + } + + ws.onerror = () => { + // onerror fires when the connection fails or close() is called on a + // CONNECTING socket (e.g. StrictMode unmount). Let onclose handle it. + } + }, []) + + useEffect(() => { + connect() + return () => { + const ws = wsRef.current + if (ws) { + if (ws.readyState === WebSocket.CONNECTING) { + ws.onopen = () => ws.close() + ws.onmessage = null + ws.onclose = null + ws.onerror = null + } else { + ws.onclose = null + ws.onerror = null + ws.close() + } + } + wsRef.current = null + } + }, [connect]) + + return { connected, subscribe, unsubscribe } +} diff --git a/web/src/useKanbanWebSocket.ts b/web/src/useKanbanWebSocket.ts new file mode 100644 index 00000000..607870bc --- /dev/null +++ b/web/src/useKanbanWebSocket.ts @@ -0,0 +1,46 @@ +import { useEffect, useRef, useCallback, useState } from "react" + +export function useKanbanWebSocket(onUpdate: () => void) { + const wsRef = useRef<WebSocket | null>(null) + const [connected, setConnected] = useState(false) + const onUpdateRef = useRef(onUpdate) + onUpdateRef.current = onUpdate + + const connect = useCallback(() => { + const proto = location.protocol === "https:" ? "wss:" : "ws:" + const ws = new WebSocket(`${proto}//${location.host}/ws/kanban`) + wsRef.current = ws + + ws.onopen = () => setConnected(true) + + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) + if (msg.type === "kanban_update") { + onUpdateRef.current() + } + } catch {} + } + + ws.onclose = () => { + setConnected(false) + wsRef.current = null + // Reconnect with backoff + setTimeout(connect, 2000 + Math.random() * 3000) + } + + ws.onerror = () => { + ws.close() + } + }, []) + + useEffect(() => { + connect() + return () => { + wsRef.current?.close() + wsRef.current = null + } + }, [connect]) + + return connected +} diff --git a/web/src/useLoopRuntime.tsx b/web/src/useLoopRuntime.tsx new file mode 100644 index 00000000..61b5a3a5 --- /dev/null +++ b/web/src/useLoopRuntime.tsx @@ -0,0 +1,1444 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react" +import { useExternalStoreRuntime, type AppendMessage } from "@assistant-ui/react" +import type { PermissionMode } from "@/components/chat/PlanModeToggle" +import { getVersion, getBuildInfo, type ModelEntry } from "@/api" + +// ── Slash-commands cache ── +// Persists the last known slash commands per loopId so they're available +// immediately on reconnect, before CC's system/init message arrives. + +type SlashCommandInfo = { name: string; description: string } + +function normalizeSlashCommands(cmds: unknown[]): SlashCommandInfo[] { + const result: SlashCommandInfo[] = [] + for (const c of cmds) { + if (typeof c === "string") { + result.push({ name: c, description: "" }) + } else if (typeof c === "object" && c !== null && "name" in c) { + const obj = c as Record<string, unknown> + result.push({ + name: String(obj.name), + description: typeof obj.description === "string" ? obj.description : "", + }) + } + } + return result +} + +const SLASH_CACHE_KEY = "loopat:slash-commands" + +function loadCachedSlashCommands(loopId: string): SlashCommandInfo[] { + try { + const raw = localStorage.getItem(SLASH_CACHE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + const cache: Record<string, unknown[]> = typeof parsed === "object" && parsed !== null ? parsed : {} + const entry = cache[loopId] + return Array.isArray(entry) ? normalizeSlashCommands(entry) : [] + } catch { + return [] + } +} + +function saveCachedSlashCommands(loopId: string, cmds: SlashCommandInfo[]) { + try { + const raw = localStorage.getItem(SLASH_CACHE_KEY) + const cache: Record<string, SlashCommandInfo[]> = raw ? JSON.parse(raw) : {} + cache[loopId] = cmds + localStorage.setItem(SLASH_CACHE_KEY, JSON.stringify(cache)) + } catch { + // ignore storage errors + } +} + +type RawMsg = { + id: string + role: "user" | "assistant" + content: any[] + parent_tool_use_id?: string | null +} + +function asContentArray(c: any): any[] { + if (typeof c === "string") return [{ type: "text", text: c }] + if (Array.isArray(c)) return c + return [] +} + +let counter = 0 +function freshId(prefix: string) { + counter++ + return `${prefix}-${Date.now()}-${counter}` +} + +/** + * Apply a SDKPartialAssistantMessage (`type: "stream_event"`) — text deltas + * and tool_use input json deltas — to the live assistant message keyed by + * uuid. Final SDKAssistantMessage with the same uuid replaces the partial + * later, so this is best-effort live preview. + */ +function handleStreamEvent(m: any, setRaw: React.Dispatch<React.SetStateAction<RawMsg[]>>) { + const uuid: string | undefined = m?.uuid + const ev = m?.event + if (!uuid || !ev) return + + const upsert = (mutate: (msg: RawMsg) => RawMsg) => { + setRaw((prev) => { + const idx = prev.findIndex((x) => x.id === uuid) + if (idx < 0) { + return [...prev, mutate({ id: uuid, role: "assistant", content: [] })] + } + const out = prev.slice() + out[idx] = mutate(prev[idx]) + return out + }) + } + + if (ev.type === "content_block_start") { + const idx: number = ev.index ?? 0 + const cb = ev.content_block ?? {} + upsert((msg) => { + const content = msg.content.slice() + while (content.length <= idx) content.push({ type: "text", text: "" }) + if (cb.type === "text") { + content[idx] = { type: "text", text: cb.text ?? "" } + } else if (cb.type === "tool_use") { + content[idx] = { + type: "tool_use", + id: cb.id, + name: cb.name, + input: cb.input ?? {}, + _partial_json: "", + } + } else if (cb.type === "thinking") { + content[idx] = { type: "thinking", thinking: cb.thinking ?? "", signature: cb.signature ?? "" } + } else if (cb.type === "redacted_thinking") { + content[idx] = { type: "thinking", thinking: "[Redacted]", signature: "" } + } + return { ...msg, content } + }) + } else if (ev.type === "content_block_delta") { + const idx: number = ev.index ?? 0 + const d = ev.delta + upsert((msg) => { + const content = msg.content.slice() + const cur = content[idx] + if (!cur) return msg + if (d?.type === "text_delta" && cur.type === "text") { + content[idx] = { ...cur, text: (cur.text ?? "") + (d.text ?? "") } + } else if (d?.type === "thinking_delta" && cur.type === "thinking") { + content[idx] = { ...cur, thinking: (cur.thinking ?? "") + (d.thinking ?? "") } + } else if (d?.type === "signature_delta" && cur.type === "thinking") { + content[idx] = { ...cur, signature: (cur.signature ?? "") + (d.signature ?? "") } + } else if (d?.type === "input_json_delta" && cur.type === "tool_use") { + const json = (cur._partial_json ?? "") + (d.partial_json ?? "") + let parsed = cur.input + try { + parsed = JSON.parse(json) + } catch {} + content[idx] = { ...cur, _partial_json: json, input: parsed } + } + return { ...msg, content } + }) + } +} + +function aggregateToolResults(raw: RawMsg[]): RawMsg[] { + const resultMap = new Map<string, { content: string; isError: boolean }>() + for (const m of raw) { + if (m.role === "user") { + for (const block of m.content) { + if (block?.type === "tool_result") { + const c = block.content + const txt = + typeof c === "string" + ? c + : Array.isArray(c) + ? c.map((x: any) => x?.text ?? JSON.stringify(x)).join("") + : JSON.stringify(c) + resultMap.set(block.tool_use_id, { content: txt, isError: !!block.is_error }) + } + } + } + } + const out: RawMsg[] = [] + for (const m of raw) { + if (m.role === "user") { + const textBlocks = m.content.filter((b: any) => b?.type === "text" && (b.text ?? "").trim()) + if (textBlocks.length === 0) continue + out.push({ ...m, content: textBlocks }) + } else { + const enriched = m.content.map((b: any) => { + if (b?.type === "tool_use") { + const r = resultMap.get(b.id) + return { ...b, _result: r } + } + return b + }) + // skip assistant entries that render to nothing (e.g. a freshly-allocated + // partial message with no text yet) — otherwise they show as empty bubbles + const visible = enriched.some( + (b: any) => + (b?.type === "text" && (b.text ?? "").length > 0) || + b?.type === "tool_use" || + b?.type === "thinking", + ) + if (!visible) continue + out.push({ ...m, content: enriched }) + } + } + return out +} + +export function convertMessage(raw: RawMsg) { + const parts: any[] = [] + for (const b of raw.content) { + if (b?.type === "text") { + const txt = (b.text ?? "").trim() + if (txt) parts.push({ type: "text", text: txt }) + } else if (b?.type === "clear-divider") { + const ts = (b as any).ts + const by = (b as any).by + const timeStr = ts ? new Date(ts).toLocaleString() : "" + const byStr = by ? ` by ${by}` : "" + const stamp = [byStr, timeStr].filter(Boolean).join(" · ") + parts.push({ + type: "text", + text: `---\n*Context cleared${stamp ? ` ${stamp}` : ""}*\n---`, + }) + } else if (b?.type === "thinking") { + parts.push({ + type: "reasoning", + text: b.thinking ?? "", + signature: b.signature, + }) + } else if (b?.type === "tool_use") { + const r = b._result + const args = b.input ?? {} + // Don't mark as complete until input JSON has actually arrived — + // content_block_start fires with input={} before input_json_deltas. + const hasArgs = Object.keys(args).length > 0 + parts.push({ + type: "tool-call", + toolCallId: b.id, + toolName: b.name, + args, + result: r ? r.content : undefined, + isError: r ? r.isError : undefined, + status: r + ? r.isError + ? { type: "incomplete", reason: "error" as const } + : hasArgs + ? { type: "complete" as const } + : { type: "running" as const } + : { type: "running" as const }, + }) + } + } + return { + id: raw.id, + role: raw.role, + content: parts, + } as const +} + +/* ─── Permission prompt ─── */ + +export interface PermissionPrompt { + toolUseId: string + toolName: string + title: string + displayName: string +} + +/* ─── Tool progress & task tracking ─── */ + +export interface ToolProgress { + tool_use_id: string + tool_name: string + elapsed_time_seconds: number + parent_tool_use_id: string | null + task_id?: string +} + +export interface TaskState { + task_id: string + tool_use_id?: string + status: "pending" | "running" | "completed" | "failed" | "killed" | "stopped" + description: string + task_type?: string + workflow_name?: string + prompt?: string + usage?: { + total_tokens: number + tool_uses: number + duration_ms: number + } + summary?: string + last_tool_name?: string + output_file?: string + end_time?: number + error?: string +} + +export interface QuestionOption { + label: string + description: string +} + +export interface QuestionDef { + question: string + header: string + options: QuestionOption[] + multiSelect: boolean +} + +export interface ProviderInfo { + name: string + model: string + models: ModelEntry[] + contextWindow: number +} + +export interface ContextUsage { + totalTokens: number + maxTokens: number + percentage: number + model: string +} + +export interface LoopRuntimeExtra { + toolProgressMap: ReadonlyMap<string, ToolProgress> + taskMap: ReadonlyMap<string, TaskState> + questions: ReadonlyMap<string, QuestionDef[]> + sendAnswers: (toolUseId: string, answers: Record<string, string>) => void + thinkingOpen: boolean + setThinkingOpen: (open: boolean) => void + permissionMode: PermissionMode + setPermissionMode: (mode: PermissionMode) => void + permissionPrompt: PermissionPrompt | null + answerPermission: (toolUseId: string, allow: boolean) => void + setMaxThinkingTokens: (tokens: number | null) => void + getContextUsage: () => void + contextUsage: ContextUsage | null + thinkingBudget: number | null + provider: ProviderInfo | null + selectProvider: (name: string, source?: "personal" | "workspace", model?: string) => void + /** Drop SDK context (like CC's /clear); next message starts with 0 history. */ + clearContext: () => void + /** Count of thinking/redacted_thinking content blocks currently in raw + * assistant history. Used by ModelSelector to decide whether a cross- + * provider switch needs a strip-thinking confirmation. */ + thinkingBlockCount: number + /** This loop's id — needed by ModelSelector to call /strip-thinking. */ + loopId: string + /** True while server is replaying history on connect. Chat scrolls to bottom + * instantly during this phase; after it ends, normal scroll behavior resumes. */ + loadingHistory: boolean + /** Set of tool_use_ids that are Agent or Task tools. */ + agentToolUseIds: ReadonlySet<string> + /** Agent tool_use_id → child RawMsgs that belong to that agent. */ + childMessagesByAgentId: ReadonlyMap<string, RawMsg[]> + /** True while SDK is generating. */ + isRunning: boolean + /** Enqueue a message to be sent after current generation completes. */ + enqueueMessage: (text: string) => void + /** Messages waiting in the server queue. */ + queue: string[] + /** Clear the pending message queue. */ + clearQueue: () => void + /** Remove a single item from the queue by index. */ + removeFromQueue: (index: number) => void + /** Whether there are messages before a clear boundary (history available). */ + hasHistory: boolean + /** Whether to show pre-clear history messages. */ + showHistory: boolean + /** Toggle showing pre-clear history. */ + toggleShowHistory: () => void + /** Slash commands advertised by CC at session init: built-ins ("init", + * "clear"), user-tier skills ("loop", "schedule"), and plugin commands + * ("loopat:onboarding"). Empty until the first init message arrives; + * may include duplicates if CC ever reports them — caller should dedup. */ + availableSlashCommands: SlashCommandInfo[] + /** Set by Composer before navigating history with ArrowUp/ArrowDown. + * SlashCommand reads it to suppress the dropdown so it doesn't pop up + * when the history entry happens to start with "/". */ + suppressSlashRef: React.MutableRefObject<boolean> + /** True when aggregated messages exceed the render window. */ + hasOlderMessages: boolean + /** Load and render the next batch of older messages. */ + loadMoreMessages: () => void + /** Increments on each new overall turn / reconnect. ClaudeStatus uses it + * to force-restart the elapsed timer (as a useEffect dep). */ + turnGeneration: number + /** Timestamp (ms) when the current user turn started. Persisted in + * sessionStorage so the elapsed timer survives page refreshes. */ + turnStartedAt: number | null + /** Getter for current-turn streaming output tokens. Reads ref directly for rAF polling. */ + getStreamingTokenCount: () => number + /** Getter that returns true while waiting for the first token of a (sub-)turn. + * Arrow-up (uploading) vs arrow-down (streaming) indicator. */ + getWaitingForResponse: () => boolean + /** Precise context-window token count (last result input+output). */ + contextTokens: number + /** Precise cumulative tokens from model turns + agent tasks + live streaming. */ + cumulativeTokens: number + /** Open a file in the editor panel (loop page only). */ + openFile?: (path: string) => void + /** File blocks sent with the last user message (for UserMessage rendering). */ + lastSentFiles?: { path: string; content: string }[] + /** Active goal set via /goal (null = no active goal). */ + goal: string | null + goalSetAt: string | null + goalStatus: "active" | "completed" | null + /** Set or clear the active goal (null to clear). */ + setGoal: (goal: string | null) => void + /** Mark the current goal as completed. */ + completeGoal: () => void +} + +const LoopRuntimeCtx = createContext<LoopRuntimeExtra>({ + toolProgressMap: new Map(), + taskMap: new Map(), + questions: new Map(), + sendAnswers: () => {}, + thinkingOpen: false, + setThinkingOpen: () => {}, + permissionMode: "bypassPermissions" as PermissionMode, + setPermissionMode: () => {}, + permissionPrompt: null, + answerPermission: () => {}, + setMaxThinkingTokens: () => {}, + getContextUsage: () => {}, + contextUsage: null, + thinkingBudget: null, + provider: null, + selectProvider: () => {}, + clearContext: () => {}, + thinkingBlockCount: 0, + loopId: "", + loadingHistory: true, + agentToolUseIds: new Set(), + childMessagesByAgentId: new Map(), + isRunning: false, + enqueueMessage: () => {}, + queue: [], + clearQueue: () => {}, + removeFromQueue: () => {}, + hasHistory: false, + showHistory: false, + toggleShowHistory: () => {}, + availableSlashCommands: [], + suppressSlashRef: { current: false }, + hasOlderMessages: false, + loadMoreMessages: () => {}, + turnGeneration: 0, + turnStartedAt: null, + getStreamingTokenCount: () => 0, + getWaitingForResponse: () => true, + contextTokens: 0, + cumulativeTokens: 0, + goal: null, + goalSetAt: null, + goalStatus: null, + setGoal: () => {}, + completeGoal: () => {}, +}) + +export function useLoopRuntimeExtra(): LoopRuntimeExtra { + return useContext(LoopRuntimeCtx) +} + +export function LoopRuntimeProvider({ + extra, + children, +}: { + extra: LoopRuntimeExtra + children: React.ReactNode +}) { + return ( + <LoopRuntimeCtx.Provider value={extra}> + {children} + </LoopRuntimeCtx.Provider> + ) +} + +export function useLoopRuntime(loopId: string | null, currentUserId: string, openFile?: (path: string) => void) { + const [raw, setRaw] = useState<RawMsg[]>([]) + const [connected, setConnected] = useState(false) + const [reconnecting, setReconnecting] = useState(false) + const [running, setRunning] = useState(false) + const [viewers, setViewers] = useState(0) + const [mounts, setMounts] = useState<{ name: string; path: string }[]>([]) + const [provider, setProvider] = useState<ProviderInfo | null>(null) + // Start with cached commands (or empty). When CC's real system/init + // arrives, the list is replaced with the actual reported commands. + // On first-ever open (no cache), seed with known CC built-in commands + // so the / menu is useful immediately. + const [availableSlashCommands, setAvailableSlashCommands] = useState<SlashCommandInfo[]>( + () => { + if (!loopId) return [] + const cached = loadCachedSlashCommands(loopId) + if (cached.length > 0) return cached + // Default CC built-in commands — always available once CC starts. + // These get replaced by the real list when system/init arrives. + return ["help", "model", "compress", "review", "init", "foxtrot"].map(name => ({ name, description: "" })) + }, + ) + const suppressSlashRef = useRef(false) + const wsRef = useRef<WebSocket | null>(null) + // v1 SSE subscription for live SDK messages (parallel to WS). The WS keeps + // delivering history + initial state + operator-feature broadcasts; v1 SSE + const seenUuidsRef = useRef<Set<string>>(new Set()) + // Ref (not state) so ws.onmessage closure sees fresh value without + // re-attaching the handler. Only the gating logic inside onmessage reads it. + const loadingHistoryRef = useRef(true) + const [loadingHistory, setLoadingHistory] = useState(true) + const reconnectTimerRef = useRef<number | null>(null) + const attemptsRef = useRef(0) + const aliveRef = useRef(true) + const replayBufRef = useRef<any[]>([]) + + // Tool progress (tool_progress messages) keyed by tool_use_id + const toolProgressRef = useRef<Map<string, ToolProgress>>(new Map()) + const [toolProgressVersion, setToolProgressVersion] = useState(0) + // Task state (task_started / task_updated / task_progress / task_notification) keyed by task_id + const taskRef = useRef<Map<string, TaskState>>(new Map()) + const [taskVersion, setTaskVersion] = useState(0) + // Accumulated token usage from result messages (main model turns). + // Reset on reconnect; incremented in dispatchMsg. + const tokenUsageRef = useRef(0) + const [tokenUsageVersion, setTokenUsageVersion] = useState(0) + // Live token tracking from stream events during the current model turn. + // input: from message_start, output: estimated from content_block_delta chars + // or precise from message_delta usage. Reset when result arrives. + const streamingInputRef = useRef(0) + const streamingOutputCharsRef = useRef(0) + const streamingTokensRef = useRef(0) + const [streamingTokensVersion, setStreamingTokensVersion] = useState(0) + // Context-window token count for the pie chart. Set from API input_tokens + // on message_start and result. The next request's input_tokens is the + // authoritative measure of context usage (SDK may compress/summarize). + const contextTokensRef = useRef(0) + const [contextTokensVersion, setContextTokensVersion] = useState(0) + // Output token count for ClaudeStatus display. Updated on content_block_delta + // (chars/3.5 estimate) and message_delta (precise). Reset on message_start. + // ClaudeStatus reads it directly via rAF — no setState on the hot path. + const streamingOutputRef = useRef(0) + // Cumulative output chars across all sub-turns within a single user request. + // Only reset on new user message (onNew) and reconnect — never on + // message_start, because tool-use / thinking continuations emit additional + // message_start events that would incorrectly drop the display counter. + // Mirrors the official TUI's responseLengthRef which is scoped to the full + // query lifecycle, not individual sub-turns. + const displayOutputCharsRef = useRef(0) + // Arrow-up (true) until the first token arrives for the current (sub-)turn. + // Set true on message_start / onNew, false on content_block_delta. + const waitingForResponseRef = useRef(true) + // Incremented on new user requests and reconnects. ClaudeStatus uses it as + // a useEffect dep to force-restart the elapsed timer. + const [turnGeneration, setTurnGeneration] = useState(0) + // Persist turn start time in sessionStorage so the elapsed timer in + // ClaudeStatus survives page refreshes. Saved in onNew, never cleared + // (each new user message overwrites it). + const TURN_START_KEY = `loopat:turn-start:${loopId ?? "unknown"}` + const [turnStartedAt, setTurnStartedAt] = useState<number | null>(() => { + try { + const saved = sessionStorage.getItem(TURN_START_KEY) + return saved ? Number(saved) : null + } catch { + return null + } + }) + + // Questions (AskUserQuestion tool) — plain object for immutable updates + const [questionsObj, setQuestionsObj] = useState<Record<string, QuestionDef[]>>({}) + const [thinkingOpen, setThinkingOpen] = useState(false) + const [permissionMode, setPermissionMode] = useState<PermissionMode>("bypassPermissions") + const permissionModeRef = useRef<PermissionMode>("bypassPermissions") + const [goal, setGoal] = useState<string | null>(null) + const [goalSetAt, setGoalSetAt] = useState<string | null>(null) + const [goalStatus, setGoalStatus] = useState<"active" | "completed" | null>(null) + permissionModeRef.current = permissionMode + const [queue, setQueue] = useState<string[]>([]) + + const [showHistory, setShowHistory] = useState(false) + + const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null) + + const answerPermission = useCallback((toolUseId: string, allow: boolean) => { + // v1: POST /loops/{id}/choices/{choice_id} { allow } + const choiceId = toolUseId.startsWith("choice_") ? toolUseId : `choice_${toolUseId}` + fetch(`/api/v1/loops/loop_${loopId}/choices/${choiceId}`, { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ allow }), + }).catch(() => {}) + setPermissionPrompt(null) + }, [loopId]) + + const [contextUsage, setContextUsage] = useState<ContextUsage | null>(null) + const [thinkingBudget, setThinkingBudget] = useState<number | null>(null) + + const setMaxThinkingTokens = useCallback((tokens: number | null) => { + const ws = wsRef.current + if (!ws || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type: "set_max_thinking_tokens", tokens })) + setThinkingBudget(tokens) + }, []) + + const setGoalFn = useCallback((goal: string | null) => { + const ws = wsRef.current + if (!ws || ws.readyState !== WebSocket.OPEN) return + if (goal === null) { + ws.send(JSON.stringify({ type: "set_goal", goal: null })) + } else { + ws.send(JSON.stringify({ type: "set_goal", goal: goal.trim() })) + } + }, []) + + const completeGoal = useCallback(() => { + const ws = wsRef.current + if (!ws || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type: "complete_goal" })) + }, []) + + const getContextUsage = useCallback(() => { + const ws = wsRef.current + if (!ws || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type: "get_context_usage" })) + }, []) + + const sendAnswers = useMemo(() => { + const fn = (toolUseId: string, answers: Record<string, string>) => { + // v1: POST /loops/{id}/choices/{choice_id} { answers } + const choiceId = toolUseId.startsWith("choice_") ? toolUseId : `choice_${toolUseId}` + fetch(`/api/v1/loops/loop_${loopId}/choices/${choiceId}`, { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ answers }), + }).catch(() => {}) + setQuestionsObj((prev) => { + if (!(toolUseId in prev)) return prev + const next = { ...prev } + delete next[toolUseId] + return next + }) + } + return fn + }, [loopId]) + + // Expose as stable read-only Maps that re-render when version bumps + const toolProgressMap = useMemo(() => { + void toolProgressVersion // pin for re-computation + return toolProgressRef.current as ReadonlyMap<string, ToolProgress> + }, [toolProgressVersion]) + + const taskMap = useMemo(() => { + void taskVersion + return taskRef.current as ReadonlyMap<string, TaskState> + }, [taskVersion]) + + const questionsReadonlyMap = useMemo<ReadonlyMap<string, QuestionDef[]>>(() => { + return new Map(Object.entries(questionsObj)) + }, [questionsObj]) + + const selectProvider = useCallback((name: string, source?: "personal" | "workspace", model?: string) => { + const ws = wsRef.current + if (!ws || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type: "provider_select", provider: name, source, ...(model ? { model } : {}) })) + }, []) + + const clearContext = useCallback(() => { + const ws = wsRef.current + if (!ws || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type: "clear" })) + }, []) + + // Count thinking blocks in raw history. Cheap walk; raw is bounded by + // session length and rerenders only on raw change. + const thinkingBlockCount = useMemo(() => { + let n = 0 + for (const m of raw) { + if (m.role !== "assistant" || !Array.isArray(m.content)) continue + for (const b of m.content) { + if (b?.type === "thinking" || b?.type === "redacted_thinking") n++ + } + } + return n + }, [raw]) + + const hasHistory = useMemo(() => { + for (const m of raw) { + if (Array.isArray(m?.content) && m.content[0]?.type === "clear-divider") { + return true + } + } + return false + }, [raw]) + + const { aggregated, agentToolUseIds, childMessagesByAgentId } = useMemo(() => { + try { + let from = 0 + if (!showHistory) { + for (let i = raw.length - 1; i >= 0; i--) { + const m: any = raw[i] + const firstPart = Array.isArray(m?.content) ? m.content[0] : null + if (firstPart?.type === "clear-divider") { + from = i + 1 + break + } + } + } + const allEnriched = aggregateToolResults(from === 0 ? raw : raw.slice(from)) + + const agentIds = new Set<string>() + for (const m of allEnriched) { + if (!Array.isArray(m.content)) continue + for (const b of m.content) { + if (b?.type === "tool_use" && (b.name === "Agent" || b.name === "Task") && b.id) { + agentIds.add(b.id) + } + } + } + + const main: RawMsg[] = [] + const childrenByAgent = new Map<string, RawMsg[]>() + for (const m of allEnriched) { + const pid = m.parent_tool_use_id + if (pid && agentIds.has(pid)) { + const existing = childrenByAgent.get(pid) + if (existing) existing.push(m) + else childrenByAgent.set(pid, [m]) + } else { + main.push(m) + } + } + + return { + aggregated: main, + agentToolUseIds: agentIds as ReadonlySet<string>, + childMessagesByAgentId: childrenByAgent as ReadonlyMap<string, RawMsg[]>, + } + } catch (e) { + console.error("[fe:aggregateToolResults]", e) + return { aggregated: [] as RawMsg[], agentToolUseIds: new Set() as ReadonlySet<string>, childMessagesByAgentId: new Map() as ReadonlyMap<string, RawMsg[]> } + } + }, [raw, showHistory]) + + const RENDER_WINDOW_SIZE = 20 + const RENDER_WINDOW_BATCH = 20 + + const [renderCount, setRenderCount] = useState(RENDER_WINDOW_SIZE) + + useEffect(() => { + setRenderCount(RENDER_WINDOW_SIZE) + }, [loopId]) + + const hasOlderMessages = aggregated.length > renderCount + const visibleMessages = hasOlderMessages ? aggregated.slice(-renderCount) : aggregated + + const loadMoreMessages = useCallback(() => { + setRenderCount(prev => prev + RENDER_WINDOW_BATCH) + }, []) + + const onCancel = useCallback(async () => { + // v1: POST /loops/{id}/interrupt + fetch(`/api/v1/loops/loop_${loopId}/interrupt`, { + method: "POST", + credentials: "include", + }).catch(() => {}) + setRunning(false) + }, [loopId]) + + const onClearQueue = useCallback(() => { + const ws = wsRef.current + if (!ws || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type: "queue_clear" })) + }, []) + + const onRemoveFromQueue = useCallback((index: number) => { + const ws = wsRef.current + if (!ws || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type: "queue_remove", index })) + }, []) + + const enqueueMessage = useCallback((text: string, files?: { path: string; content: string }[]) => { + const ws = wsRef.current + if (!running) { + setRunning(true) + setTurnGeneration((v) => v + 1) + const now = Date.now() + setTurnStartedAt(now) + try { sessionStorage.setItem(TURN_START_KEY, String(now)) } catch {} + } + + // v1: POST /loops/{id}/messages. files attachments still go via WS until + // v1 supports them (rare path: user pastes a screenshot or similar). + const postV1 = (content: string) => { + fetch(`/api/v1/loops/loop_${loopId}/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ content, permission_mode: permissionModeRef.current }), + // Don't consume the SSE response body — the parallel /events listener + // (or WS) already delivers all SDK messages from this turn. + }).catch(() => {}) + } + + // /goal: goal management stays on WS (operator-only feature). + const goalMatch = text.match(/^\/goal\s+(.+)/) + const bareGoal = text.match(/^\/goal$/) + if (goalMatch) { + const arg = goalMatch[1].trim() + if (arg === "done") { + ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: "complete_goal" })) + return + } + if (arg === "clear") { + ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: "set_goal", goal: null })) + return + } + ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: "set_goal", goal: arg })) + if (files?.length) { + ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: "user", text: `My goal is: ${arg}`, files, permissionMode: permissionModeRef.current })) + } else { + postV1(`My goal is: ${arg}`) + } + } else if (bareGoal) { + ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: "set_goal", goal: null })) + } else if (files?.length) { + // Files attachment: still WS (v1 doesn't support file blocks yet). + ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: "user", text, files, permissionMode: permissionModeRef.current })) + } else { + postV1(text) + } + }, [running, TURN_START_KEY, loopId]) + + const toggleShowHistory = useCallback(() => { + setShowHistory((v) => !v) + }, []) + + // Stable getter so ClaudeStatus can poll streamingOutputRef from its rAF + // loop without triggering React re-renders on every content_block_delta. + const getStreamingTokenCount = useCallback(() => streamingOutputRef.current, []) + const getWaitingForResponse = useCallback(() => waitingForResponseRef.current, []) + + // Context-window token count for the pie chart. Set from API's input_tokens + // (the size of the prompt sent to the model). Does NOT add output_tokens + // because the SDK may compress/summarize previous output before appending + // to the next request — the next input_tokens is the authoritative value. + const contextTokens = useMemo(() => contextTokensRef.current, [contextTokensVersion]) + + // Cumulative precise count from result + task + streaming events (for status bar). + const cumulativeTokens = useMemo(() => { + let total = tokenUsageRef.current + streamingTokensRef.current + for (const [, task] of taskRef.current) { + const u = task.usage + if (u && typeof u.total_tokens === "number") { + total += u.total_tokens + } + } + return total + }, [tokenUsageVersion, taskVersion, streamingTokensVersion]) + + const extra = useMemo<LoopRuntimeExtra>( + () => ({ toolProgressMap, taskMap, questions: questionsReadonlyMap, sendAnswers, thinkingOpen, setThinkingOpen, permissionMode, setPermissionMode, permissionPrompt, answerPermission, setMaxThinkingTokens, getContextUsage, contextUsage, thinkingBudget, provider, selectProvider, clearContext, thinkingBlockCount, loopId: loopId ?? "", loadingHistory, agentToolUseIds, childMessagesByAgentId, isRunning: running, enqueueMessage, queue, clearQueue: onClearQueue, removeFromQueue: onRemoveFromQueue, hasHistory, showHistory, toggleShowHistory, availableSlashCommands, suppressSlashRef, hasOlderMessages, loadMoreMessages, turnGeneration, turnStartedAt, getStreamingTokenCount, getWaitingForResponse, contextTokens, cumulativeTokens, openFile, goal, goalSetAt, goalStatus, setGoal: setGoalFn, completeGoal }), + [toolProgressMap, taskMap, questionsReadonlyMap, sendAnswers, thinkingOpen, permissionMode, permissionPrompt, answerPermission, setMaxThinkingTokens, getContextUsage, contextUsage, thinkingBudget, provider, selectProvider, clearContext, thinkingBlockCount, loopId, loadingHistory, agentToolUseIds, childMessagesByAgentId, running, enqueueMessage, queue, onClearQueue, onRemoveFromQueue, hasHistory, showHistory, toggleShowHistory, availableSlashCommands, hasOlderMessages, loadMoreMessages, turnGeneration, turnStartedAt, contextTokens, cumulativeTokens, openFile], + ) + + useEffect(() => { + if (!loopId) return + aliveRef.current = true + + const url = `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws/loop/${loopId}` + + const connect = () => { + // server replays history on each connect — clear local buffer + setRaw([]) + setRunning(false) + loadingHistoryRef.current = true + setLoadingHistory(true) + // Clear tool progress & task state & questions on reconnect + toolProgressRef.current = new Map() + setToolProgressVersion((v) => v + 1) + taskRef.current = new Map() + setTaskVersion((v) => v + 1) + tokenUsageRef.current = 0 + setTokenUsageVersion((v) => v + 1) + streamingInputRef.current = 0 + streamingOutputCharsRef.current = 0 + streamingTokensRef.current = 0 + setStreamingTokensVersion((v) => v + 1) + displayOutputCharsRef.current = 0 + streamingOutputRef.current = 0 + waitingForResponseRef.current = true + contextTokensRef.current = 0 + setContextTokensVersion((v) => v + 1) + setQuestionsObj({}) + setPermissionPrompt(null) + setGoal(null) + setGoalSetAt(null) + setGoalStatus(null) + setContextUsage(null) + setThinkingBudget(null) + setThinkingOpen(false) + replayBufRef.current = [] + seenUuidsRef.current = new Set() + const ws = new WebSocket(url) + wsRef.current = ws + + ws.onopen = () => { + setConnected(true) + setReconnecting(false) + attemptsRef.current = 0 + // Clear any pending retry from a previous connection's onclose + if (reconnectTimerRef.current !== null) { + clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + // Check if server version differs from the frontend build + getVersion().then((v) => { + const build = getBuildInfo() + if (v.commit !== "unknown" && build.commit !== "unknown" && v.commit !== build.commit) { + window.dispatchEvent(new CustomEvent("loopat:version-mismatch", { detail: { commit: v.commit } })) + } + }).catch(() => {}) + } + ws.onclose = () => { + setConnected(false) + setRunning(false) + if (!aliveRef.current) return + const n = ++attemptsRef.current + // exp backoff capped at 30s: 500ms, 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... + const delay = Math.min(30_000, 500 * 2 ** Math.min(n - 1, 6)) + setReconnecting(true) + reconnectTimerRef.current = window.setTimeout(() => { + reconnectTimerRef.current = null + // Don't reconnect if a new WS already opened (e.g. StrictMode double-mount) + if (!aliveRef.current) return + if (wsRef.current?.readyState === WebSocket.OPEN) return + connect() + }, delay) + } + ws.onerror = () => setConnected(false) + const dispatchMsg = (m: any) => { + // Dedupe by uuid for SDK messages — both WS and v1 SSE deliver the + // same messages; whichever arrives first wins, the other is dropped. + // Non-uuid messages (queue_update, viewers, etc.) are idempotent + // state updates and safe to re-apply. + if (typeof m?.uuid === "string") { + if (seenUuidsRef.current.has(m.uuid)) return + seenUuidsRef.current.add(m.uuid) + } + if (m?.type === "viewers") { + setViewers(typeof m.count === "number" ? m.count : 0) + return + } + if (m?.type === "queue_update") { + setQueue(Array.isArray(m.queue) ? m.queue : []) + return + } + if (m?.type === "provider") { + const models: ModelEntry[] = Array.isArray(m.models) ? m.models : (m.model ? [{ id: String(m.model), enabled: true }] : []) + setProvider({ + name: String(m.name ?? "?"), + model: String(m.model ?? models[0]?.id ?? ""), + models, + contextWindow: typeof m.contextWindow === "number" ? m.contextWindow : 200_000, + }) + return + } + + if (m?.type === "permission_mode" && typeof m.mode === "string") { + const validModes = ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"] + if (validModes.includes(m.mode)) { + setPermissionMode(m.mode as PermissionMode) + } + return + } + + if (m?.type === "goal") { + if (m.goal && typeof m.goal === "string") { + setGoal(m.goal) + setGoalSetAt(typeof m.setAt === "string" ? m.setAt : null) + setGoalStatus(typeof m.status === "string" && (m.status === "active" || m.status === "completed") ? m.status : "active") + } else if (m.goal === null) { + setGoal(null) + setGoalSetAt(null) + setGoalStatus(null) + } + return + } + + if (m?.type === "context_usage") { + setContextUsage({ + totalTokens: m.totalTokens ?? 0, + maxTokens: m.maxTokens ?? 0, + percentage: m.percentage ?? 0, + model: m.model ?? "", + }) + return + } + + // ── tool_progress ── + if (m?.type === "tool_progress") { + const tp: ToolProgress = { + tool_use_id: m.tool_use_id, + tool_name: m.tool_name, + elapsed_time_seconds: m.elapsed_time_seconds, + parent_tool_use_id: m.parent_tool_use_id ?? null, + task_id: m.task_id, + } + toolProgressRef.current.set(m.tool_use_id, tp) + setToolProgressVersion((v) => v + 1) + return + } + + // ── permission prompt ── + if (m?.type === "permission_prompt" && typeof m.tool_use_id === "string") { + setPermissionPrompt({ + toolUseId: m.tool_use_id, + toolName: m.tool_name || "?", + title: m.title || "Permission required", + displayName: m.displayName || m.tool_name || "?", + }) + return + } + + // ── question (AskUserQuestion) ── + if (m?.type === "question" && Array.isArray(m.questions)) { + setQuestionsObj((prev) => ({ ...prev, [m.tool_use_id]: m.questions })) + return + } + + // ── task messages ── + if (m?.type === "system") { + const subtype = m.subtype + if (subtype === "task_started") { + const existing = taskRef.current.get(m.task_id) + taskRef.current.set(m.task_id, { + task_id: m.task_id, + tool_use_id: m.tool_use_id, + status: "running", + description: m.description, + task_type: m.task_type, + workflow_name: m.workflow_name, + prompt: m.prompt, + ...(existing?.usage ? { usage: existing.usage } : {}), + }) + setTaskVersion((v) => v + 1) + return + } + if (subtype === "task_updated") { + const prev = taskRef.current.get(m.task_id) + taskRef.current.set(m.task_id, { + ...(prev ?? { task_id: m.task_id, status: "pending" as const, description: "" }), + ...(m.patch?.status ? { status: m.patch.status } : {}), + ...(m.patch?.description ? { description: m.patch.description } : {}), + ...(m.patch?.end_time ? { end_time: m.patch.end_time } : {}), + ...(m.patch?.error ? { error: m.patch.error } : {}), + } as TaskState) + setTaskVersion((v) => v + 1) + return + } + if (subtype === "task_progress") { + const prev = taskRef.current.get(m.task_id) + taskRef.current.set(m.task_id, { + ...(prev ?? { task_id: m.task_id, status: "running" as const, description: "" }), + description: m.description ?? prev?.description ?? "", + usage: m.usage, + ...(m.last_tool_name ? { last_tool_name: m.last_tool_name } : {}), + ...(m.summary ? { summary: m.summary } : {}), + tool_use_id: m.tool_use_id ?? prev?.tool_use_id, + } as TaskState) + setTaskVersion((v) => v + 1) + return + } + if (subtype === "task_notification") { + const prev = taskRef.current.get(m.task_id) + taskRef.current.set(m.task_id, { + ...(prev ?? { task_id: m.task_id, status: "pending" as const, description: "" }), + status: m.status === "completed" ? "completed" + : m.status === "failed" ? "failed" + : "stopped", + tool_use_id: m.tool_use_id ?? prev?.tool_use_id, + output_file: m.output_file, + summary: m.summary, + usage: m.usage, + } as TaskState) + setTaskVersion((v) => v + 1) + return + } + // system/init — start running; also cache the slash-command catalog + // advertised by CC (built-ins + skills + plugin commands like + // "loopat:onboarding"). + if (subtype === "init") { + if (!loadingHistoryRef.current) setRunning(true) + const raw = (m as any).slash_commands + const cmds = Array.isArray(raw) ? normalizeSlashCommands(raw) : [] + if (cmds.length > 0) { + setAvailableSlashCommands(cmds) + saveCachedSlashCommands(loopId, cmds) + } + return + } + return + } + + if (m?.type === "result") { + if (!loadingHistoryRef.current) setRunning(false) + // Only accumulate main-model results; agent tokens are tracked via + // task_notification messages and counted from taskRef. + if (!m.parent_tool_use_id) { + const u = m.usage + if (u && typeof u.input_tokens === "number" && typeof u.output_tokens === "number") { + tokenUsageRef.current += u.input_tokens + u.output_tokens + setTokenUsageVersion((v) => v + 1) + // input_tokens is the context-window usage for this request. + contextTokensRef.current = u.input_tokens + setContextTokensVersion((v) => v + 1) + } + // Reset live streaming counters — the turn is complete. + streamingInputRef.current = 0 + streamingTokensRef.current = 0 + setStreamingTokensVersion((v) => v + 1) + } + return + } + + if (m?.type === "user") { + const uuid: string = m.uuid || freshId("u") + const parentId: string | null = m.parent_tool_use_id ?? null + const content = m.message ? m.message.content : (m.text || "") + try { + setRaw((prev) => { + if (prev.some((x) => x.id === uuid)) return prev + return [...prev, { id: uuid, role: "user", content: asContentArray(content), parent_tool_use_id: parentId }] + }) + } catch {} + } else if (m?.type === "assistant" && m.message?.content) { + // upsert by uuid so the streaming partial gets replaced cleanly. + // Fallback: if the full message uuid doesn't match the stream_event + // uuid, hunt down any streaming partial whose tool_use ids overlap + // and replace it — otherwise both appear side-by-side. + const uuid: string = m.uuid ?? freshId("a") + const content = m.message.content + const parentId: string | null = m.parent_tool_use_id ?? null + try { + setRaw((prev) => { + const full: RawMsg = { id: uuid, role: "assistant", content, parent_tool_use_id: parentId } + const idx = prev.findIndex((x) => x.id === uuid) + if (idx >= 0) { + const out = prev.slice() + out[idx] = full + return out + } + // No uuid match — try to find a streaming partial that shares + // at least one tool_use id with the full message. + const fullToolIds = new Set( + (content as any[]).filter((b: any) => b?.type === "tool_use").map((b: any) => b.id) + ) + if (fullToolIds.size > 0) { + const dupIdx = prev.findIndex((x) => + x.role === "assistant" && + x.content.some((b: any) => b?.type === "tool_use" && fullToolIds.has(b.id)), + ) + if (dupIdx >= 0) { + const out = prev.slice() + out[dupIdx] = full + return out + } + } + return [...prev, full] + }) + } catch {} + } else if (m?.type === "stream_event") { + try { handleStreamEvent(m, setRaw) } catch {} + // Only track main-model streams; agent stream events have + // parent_tool_use_id and would overwrite the main counters. + if (m.parent_tool_use_id) return + // Track live token usage from streaming events. + // message_delta only fires at stream end, so we estimate from + // content_block_delta text/thinking growth for real-time updates. + const ev = m?.event + if (ev?.type === "message_start") { + if (!loadingHistoryRef.current) setRunning(true) + streamingInputRef.current = ev?.message?.usage?.input_tokens ?? 0 + streamingOutputCharsRef.current = 0 + streamingTokensRef.current = streamingInputRef.current + contextTokensRef.current = streamingInputRef.current + waitingForResponseRef.current = true + setStreamingTokensVersion((v) => v + 1) + setContextTokensVersion((v) => v + 1) + } else if (ev?.type === "message_delta") { + const u = ev?.usage + if (u && typeof u.output_tokens === "number") { + streamingTokensRef.current = streamingInputRef.current + u.output_tokens + setStreamingTokensVersion((v) => v + 1) + // Keep the chars/3.5 estimate for consistency across sub-turns. + // The API's output_tokens is per-sub-turn and would cause a drop. + streamingOutputRef.current = Math.round(displayOutputCharsRef.current / 3.5) + } + } else if (ev?.type === "content_block_delta") { + const d = ev?.delta + let deltaLen = 0 + if (d?.type === "text_delta" && typeof d.text === "string") { + deltaLen = d.text.length + } else if (d?.type === "thinking_delta" && typeof d.thinking === "string") { + deltaLen = d.thinking.length + } else if (d?.type === "input_json_delta" && typeof d.partial_json === "string") { + deltaLen = d.partial_json.length + } + waitingForResponseRef.current = false + // Per-sub-turn accumulator (resets on message_start) — used for + // context-window math (streamingTokens, contextTokens). + streamingOutputCharsRef.current += deltaLen + const estimated = Math.round(streamingOutputCharsRef.current / 3.5) + streamingTokensRef.current = streamingInputRef.current + estimated + setStreamingTokensVersion((v) => v + 1) + // contextTokensRef intentionally NOT updated here. input_tokens + // from message_start is the accurate context-window usage; adding + // chars/3.5 output estimate inflates it (especially with verbose + // thinking / tool-use JSON), making the pie show "full" when the + // real context is much lower. The context growth from this turn's + // output is captured at message_delta / result via precise API + // token counts. + // Cumulative accumulator (never resets mid-turn) — used for the + // ClaudeStatus display. Survives tool-use / thinking sub-turn + // boundaries so the counter never drops. + displayOutputCharsRef.current += deltaLen + streamingOutputRef.current = Math.round(displayOutputCharsRef.current / 3.5) + // No setState here — ClaudeStatus reads the ref directly via rAF + } + } else if (m?.type === "error") { + setRaw((prev) => [ + ...prev, + { id: freshId("e"), role: "assistant", content: [{ type: "text", text: `⚠️ ${m.message ?? "error"}` }] }, + ]) + if (!loadingHistoryRef.current) setRunning(false) + } else if (m?.type === "clear-boundary") { + // Context dropped — reset the context-window snapshot. + contextTokensRef.current = 0 + setContextTokensVersion((v) => v + 1) + // Server signals: SDK context dropped at this point. We push a + // synthetic assistant message whose only content part is a custom + // `clear-divider`; AssistantMessage detects that part type and + // renders a striking full-width banner (bypassing the normal + // assistant chrome). + setRaw((prev) => [ + ...prev, + { + id: freshId("clear"), + role: "assistant", + content: [{ type: "clear-divider", ts: m.ts ?? "", by: m.by ?? "" } as any], + }, + ]) + } + } + ws.onmessage = (e) => { + let m: any + try { + m = JSON.parse(e.data) + } catch { + return + } + // During history replay: buffer everything, process on history_end. + // This avoids partial/intermediate state during loading. + if (loadingHistoryRef.current) { + if (m?.type === "history_end") { + // Seed slash commands from server (best-effort before CC starts). + const raw = (m as any).slash_commands + if (Array.isArray(raw) && raw.length > 0) { + const seedCmds = normalizeSlashCommands(raw) + setAvailableSlashCommands(seedCmds) + saveCachedSlashCommands(loopId, seedCmds) + } + for (const bufMsg of replayBufRef.current) { + dispatchMsg(bufMsg) + } + // If no result with usage was replayed (contextTokensRef still 0), + // fall back to estimating from raw message body size. + if (contextTokensRef.current === 0 && replayBufRef.current.length > 0) { + let chars = 0 + for (const bufMsg of replayBufRef.current) { + chars += JSON.stringify(bufMsg).length + } + contextTokensRef.current = Math.round(chars / 3.5) + setContextTokensVersion((v) => v + 1) + } + // Restore the ClaudeStatus display counter from assistant message + // content in the buffer. displayOutputCharsRef is zeroed in connect() + // and result messages don't rebuild it, so a resumed active turn + // would otherwise start the counter at 0. + if (displayOutputCharsRef.current === 0 && replayBufRef.current.length > 0) { + let outputChars = 0 + for (const bufMsg of replayBufRef.current) { + if (bufMsg?.type === "assistant" && bufMsg.message?.content) { + for (const block of bufMsg.message.content) { + if (block?.type === "text" && typeof block.text === "string") { + outputChars += block.text.length + } else if (block?.type === "tool_use") { + outputChars += JSON.stringify(block.input ?? {}).length + } + } + } else if (bufMsg?.type === "stream_event") { + const d = bufMsg?.event?.delta + if (d?.type === "text_delta" && typeof d.text === "string") { + outputChars += d.text.length + } else if (d?.type === "thinking_delta" && typeof d.thinking === "string") { + outputChars += d.thinking.length + } else if (d?.type === "input_json_delta" && typeof d.partial_json === "string") { + outputChars += d.partial_json.length + } + } + } + displayOutputCharsRef.current = outputChars + streamingOutputRef.current = Math.round(outputChars / 3.5) + } + // Detect whether a main-model turn was in-flight when we + // disconnected (message_start with no result after it). If so, + // keep running=true so ClaudeStatus stays visible and the + // restored token count is displayed. + let sawUnfinishedTurn = false + for (let i = replayBufRef.current.length - 1; i >= 0; i--) { + const bm = replayBufRef.current[i] + if (bm?.type === "result" && !bm.parent_tool_use_id) break + if (bm?.type === "stream_event" && bm?.event?.type === "message_start" && !bm.parent_tool_use_id) { + sawUnfinishedTurn = true + break + } + } + loadingHistoryRef.current = false + setLoadingHistory(false) + setRunning(sawUnfinishedTurn) + } else { + replayBufRef.current.push(m) + } + return + } + // Live messages: process immediately + if (m?.type === "history_end") { + const raw = (m as any).slash_commands + if (Array.isArray(raw) && raw.length > 0) { + const seedCmds = normalizeSlashCommands(raw) + setAvailableSlashCommands(seedCmds) + saveCachedSlashCommands(loopId, seedCmds) + } + loadingHistoryRef.current = false + setLoadingHistory(false) + // Don't force running=false — the loading-history path already + // handles this. A live history_end has no buffer to inspect, so + // leave running as-is. + return + } + dispatchMsg(m) + } + + } + + connect() + + return () => { + aliveRef.current = false + if (reconnectTimerRef.current !== null) { + clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + const ws = wsRef.current + if (ws) { + if (ws.readyState === WebSocket.CONNECTING) { + ws.onopen = () => ws.close() + ws.onmessage = null + ws.onclose = null + ws.onerror = null + } else { + ws.onclose = null + ws.onerror = null + ws.close() + } + } + wsRef.current = null + setReconnecting(false) + attemptsRef.current = 0 + } + }, [loopId, currentUserId]) + + const onNew = useCallback(async (message: AppendMessage) => { + let text = "" + if (Array.isArray(message.content)) { + for (const part of message.content) { + if (part.type === "text") text += part.text + } + } + text = text.trim() + // Prepend file context set by Composer handleSubmit + try { + const ctx = sessionStorage.getItem("loopat:pendingFileContext") + if (ctx) { text = ctx + "\n" + text; sessionStorage.removeItem("loopat:pendingFileContext") } + } catch {} + if (!text) return + const ws = wsRef.current + setRunning(true) + // Reset per-turn output counter only on fresh user requests, not on + // tool-use continuations (which trigger message_start within the same turn). + displayOutputCharsRef.current = 0 + streamingOutputRef.current = 0 + waitingForResponseRef.current = true + setTurnGeneration((v) => v + 1) + // Persist turn start so the ClaudeStatus timer survives browser refreshes. + const now = Date.now() + setTurnStartedAt(now) + try { sessionStorage.setItem(TURN_START_KEY, String(now)) } catch {} + + const postV1 = (content: string) => { + fetch(`/api/v1/loops/loop_${loopId}/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ content, permission_mode: permissionModeRef.current }), + }).catch(() => {}) + } + + // /goal: goal management stays on WS (operator-only feature). + const goalMatch = text.match(/^\/goal\s+(.+)/) + const bareGoal = text.match(/^\/goal$/) + if (goalMatch) { + const arg = goalMatch[1].trim() + if (arg === "done") { + ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: "complete_goal" })) + return + } + if (arg === "clear") { + ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: "set_goal", goal: null })) + return + } + ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: "set_goal", goal: arg })) + postV1(`My goal is: ${arg}`) + } else if (bareGoal) { + ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ type: "set_goal", goal: null })) + } else { + postV1(text) + } + }, [TURN_START_KEY, loopId]) + + const safeConvert = useCallback((raw: RawMsg) => { + try { + return convertMessage(raw) + } catch (e) { + console.error("[fe:convertMessage]", e) + return { id: raw.id, role: raw.role, content: [{ type: "text", text: "" }] } as any + } + }, []) + + const runtime = useExternalStoreRuntime({ + messages: visibleMessages, + convertMessage: safeConvert, + isRunning: running, + onNew, + onCancel, + }) + + return { runtime, connected, reconnecting, running, viewers, mounts, setMounts, provider, extra, queue, onClearQueue } +} diff --git a/web/src/useLoopStatus.ts b/web/src/useLoopStatus.ts new file mode 100644 index 00000000..e375774c --- /dev/null +++ b/web/src/useLoopStatus.ts @@ -0,0 +1,48 @@ +import { useEffect, useState, useRef } from "react" + +type StatusMap = Record<string, { status: string; updated: string; viewed?: boolean; phase?: "preparing" | "ready" }> + +export function useLoopStatus(loopIds: string[]) { + const [statusMap, setStatusMap] = useState<StatusMap>({}) + const wsRef = useRef<WebSocket | null>(null) + + useEffect(() => { + if (loopIds.length === 0) return + const protocol = location.protocol === "https:" ? "wss:" : "ws:" + const ws = new WebSocket(`${protocol}//${location.host}/ws/loop-status`) + wsRef.current = ws + + ws.onopen = () => { + ws.send(JSON.stringify({ type: "subscribe", ids: loopIds })) + } + + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) + if (msg.type === "init" || msg.type === "update") { + setStatusMap(prev => ({ ...prev, ...msg.data })) + } + } catch {} + } + + return () => { + if (ws) { + if (ws.readyState === WebSocket.CONNECTING) { + // Don't close() a CONNECTING socket (WebKit logs a native error) — + // close it as soon as it actually connects. + ws.onopen = () => ws.close() + ws.onmessage = null + ws.onclose = null + ws.onerror = null + } else { + ws.onclose = null + ws.onerror = null + ws.close() + } + } + wsRef.current = null + } + }, [loopIds.join(",")]) + + return statusMap +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 00000000..8f2ea5cd --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 00000000..f487466a --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from "vite" +import { fileURLToPath, URL } from "node:url" +import { execSync } from "node:child_process" +import react from "@vitejs/plugin-react" +import tailwindcss from "@tailwindcss/vite" + +function git(key: string, fallback = "unknown") { + try { return execSync(`git rev-parse ${key}`, { encoding: "utf8" }).trim() } catch { return fallback } +} + +export default defineConfig({ + plugins: [react(), tailwindcss()], + define: { + __BUILD_COMMIT__: JSON.stringify(git("HEAD")), + __BUILD_TIME__: JSON.stringify(new Date().toISOString()), + }, + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + build: { + rolldownOptions: { + output: { + codeSplitting: true, + chunkFileNames: "assets/[name]-[hash].js", + }, + }, + }, + server: { + host: process.env.HOST ?? "localhost", + port: 5173, + allowedHosts: [".ngrok-free.app"], + proxy: { + "/api": { target: `http://${process.env.HOST ?? "localhost"}:${process.env.PORT ?? 10001}` }, + "/ws": { + target: `ws://${process.env.HOST ?? "localhost"}:${process.env.PORT ?? 10001}`, + ws: true, + }, + }, + }, +})