diff --git a/CLAUDE.md b/CLAUDE.md index a5fcc5c..e39acd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,12 @@ # Appx -Agentic Application Proxy — self-hostable tool to build and host personal apps with AI agents powered by [OpenCode](https://github.com/anomalyco/opencode). +Agentic Application Proxy: a self-hostable tool to build and host personal apps with AI agents powered by Pi. ## Quick Reference ```bash task local # Build and run appx in HTTP dev mode (127.0.0.1.sslip.io, port 8080) -task build # Build frontend + Go binary → ./appx (without running) +task build # Build frontend + Go binary -> ./appx (without running) task web # Build frontend only, copy to cmd/appx/web/dist task test # Run all Go tests task lint # Lint frontend @@ -16,234 +16,121 @@ task clean # Remove build artifacts ## Architecture -Single Go binary serves everything on one port (HTTPS or HTTP in dev mode). OpenCode runs as a separate process on `localhost:4096` — appx proxies to it and adds auth + TLS. Routing is Host-header based: +Single Go binary serves everything on one port (HTTPS or HTTP in dev mode). Pi runs behind the sibling `agent-server` service on `localhost:4001`. agent-server owns project identity, the on-disk project directory (including each project's `.pi/` harness), session transcripts, models, and credentials; appx is a **control plane + authorizing gateway** that owns auth, TLS, port/subdomain assignment, egress policy, and a per-project SQLite record, and proxies agent traffic to agent-server. See `.superpowers/specs/2026-06-09-project-ownership-and-agent-chat-integration-adr.md`. -- `localhost:` — appx dashboard (React SPA + REST API) - - `/` — React SPA (embedded via `go:embed`) - - `/api/*` — REST API (public: `POST /api/login`; protected: everything else) - - `/api/opencode/*` — reverse proxy to OpenCode server (strips prefix, forwards to `localhost:4096`) -- `.localhost:` — reverse proxy to agent-built apps (port 10000–10999) +- `localhost:`: appx dashboard, embedded React SPA, and REST API. +- `/api/*`: public `POST /api/login`, protected everything else. +- `/api/pi/*`: same-origin 1:1 mirror of the agent-server `/v1` contract, consumed by the `@appx-org/agent-chat-ui` SDK. Authorizes project-scoped session traffic against the caller's registered projects (by slug) and never exposes project-lifecycle routes. +- `/api/projects/:id/agent/*`: legacy project-scoped Pi session proxy (no remaining frontend consumer; retained pending cleanup). +- `/api/agent/*`: shared Pi provider auth, subscription login, model, and custom provider proxy. +- `.`: reverse proxy to agent-built apps on assigned ports. -Auth: session cookie (`appx_session`), `Domain=localhost`, `SameSite=Lax`, bcrypt password, rate-limited login. The `Domain=localhost` setting makes the cookie available across all `*.localhost` subdomains so login on the dashboard is shared with project subdomains. - -TLS cert includes `*.localhost` SAN so browsers accept subdomain connections without extra configuration. Modern browsers resolve `*.localhost` to `127.0.0.1` natively. - -OpenCode is HTTP-only by design. Appx terminates TLS and injects auth — the browser never talks to OpenCode directly. +Auth uses a single-user password login with an `appx_session` cookie, bcrypt password hashing, rate-limited login, and 30-day sessions. TLS uses generated self-signed certificates by default or Let's Encrypt with Cloudflare DNS-01 when configured. ## Project Structure -``` -cmd/appx/main.go # Entry point, CLI flags, wires all dependencies +```text +cmd/appx/main.go # Entry point, CLI flags, dependency wiring internal/ + agentserver/ + client.go # Client for agent-server project lifecycle (EnsureProject/DeleteProject) auth/ - auth.go # Auth struct, middleware, session cookie helpers - store.go # Password + session CRUD; generic key-value settings + auth.go # Auth struct, middleware, session cookie helpers + store.go # Password + session CRUD, generic key-value settings db/ - db.go # SQLite connection, versioned migrations runner - migrations/ # Numbered up/down SQL files (golang-migrate) - opencode/ - client.go # HTTP client for OpenCode server (health, auth injection) - startup.go # WaitForHealthy polling + InjectAPIKey on startup - project/ - project.go # Project struct, status constants, sentinel errors - store.go # Project CRUD + TransitionStatus CAS + db.go # SQLite connection and migration runner + migrations/ # Numbered up/down SQL files egress/ - proxy.go # Go CONNECT proxy for egress control - terminal/ - ringbuf.go # Fixed-size circular byte buffer for output replay - manager.go # Session registry: create/close sessions, output pump, subscriber pub/sub - handler.go # WebSocket handler: upgrade, I/O pumps, resize, CSWSH protection + proxy.go # Go CONNECT proxy for agent egress control + store.go # Allowlist and connection log persistence + project/ + manager.go # Project lifecycle: register name with agent-server + appx record (no filesystem scaffolding) + store.go # Project CRUD, port assignment, status transitions server/ - server.go # HTTPS server, TLS config, graceful shutdown - router.go # Route registration, SPA handler, OpenCode proxy, writeJSON - auth_handlers.go # Login/logout handlers - project_handlers.go # Project CRUD + start/stop handlers - settings_handlers.go # API key + settings get/set/delete handlers - middleware.go # Security headers (CSP, HSTS), limitBody - ratelimit.go # IP-based rate limiter + router.go # Route registration, SPA handler, subdomain proxy + agent_proxy.go # agent-server reverse proxies: /api/pi mirror, project-scoped, and global + agent_handlers.go # Pi provider auth and custom-provider handlers + project_handlers.go # Project CRUD and app health shape + settings_handlers.go # Account and app settings + shell_handlers.go # Local PTY shell endpoints + terminal/ + local.go # Local PTY sessions for server/project terminals tls/ - selfsigned.go # Self-signed cert generation with auto-detected SANs + selfsigned.go # Self-signed certificate generation web/src/ - App.tsx # React router (Login / Dashboard / Settings / Project) - api/ - client.ts # Typed API client for appx endpoints - opencode.ts # OpenCode SDK client factory (browser-safe /v2/client import) - lib/ - agent-core/ # Headless core — no React dependency - types.ts # SessionState shape - reducers.ts # Pure event→state reducer for SSE events - connection.ts # SSE subscription, heartbeat, auto-reconnect - agent-react/ # React hooks wrapping agent-core - useSession.ts # Session state via useReducer + SSE + initial load - useEventStream.ts # SSE lifecycle tied to React mount/unmount - usePermissions.ts # Permission/question respond actions - pages/ - Login.tsx # Password login page - Dashboard.tsx # Project list with polling for transitional states - Project.tsx # Full-page project view with Agent/Terminal tabs - Settings.tsx # Anthropic API key management - Egress.tsx # Egress log and allowlist management - components/ - ProjectCard.tsx # Per-project card: status badge, open/start/stop/delete - CreateProjectModal.tsx # New project form with name + port validation - Terminal.tsx # xterm.js wrapper: WebSocket, reconnect, resize - Markdown.tsx # Markdown renderer: marked + DOMPurify + code copy buttons - ToolCallCard.tsx # Collapsible tool call card with status badge - PermissionDock.tsx # Permission request UI: allow/deny/always - QuestionDock.tsx # Agent question UI: options + submit - StatusBar.tsx # Agent status + connection health indicators - agent/ - ChatPanel.tsx # Agent conversation: turns, streaming, parts, docks, abort - SessionList.tsx # Session list: create, select, delete + api/client.ts # Typed Appx API client + pages/Project.tsx # Agent (via @appx-org/agent-chat-ui) and terminal tabs + components/Terminal.tsx # xterm.js wrapper for local PTY shell + pages/Dashboard.tsx # Project list + pages/Project.tsx # Agent and terminal tabs + pages/Settings.tsx # Pi credentials, subscriptions, custom providers deploy/ - appx.service # systemd unit for appx - opencode.service # systemd unit for OpenCode server + appx.service # systemd unit for appx + agent-server.service # systemd unit for Pi agent-server + bootstrap.sh # Full install/update flow + system-setup.sh # Users, directories, services + tools-install.sh # Go, Node.js, Pi, agent-server, Claude Code, uv ``` ## Tech Stack -- **Backend**: Go 1.26, stdlib `net/http` (no framework), `database/sql` + `modernc.org/sqlite` -- **Frontend**: React 19, Vite 8, TypeScript 5.9, react-router-dom 7 -- **DB**: SQLite with WAL mode, versioned migrations via `golang-migrate` (SQL files in `internal/db/migrations/`) -- **Auth**: bcrypt passwords, SHA-256 hashed session tokens, 30-day sessions -- **TLS**: Self-signed ECDSA P-256 certs, auto-renewed 7 days before expiry -- **Agent**: [OpenCode](https://github.com/anomalyco/opencode) — runs as separate process, appx proxies to it -- **Agent SDK**: `@opencode-ai/sdk` — browser-safe client at `/v2/client` entry point (never import bare `@opencode-ai/sdk`) -- **Markdown**: `marked` + `dompurify` for rendering agent responses +- Backend: Go 1.26, stdlib `net/http`, `database/sql` with `modernc.org/sqlite`. +- Frontend: React 19, Vite 8, TypeScript 5.9, react-router-dom 7. +- Agent runtime: Pi CLI plus Appx org `agent-server`. +- Streaming: Appx frontend consumes the agent-server HTTP/SSE session contract. +- Markdown: `marked` + `dompurify`. +- Deployment: Task, systemd, two OS users (`appx` and `appx-agent`) sharing the `projects` group. ## Conventions ### Go -- Every exported and unexported function/method/type must have a doc comment explaining what it does and the context in which it is used. Follow Go convention: start with the name of the identifier, write in complete sentences, and explain _why_ not just _what_ when the purpose isn't obvious from the signature. For handlers, document the HTTP method/path, request/response shape, and auth requirements. For store methods, mention the table(s) they operate on. -- Standard `internal/` layout — no exported packages -- Handlers return `http.HandlerFunc` closures (e.g. `handleLogin(a *auth.Auth) http.HandlerFunc`) -- Dependency injection via struct fields, not globals -- Migrations are numbered functions in `db.go` (`migration1`, `migration2`, ...) added to the `migrations` slice -- Tests use in-memory SQLite (`:memory:`) — no test fixtures or mocks -- Error wrapping with `fmt.Errorf("context: %w", err)` +- Every exported and unexported function, method, and type should have a useful doc comment. For handlers, document method/path, request/response shape, and auth requirements. +- Use dependency injection through parameters or config structs, not package globals. +- Keep handlers as `http.HandlerFunc` closures. +- Tests use in-memory SQLite (`:memory:`) and `httptest`. +- Wrap errors with context using `fmt.Errorf("context: %w", err)`. ### Frontend -- Every exported function and component must have a JSDoc comment (`/** ... */`) explaining its purpose and behavior. For components, describe what the page/component renders and its key interactions. For API functions, document the endpoint, method, and return type. -- Inline styles via `Record` objects (no CSS modules/Tailwind) -- Darksynth cyberpunk aesthetic — always use CSS variables from `web/src/index.css`, never hardcode colours. See [`docs/guides/style-guide.md`](docs/guides/style-guide.md) for the full palette, typography rules, button types, and spacing conventions. -- Appx API client in `web/src/api/client.ts` — all appx endpoint calls go through the `request()` helper -- OpenCode SDK client in `web/src/api/opencode.ts` — use `getClient(directory)` for all OpenCode calls. **Always import from `@opencode-ai/sdk/v2/client`** — the bare `@opencode-ai/sdk` import pulls in Node-only server code and breaks in browsers. -- Agent state lives in `web/src/lib/agent-core/` (pure TypeScript) and `web/src/lib/agent-react/` (React hooks). Do not put agent state logic directly in components. -- On 401, redirect to `/login` +- Every exported function and component should have a JSDoc comment. +- Keep endpoint calls in `web/src/api/client.ts`. +- Use the existing dark Appx design tokens from `web/src/index.css`; avoid one-off hardcoded colors unless a component already does so. +- Agent chat UI is provided by the `@appx-org/agent-chat-ui` package (linked via a `file:` dependency to the sibling `agent-chat` repo and consumed as TypeScript source). It talks to the `/api/pi` mirror; do not reintroduce a hand-written session store/reducer. Re-theme via the `--ac-*` token bridge in `web/src/index.css`. +- On 401, redirect to `/login`. ### Build -- Uses [Task](https://taskfile.dev) (`Taskfile.yml`) instead of Make. Run `task --list` to see all targets. -- `task build` builds frontend first (with file-based caching via `sources`/`generates`), copies `web/dist` into `cmd/appx/web/dist`, then `go build` -- Frontend is embedded into the Go binary via `//go:embed web/dist/*` - -## Verification Loop (mandatory) - -Every code change — new feature, bug fix, refactor, or modification — MUST go through this verification loop before the work is considered done. No exceptions. Do not skip steps or defer them. - -### 1. Build and compile - -Run `task build` (or `task web` for frontend-only changes). The change is not valid if it does not compile cleanly. - -### 2. Run existing tests - -Run `task test` and `task lint`. All existing tests must pass. If a change breaks an existing test, fix the root cause — do not delete or weaken the test. - -### 3. Write new tests - -Every change must include at least one new or updated test that specifically covers the introduced behavior. Follow these guidelines: - -- **New API endpoint**: Add request/response tests in `router_test.go` covering success, auth failure, and validation error cases. Use the `setupTest()` helper. -- **New store/data method**: Add unit tests in the corresponding `*_test.go` file using in-memory SQLite. -- **New migration**: Add a test in `db_test.go` that verifies the new table/column exists and works. -- **Bug fix**: Add a regression test that reproduces the bug and proves it is fixed. -- **Refactor**: Existing tests should still pass. If coverage gaps are found, add tests before refactoring. -- **Frontend logic**: If the change involves non-trivial logic (state management, API integration, conditional rendering), describe how you verified it manually and what a test would cover. - -### 4. Manual verification - -Simulate what a real user would experience. Think about it from the end user perspective: - -- **Backend changes**: Use `curl` or `httptest` to exercise the endpoint end-to-end. Verify response status codes, headers, body shape, and cookie behavior. Test both happy path and error cases. -- **Frontend changes**: Build with `task build`, run the server, and verify the UI renders correctly. Check that navigation, forms, error states, and loading states work as expected. -- **Database changes**: Verify migrations run on a fresh database (`rm -rf data/ && ./appx`). Confirm data survives a restart. -- **Auth changes**: Verify authenticated and unauthenticated access. Check that session cookies are set/cleared correctly. -- **TLS changes**: Verify cert generation and HTTPS connection with `curl -kv`. - -### 5. Run full suite again +- Use Task targets from `Taskfile.yml`. +- `task build` builds the frontend, copies `web/dist` into `cmd/appx/web/dist`, then builds Go. +- The frontend is embedded in the Go binary via `go:embed`. -After writing new tests and making any adjustments, run `task test` one final time to confirm everything passes together. +## Verification Loop -### Summary checklist +For any code change, run the narrowest useful checks while iterating and finish with: +```text +[ ] task build +[ ] task test +[ ] task lint +[ ] Manual verification for affected UI/API/deploy behavior ``` -[ ] task build — compiles cleanly -[ ] task test — all existing tests pass -[ ] New/updated tests written for the change -[ ] Manual verification performed (describe what was checked) -[ ] task test — full suite passes with new tests included -``` - -If any step fails, fix the issue and restart the loop from step 1. Do not proceed to the next task until all five steps are green. - -## Adding a New API Endpoint - -1. Add handler function in the appropriate `internal/server/*_handlers.go` file -2. Register the route in `NewRouter()` in `router.go` — public routes on `mux`, protected routes on `api` -3. Add corresponding function in `web/src/api/client.ts` -4. Write a test in `router_test.go` using `setupTest()` helper — note it returns `(handler, store, db)` - -## Adding a New Migration -1. Add `migrationN` function in `internal/db/db.go` -2. Append it to the `migrations` slice -3. Add a test case in `db_test.go` +Add or update tests when behavior changes, especially for server routes, database migrations, store methods, and regression fixes. -## Open Source +## Deployment Notes -This project is intended to be open sourced. No credentials, personal data, or internal hostnames in code or comments. +- `deploy/bootstrap.sh` is first-run setup. +- `task server:deploy` pulls, rebuilds, installs, restarts `agent-server` and `appx`, then verifies. +- The active agent service user is `appx-agent`. +- Pi credentials live under the agent service user's Pi storage and are managed through Settings. +- Provider traffic from Pi goes through the Appx egress proxy; loopback traffic to agent-server stays local. +- The HSTS header includes `includeSubDomains`. Do not point appx at a shared domain that also hosts HTTP services on subdomains. ## Documentation -After implementing any change, update all relevant documentation in `docs/` to reflect the new behaviour. Do not leave docs describing the old implementation. - -### Architecture Documentation - -Deep-dive references for understanding the system — read these before making significant changes: - -- `docs/architecture/arch_phase_1.md` — Foundation: HTTPS server, TLS, auth, session middleware, SPA serving -- `docs/architecture/arch_phase_3.md` — In-browser terminal: ring buffer, session manager, WebSocket handler (I/O pumps, resize, CSWSH), persistent sessions, cleanup hooks, xterm.js frontend, reconnection -- `docs/architecture/arch_auth_system.md` — Auth system deep-dive: password hashing, session tokens, cookie security, rate limiting, security headers, middleware wiring, and known pitfalls -- `docs/architecture/arch_phase_5.md` — Phase 5 de-Docker simplification: single OpenCode process, appx-assigned ports, TCP health checker, AGENTS.md scaffolding, OpenCode SDK agent UI, Go CONNECT egress proxy with allowlist and logging, subdomain routing, --http dev mode, SameSite=Lax cookie scoping -- `docs/architecture/arch_phase_5_5a.md` — Full branch deep-dive: de-Docker architecture, egress CONNECT proxy, OpenCode proxy handler, concurrent health checks, headless agent-core (types/reducers/connection), agent-react hooks (useSession, useEventStream, usePermissions), ChatPanel/SessionList components, pitfalls and fixed issues -- `docs/architecture/arch_phase_5b_self_hosting.md` — Self-hosting deployment: bootstrap pipeline, OS users/groups, directory permissions, tools installation, systemd services, verification suite, security isolation model -- `docs/architecture/appx_knos_v1.md` — Two-product architecture (Appx + Knos): deployment model, OpenCode proxy design, frontend headless core pattern, offline sync strategy -- `docs/plans/phase_5a_plan.md` — Custom agent frontend plan: feature inventory (P0-P3), headless core architecture, SDK browser compatibility, implementation details with OpenCode source references - -## Conventions (meta) - -When the user says **"Memorise please: \"**, rephrase the statement optimally for CLAUDE.md and append it to the relevant section (or create a new section if needed). - -## Current State - -Phase 5 + Phase 5a complete. - -**Phase 5 (de-Docker):** Per-project Docker containers removed. A single `opencode serve` process on `localhost:4096` manages all projects natively via `x-opencode-directory` header scoping. Appx is the management shell: auth, TLS, `/api/opencode/*` reverse proxy, subdomain proxy for agent-built apps (ports 10000–10999), egress control, `--http` dev mode. - -**Phase 5a (custom agent UI):** Full agent UI built on OpenCode SDK. Headless core (`web/src/lib/agent-core/`) manages session state via pure event reducers on SSE events. React hooks (`web/src/lib/agent-react/`) adapt the core for components. UI features: markdown rendering, streaming tool call cards, permission dock, question dock, session management with create/select/delete, agent status + connection health indicators, abort button. - -**Key SDK note:** OpenCode SDK must be imported from `@opencode-ai/sdk/v2/client` (browser-safe). The bare `@opencode-ai/sdk` import pulls in Node-only server code. - -**OpenCode is a prerequisite:** Must run as a separate process (`opencode serve --hostname 127.0.0.1 --port 4096`) before appx starts. See `deploy/opencode.service` for systemd setup. - -**Next up:** Phase 6 (installer, OS users, iptables enforcement) and subdomain routing for OpenCode (`oc.localhost` instead of `/api/opencode/*` path prefix). +After implementing behavior changes, update `README.md`, this file, and any current docs that describe the changed behavior. Historical planning documents may describe old phases, but active development docs should reflect the current Pi-only architecture. ## Superpowers -All brainstorm specs, implementation plans, and design documents generated by superpowers skills go in `.superpowers/specs/`. Use the naming convention `YYYY-MM-DD--.md` (e.g. `2026-04-12-terminal-unify-design.md`). - -## Deployment Note - -The HSTS header includes `includeSubDomains`. When deploying with `--domain example.com`, this forces all subdomains of `example.com` to HTTPS for 2 years in browsers that visit appx. Do not point appx at a shared domain that also hosts HTTP services on subdomains. +All brainstorm specs, implementation plans, and design documents generated by superpowers skills go in `.superpowers/specs/`. Use the naming convention `YYYY-MM-DD--.md`. diff --git a/README.md b/README.md index f670a69..0f892b7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Appx -Agentic Application Proxy — self-hostable tool to build and host personal apps with AI agents powered by [OpenCode](https://github.com/anomalyco/opencode). +Agentic Application Proxy — self-hostable tool to build and host personal apps with AI agents powered by Pi. ## What it does -Appx is a management shell for running OpenCode agents on a remote server. It provides authentication, TLS termination, a web dashboard, and a reverse proxy — so you can manage projects, chat with agents, and access agent-built apps from a browser over HTTPS. +Appx is a management shell for running coding agents on a remote server. It provides authentication, TLS termination, a web dashboard, and a reverse proxy — so you can manage projects, chat with agents, and access agent-built apps from a browser over HTTPS. ## Architecture @@ -13,13 +13,17 @@ Browser └── HTTPS (single port) ├── / React SPA (embedded in binary) ├── /api/* REST API (auth, projects, settings) - ├── /api/opencode/* Reverse proxy → OpenCode server + ├── /api/pi/* → agent-server /v1 mirror (agent-chat-ui SDK; project-scoped sessions + models) + ├── /api/agent/* → Pi agent-server shared auth/model proxy └── . Reverse proxy → agent-built apps ``` -Everything is a single Go binary. The React frontend is compiled and embedded at build time. State lives in a SQLite database on disk. +Appx itself is a single Go binary. The React frontend is compiled and embedded +at build time. State lives in a SQLite database on disk. -OpenCode runs as a **separate process** on `localhost:4096` and handles all AI agent work (sessions, tool execution, file editing, terminal). Appx proxies requests to it and adds auth + TLS on top. +Pi is installed as the default agent runtime. systemd runs `agent-server` on +`localhost:4001`; agent-server owns project identity, directories, and sessions +while sharing one set of Pi credentials, and Appx proxies session traffic to it. **Auth model**: single user, password login, session cookie. On first run a random password is generated and printed to stdout. @@ -81,9 +85,7 @@ If you want to use a persistent volume for storage (e.g. Hetzner Cloud Volumes), The config is saved to `/etc/appx/appx.env` and reused on subsequent runs. To change it later: `sudo nano /etc/appx/appx.env && sudo systemctl restart appx`. -Bootstrap then creates OS users with proper isolation, installs tools (Node.js, OpenCode, Claude Code, uv), sets up systemd services, starts everything, and runs a verification suite. - -During Opencode installation you might be prompted "opencode is installed to /usr/local/bin/opencode and may be managed by a package manager". Select `Install anyways? Yes` +Bootstrap then creates OS users with proper isolation, installs tools (Node.js, Pi, Claude Code, uv, and agent-server), sets up systemd services, starts everything, and runs a verification suite. The Appx UI proxies project agent sessions to project-scoped `agent-server` runtimes and proxies provider-auth, subscription login, and custom-provider requests to shared Pi agent settings at `APPX_AGENT_SERVER_URL` (default `http://127.0.0.1:4001`). The Pi agent service runs with `NODE_USE_ENV_PROXY=1`, `HTTPS_PROXY=http://127.0.0.1:9080`, and `NO_PROXY=localhost,127.0.0.1`, so provider traffic goes through the Appx egress allowlist while local agent traffic stays on loopback. On first run, a random password is written to `{data-dir}/initial_password`. Delete the file after saving your password. @@ -93,7 +95,8 @@ Bootstrap installs these tools system-wide so agents can use them in the termina - **Go** — compiled from the version in `go.mod` - **Node.js 24 / npm** — JavaScript/TypeScript projects (installed via nvm, pinned to major version 24) - **uv** — Python version and package management (self-update: `uv self update`) -- **OpenCode** — AI agent backend (pinned version in `deploy/opencode-version`) +- **Pi** — AI coding agent CLI/SDK (pinned version in `deploy/pi-version`) +- **agent-server** — separate Appx org service that exposes Pi sessions over HTTP/SSE for the Agent tab - **Claude Code** — Claude CLI for terminal use (self-update: `sudo npm update -g @anthropic-ai/claude-code`) ### Updating appx @@ -105,11 +108,11 @@ cd /srv/appx task server:deploy ``` -Pulls latest code, rebuilds, installs the binary, updates OpenCode to the pinned version, and restarts both services. +Pulls latest code, rebuilds, installs the binary, updates Pi/agent-server to the pinned versions, and restarts the needed services. -### Updating OpenCode version +### Updating Pi version -Edit `deploy/opencode-version` to the new version, then: +Edit `deploy/pi-version` to the new version, then: ```bash cd /srv/appx @@ -135,8 +138,8 @@ Checks users, permissions, isolation, tools, service files, and runtime. Exits 0 ### Troubleshoot ```bash -journalctl -u appx -f # appx logs -journalctl -u opencode -f # opencode logs +journalctl -u appx -f # appx logs +journalctl -u agent-server -f # Pi agent-server logs ``` ### Deploy scripts @@ -144,18 +147,52 @@ journalctl -u opencode -f # opencode logs | File / Script | When | What | | ------------------------------- | ---------------- | ---------------------------------------------------------- | | `deploy/bootstrap.sh` | Day 1 | Full setup: users, dirs, tools, build, start, verify | -| `deploy/system-setup.sh` | Infra changes | Users, groups, directories, service files, opencode config | -| `deploy/tools-install.sh` | Tool updates | Go, Node.js 24, OpenCode (pinned), Claude Code, uv | -| `deploy/opencode.json` | Model changes | Default OpenCode model config (copied to opencode home) | -| `deploy/opencode-version` | Version pin | Pinned OpenCode version installed by tools-install | +| `deploy/system-setup.sh` | Infra changes | Users, groups, directories, service files, agent config | +| `deploy/tools-install.sh` | Tool updates | Go, Node.js 24, Pi, agent-server, Claude Code, uv | +| `deploy/agent-server.service` | Pi backend | Systemd unit for project-scoped Pi session service | +| `deploy/pi-version` | Version pin | Pinned Pi version installed by tools-install | | `deploy/verify-installation.sh` | After any change | Full system verification | ## Local development -OpenCode must be running before starting appx: +### Temporary hack: link the `agent-chat` SDK locally + +The Agent tab UI is provided by the `@appx-org/agent-chat-ui` package. Until that +package is published to GitHub Packages, `web/package.json` links it from a +**sibling checkout** via a `file:` dependency (`file:../../agent-chat`), so the +`agent-chat` repo must be cloned next to `appx` (both under the same parent): + +```text +/ +├── appx/ ← this repo +└── agent-chat/ ← github.com/appx-org/agent-chat +``` ```bash -opencode serve --hostname 127.0.0.1 --port 4096 +# one-time, beside your appx checkout +git clone https://github.com/appx-org/agent-chat.git ../agent-chat +# the package ships TypeScript source consumed directly by appx's Vite build, +# so its own deps must be installed once for the symlinked import to resolve +cd ../agent-chat && npm install && cd - +``` + +`task web` / `task build` then follow the symlink and compile the SDK source as +part of the frontend bundle. Vite dedupes React (see `web/vite.config.ts`) so +the symlink can't pull a second React copy. When the package is published this +`file:` spec swaps back to a semver range and the clone step goes away. + +### Run agent-server, then appx + +Run the sibling `agent-server` before starting appx. It needs `WORKSPACE_DIR` +pointed at the **same** directory appx uses for projects (co-located dev), since +agent-server owns the project directories and appx's subdomain proxy/terminal +read them from that shared path: + +```bash +cd ../agent-server +WORKSPACE_DIR=/path/to/appx-data/projects \ +AGENT_SERVER_PORT=4001 \ +npm run dev ``` Then start appx with `--host 127.0.0.1.sslip.io` so that subdomain routing and session cookies work correctly across project subdomains. Plain `localhost` has inconsistent cookie-sharing behaviour for subdomains across browsers. @@ -179,6 +216,27 @@ All state lives in the data directory (configured during bootstrap, default `/va | SQLite DB, TLS certs, secrets | `{data}/.appx-internals/` | appx only | | Project directories | `{data}/projects/` | shared | +Each new project's directory is created and owned by `agent-server` (under its +`WORKSPACE_DIR`, which is the shared `{data}/projects/` path in a co-located +deployment). The project's Pi harness (`{data}/projects//.pi/`) is owned by +agent-server and currently starts empty — appx no longer scaffolds a prompt, +guardrail extension, or egress skill into it (see +`.superpowers/specs/2026-06-09-project-ownership-and-agent-chat-integration-adr.md`). +Reintroducing harness defaults/templates is tracked as future work. + +Pi credentials are configured from Settings. Built-in providers can use stored +API keys or Pi subscription auth where the provider supports it, and custom +providers such as LiteLLM are written to the agent service user's +`models.json` without exposing secret values back to the browser. + +The Agent tab is the `@appx-org/agent-chat-ui` SDK talking to Appx's same-origin +`/api/pi/*` mirror, which proxies the `agent-server` `/v1` session contract +(keeping the bearer token server-side). `agent-server` turns all supported Pi +providers into the same session HTTP/SSE contract, so the SDK handles Pi +`message_update` events by `contentIndex` for text and tool-call blocks. Pi +extension UI requests, including Appx guardrail approvals for risky commands, are +delivered over the same session stream and answered through the mirror. + To use a mounted volume, specify the path when bootstrap prompts for "Data directory". Bootstrap automatically creates the subdirectories with correct permissions. ## Subdomain routing without a domain (sslip.io) @@ -232,11 +290,11 @@ Bootstrap creates two OS users with a shared `projects` group: ``` appx — runs the appx server, owns DB and TLS certs -opencode — runs OpenCode, cannot access appx data +appx-agent — isolated agent user for Pi tooling, cannot access appx data projects — shared group, both users read/write project directories ``` -Directory permissions prevent OpenCode (and any agent it spawns) from accessing the appx database, TLS keys, or binary. Project directories use setgid so files created by either user are accessible to both. +Directory permissions prevent agent tooling from accessing the appx database, TLS keys, or binary. Project directories use setgid so files created by either user are accessible to both. ## Development diff --git a/Taskfile.yml b/Taskfile.yml index f69f37c..a4ed226 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -16,11 +16,17 @@ tasks: - web/package-lock.json - web/vite.config.ts - web/tsconfig*.json + # Linked SDK source: @appx-org/agent-chat-ui is a file: dependency on the + # sibling agent-chat repo and is bundled from source, so edits there must + # invalidate this task. In CI/prod the package is installed from the + # registry and this path matches nothing, so the fingerprint quietly falls + # back to the entries above (Task ignores non-matching globs). + - ../agent-chat/src/**/* generates: - cmd/appx/web/dist/**/* build: - desc: Build frontend and Go binary + desc: Build frontend and Go binary (use `task build --force` to force a full rebuild, e.g. after editing the linked agent-chat-ui) deps: [web] env: GOCACHE: "{{.ROOT_DIR}}/.go-build-cache" @@ -28,7 +34,7 @@ tasks: - go build -o appx ./cmd/appx local: - desc: Build and run appx in HTTP dev mode with sslip.io subdomain routing + desc: Build and run appx in HTTP dev mode with sslip.io subdomain routing (use `task local --force` to force a full rebuild) deps: [build] cmds: - ./appx --http --host 127.0.0.1.sslip.io @@ -66,7 +72,8 @@ tasks: - sudo install -m 750 -o root -g appx ./appx /usr/local/bin/appx - sudo ./deploy/tools-install.sh - sudo ./deploy/system-setup.sh - - sudo systemctl restart opencode appx + - sudo systemctl stop opencode 2>/dev/null || true + - sudo systemctl restart agent-server appx - sudo ./deploy/verify-installation.sh server:verify: diff --git a/cmd/appx/main.go b/cmd/appx/main.go index 74744b3..4e5ecf6 100644 --- a/cmd/appx/main.go +++ b/cmd/appx/main.go @@ -9,14 +9,13 @@ import ( "log" "os" "path/filepath" - "time" "strconv" + "github.com/neuromaxer/appx/internal/agentserver" "github.com/neuromaxer/appx/internal/auth" "github.com/neuromaxer/appx/internal/db" "github.com/neuromaxer/appx/internal/egress" - "github.com/neuromaxer/appx/internal/opencode" "github.com/neuromaxer/appx/internal/project" "github.com/neuromaxer/appx/internal/server" "github.com/neuromaxer/appx/internal/terminal" @@ -159,25 +158,18 @@ func main() { pm := project.NewManager(projectStore, projectRoot) pm.BaseDomain = baseDomain - // Initialize OpenCode client. OpenCode runs as a separate process on - // localhost:4096. Poll until healthy, then inject the Anthropic API key. - ocClient := opencode.NewClient("http://127.0.0.1:4096") + agentServerURL := envOr("APPX_AGENT_SERVER_URL", "http://127.0.0.1:4001") + agentServerToken := os.Getenv("APPX_AGENT_SERVER_TOKEN") + log.Printf("agent backend: pi (%s)", agentServerURL) - // Resolve Anthropic API key: DB setting takes priority, then env var. - anthropicKey, _ := authStore.GetSetting("anthropic_api_key") - if anthropicKey == "" { - anthropicKey = os.Getenv("ANTHROPIC_API_KEY") + // agent-server owns project runtimes; appx registers/removes projects through it. + pm.Agent = agentserver.NewClient(agentServerURL, agentServerToken) + // Best-effort: re-register known projects so existing projects work and an + // agent-server restart is transparent. Idempotent on the agent-server side. + if err := pm.ReconcileAgentProjects(context.Background()); err != nil { + log.Printf("warning: agent-server project reconcile incomplete: %v", err) } - // Start OpenCode polling in background — does not block server startup. - go func() { - pollCtx, pollCancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer pollCancel() - if err := ocClient.InjectAPIKey(pollCtx, 2*time.Second, anthropicKey); err != nil { - log.Printf("opencode: startup polling failed: %v", err) - } - }() - webFS, err := fs.Sub(webEmbed, "web/dist") if err != nil { log.Fatalf("embed fs: %v", err) @@ -191,22 +183,23 @@ func main() { localManager := terminal.NewLocalManager(512 * 1024) // 512 KB ring buffer if err := server.Run(server.Config{ - Port: *port, - InternalsDir: internalsDir, - DB: database, - AuthStore: authStore, - ProjectManager: pm, - WebFS: webFS, - TLSHosts: hosts, - Domain: *domain, - CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"), - HTTPMode: *httpMode, - BaseDomain: baseDomain, - HostAliases: hosts, - OpenCodeClient: ocClient, - EgressStore: egressStore, - EgressPending: pendingRegistry, - LocalManager: localManager, + Port: *port, + InternalsDir: internalsDir, + DB: database, + AuthStore: authStore, + ProjectManager: pm, + WebFS: webFS, + TLSHosts: hosts, + Domain: *domain, + CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"), + HTTPMode: *httpMode, + BaseDomain: baseDomain, + HostAliases: hosts, + AgentServerURL: agentServerURL, + AgentServerToken: agentServerToken, + EgressStore: egressStore, + EgressPending: pendingRegistry, + LocalManager: localManager, }); err != nil { log.Fatal(err) } diff --git a/deploy/AGENTS.md b/deploy/AGENTS.md deleted file mode 100644 index 442c39d..0000000 --- a/deploy/AGENTS.md +++ /dev/null @@ -1,13 +0,0 @@ -## Egress Access - -When a command fails with "destination not in allowlist" or a similar proxy/network -error for an external host, request access by running: - - curl -s -X POST http://127.0.0.1:9081/egress/request \ - -H 'Content-Type: application/json' \ - -d '{"host":"","port":,"reason":""}' - -This blocks until the user approves or denies (up to 60 seconds). If approved, -retry the original command. If denied or timed out, inform the user and stop. - -Do not request access to hosts that are not strictly necessary for the task. diff --git a/deploy/agent-server.service b/deploy/agent-server.service new file mode 100644 index 0000000..907a77e --- /dev/null +++ b/deploy/agent-server.service @@ -0,0 +1,41 @@ +[Unit] +Description=Appx Pi Agent Server +Documentation=https://github.com/appx-org/agent-server +After=network.target +Before=appx.service + +[Service] +User=appx-agent +Group=appx-agent +UMask=0007 + +EnvironmentFile=/etc/appx/appx.env +Environment=HOME=/home/appx-agent + +# Route provider HTTPS traffic through appx's egress CONNECT proxy on 127.0.0.1:9080. +# This keeps Pi model calls behind the same allowlist and request logging as +# other agent network traffic. Node.js needs NODE_USE_ENV_PROXY=1 before +# fetch/core HTTP clients honor HTTPS_PROXY/NO_PROXY. +Environment=NODE_USE_ENV_PROXY=1 +Environment=HTTPS_PROXY=http://127.0.0.1:9080 +Environment=NO_PROXY=localhost,127.0.0.1 + +Environment=AGENT_SERVER_MODE=multi +Environment=PROJECT_DIR=__APPX_PROJECTS_DIR__ +Environment=SESSIONS_DIR=/home/appx-agent/.pi/agent/appx-default-sessions +Environment=AGENT_DIR=/home/appx-agent/.pi/agent +Environment=AGENTS_FILE=.pi/AGENTS.md +Environment=AGENT_SERVER_HOST=127.0.0.1 +Environment=AGENT_SERVER_PORT=4001 + +WorkingDirectory=__APPX_PROJECTS_DIR__ +ExecStart=/usr/local/bin/agent-server + +Restart=on-failure +RestartSec=5 + +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/deploy/appx.service b/deploy/appx.service index 7ea34f5..316b7d0 100644 --- a/deploy/appx.service +++ b/deploy/appx.service @@ -1,7 +1,7 @@ [Unit] Description=Appx — Agentic Application Proxy Documentation=https://github.com/neuromaxer/appx -After=network.target opencode.service +After=network.target [Service] User=appx @@ -13,7 +13,7 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE # Ensure directories created by appx (project subdirs) have group-write so the -# opencode user can write files via the shared projects group. +# agent service user can write files via the shared projects group. UMask=0007 # Server-specific config lives in /etc/appx/appx.env (created by bootstrap). diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index f1543ba..d807d5b 100755 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -83,17 +83,20 @@ else # APPX_PORT=443 # APPX_DOMAIN=app.example.com # CLOUDFLARE_API_TOKEN=your_token_here +# APPX_AGENT_SERVER_URL=http://127.0.0.1:4001 # # All variables: # APPX_HOST — server hostname for TLS cert and routing (default: .sslip.io) # APPX_DATA — data directory: DB, TLS certs, projects (default: /var/lib/appx) # APPX_PORT — listen port (default: 443). MUST be open in firewall +# APPX_AGENT_SERVER_URL — Pi agent-server URL used by the Appx proxy # APPX_DOMAIN — domain for Let's Encrypt via Cloudflare DNS-01 (optional) # CLOUDFLARE_API_TOKEN — Cloudflare API token for DNS-01 challenge (optional) APPX_HOST=$APPX_HOST APPX_DATA=$APPX_DATA APPX_PORT=$APPX_PORT +APPX_AGENT_SERVER_URL=http://127.0.0.1:4001 # APPX_DOMAIN= # CLOUDFLARE_API_TOKEN= EOF @@ -113,7 +116,7 @@ STEP="system-setup" echo "" # --------------------------------------------------------------------------- -# 3. Install tools: node, opencode (pinned), claude, uv. +# 3. Install tools: node, Pi, agent-server, claude, uv. # --------------------------------------------------------------------------- STEP="tools-install" @@ -167,13 +170,13 @@ echo "" STEP="restart-services" echo "stopping services..." -systemctl stop opencode appx 2>/dev/null || true +systemctl stop agent-server opencode appx 2>/dev/null || true sleep 2 echo "starting services..." -systemctl start opencode appx -echo "waiting for services to be ready..." +systemctl start agent-server appx +echo "waiting for agent-server to be ready..." for i in $(seq 1 10); do - curl -sf http://127.0.0.1:4096/health >/dev/null 2>&1 && break + curl -sf http://127.0.0.1:4001/v1/healthz >/dev/null 2>&1 && break sleep 2 done echo "services started" @@ -207,5 +210,5 @@ if [ -n "$APPX_HOST_VAL" ]; then echo " Visit: https://${APPX_HOST_VAL}:${APPX_PORT_VAL}" fi fi -echo " Log in and set your Anthropic API key in Settings." +echo " Open Settings to configure Pi credentials and models." echo "========================================" diff --git a/deploy/opencode-version b/deploy/opencode-version deleted file mode 100644 index 37957a3..0000000 --- a/deploy/opencode-version +++ /dev/null @@ -1 +0,0 @@ -v1.14.24 \ No newline at end of file diff --git a/deploy/opencode.json b/deploy/opencode.json deleted file mode 100644 index abd2fef..0000000 --- a/deploy/opencode.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "model": "anthropic/claude-sonnet-4-6" -} diff --git a/deploy/opencode.service b/deploy/opencode.service deleted file mode 100644 index e5eca53..0000000 --- a/deploy/opencode.service +++ /dev/null @@ -1,41 +0,0 @@ -[Unit] -Description=OpenCode Server — AI agent backend for Appx -Documentation=https://github.com/sst/opencode -After=network.target -Before=appx.service -# Restart aggressively — if opencode crashes, appx will reconnect automatically -# once systemd revives it. -StartLimitIntervalSec=60 -StartLimitBurst=5 - -[Service] -User=opencode -Group=opencode - -Environment=HOME=/home/opencode - -# Route all HTTPS traffic through appx's egress CONNECT proxy on 127.0.0.1:9080. -# This provides allowlist enforcement and request logging via the Egress UI. -# -# NOTE: opencode starts before appx (Before=appx.service), so there is a brief -# window at startup where the proxy isn't available yet. This is harmless — -# opencode doesn't make outbound HTTPS requests during its own startup. Agent -# HTTPS calls only happen after a user sends a message, by which time appx -# (and the egress proxy) is running. -Environment=HTTPS_PROXY=http://127.0.0.1:9080 -# Prevent internal traffic (localhost API calls) from going through the proxy. -Environment=NO_PROXY=localhost,127.0.0.1 - -# Binary installed to /usr/local/bin by deploy/tools-install.sh. -# WorkingDirectory is rewritten by system-setup.sh to the shared projects dir. -ExecStart=/usr/local/bin/opencode serve --hostname 127.0.0.1 --port 4096 -WorkingDirectory=/var/lib/appx/projects - -Restart=always -RestartSec=5 - -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=multi-user.target diff --git a/deploy/pi-version b/deploy/pi-version new file mode 100644 index 0000000..62474aa --- /dev/null +++ b/deploy/pi-version @@ -0,0 +1 @@ +0.75.4 diff --git a/deploy/system-setup.sh b/deploy/system-setup.sh index f5c2274..6339004 100755 --- a/deploy/system-setup.sh +++ b/deploy/system-setup.sh @@ -1,19 +1,19 @@ #!/usr/bin/env bash # deploy/system-setup.sh — create OS users, groups, directories, and install -# systemd service files for appx and opencode. +# systemd service files for appx plus the Pi agent backend. # # Must be run as root. Safe to run multiple times (idempotent). # # What this script does: # 1. Reads APPX_DATA from /etc/appx/appx.env (falls back to /var/lib/appx) -# 2. Creates appx and opencode users with login shells (/bin/bash) +# 2. Creates appx and appx-agent users with login shells (/bin/bash) # — appx user's home dir is set to the data directory # 3. Creates a shared "projects" group for project directory access # 4. Sets up directories with correct ownership and permissions # 5. Copies systemd service files and enables them # # What this script does NOT do: -# - Install Go, Node, opencode, or claude binaries (use tools-install.sh) +# - Install Go, Node, Pi, agent-server, or Claude binaries (use tools-install.sh) # - Copy the appx binary (handled by bootstrap.sh / server:deploy) set -euo pipefail @@ -38,12 +38,13 @@ if [ -f /etc/appx/appx.env ]; then fi fi echo "data directory: $DATA_DIR" +echo "agent backend: pi" # --------------------------------------------------------------------------- # OS users and groups # --------------------------------------------------------------------------- -# Shared group — both users get read/write access to project directories. +# Shared group — appx and appx-agent get read/write access to project directories. if ! getent group projects >/dev/null 2>&1; then groupadd --system projects echo "created group: projects" @@ -70,22 +71,26 @@ else fi fi -# opencode user — runs the opencode server process. -if ! id -u opencode >/dev/null 2>&1; then - useradd --system --create-home --shell /bin/bash --home-dir /home/opencode \ - --groups projects opencode - echo "created user: opencode" +# appx-agent user — runs the Pi agent-server process. +if ! getent group appx-agent >/dev/null 2>&1; then + groupadd --system appx-agent + echo "created group: appx-agent" +fi +if ! id -u appx-agent >/dev/null 2>&1; then + useradd --system --create-home --shell /bin/bash --home-dir /home/appx-agent \ + --gid appx-agent --groups projects appx-agent + echo "created user: appx-agent" else - usermod --shell /bin/bash --append --groups projects opencode || true - echo "user opencode already exists (updated shell and groups)" + usermod --shell /bin/bash --home /home/appx-agent --append --groups projects appx-agent || true + echo "user appx-agent already exists (updated shell, home, and groups)" fi # --------------------------------------------------------------------------- # Directories # --------------------------------------------------------------------------- -# Data dir: appx owns it. Accessible for traversal by others (opencode needs -# to reach the projects/ subdirectory inside it). +# Data dir: appx owns it. Accessible for traversal by the agent user so it can +# reach the projects/ subdirectory inside it. install -d -o appx -g appx -m 755 "$DATA_DIR" echo "directory ready: $DATA_DIR (appx:appx 755)" @@ -93,39 +98,29 @@ echo "directory ready: $DATA_DIR (appx:appx 755)" install -d -o appx -g appx -m 700 "$DATA_DIR/.appx-internals" echo "directory ready: $DATA_DIR/.appx-internals (appx:appx 700)" -# Projects subdir: shared workspace for appx and opencode. +# Projects subdir: shared workspace for appx and appx-agent. # Setgid ensures new files inherit the projects group. install -d -o appx -g projects -m 2770 "$DATA_DIR/projects" echo "directory ready: $DATA_DIR/projects (appx:projects 2770)" -# /home/opencode: opencode workspace. -install -d -o opencode -g opencode -m 700 /home/opencode -echo "directory ready: /home/opencode (opencode:opencode 700)" - -# OpenCode config: pin the default model to the Anthropic BYOK provider so -# that API calls go directly to api.anthropic.com using the injected key, -# rather than routing through the opencode.ai zen proxy (which requires a -# separate OpenCode account key). -OC_CONFIG_DIR="/home/opencode/.config/opencode" -OC_CONFIG_FILE="$OC_CONFIG_DIR/opencode.json" -install -d -o opencode -g opencode -m 700 "$OC_CONFIG_DIR" -if [ ! -f "$OC_CONFIG_FILE" ]; then - install -m 600 -o opencode -g opencode "$SCRIPT_DIR/opencode.json" "$OC_CONFIG_FILE" - echo "wrote opencode config → $OC_CONFIG_FILE" -else - echo "opencode config already exists: $OC_CONFIG_FILE" +# /home/appx-agent: private Pi agent workspace. +install -d -o appx-agent -g appx-agent -m 700 /home/appx-agent +echo "directory ready: /home/appx-agent (appx-agent:appx-agent 700)" +if [ ! -d /home/appx-agent/.pi ] && [ -d /home/opencode/.pi ]; then + cp -a /home/opencode/.pi /home/appx-agent/.pi + chown -R appx-agent:appx-agent /home/appx-agent/.pi + chmod 700 /home/appx-agent/.pi /home/appx-agent/.pi/agent 2>/dev/null || true + echo "migrated existing Pi agent data to /home/appx-agent/.pi" fi -# AGENTS.md: global rules for the OpenCode agent, including egress access -# request instructions. Copied only on first setup — user customizations -# are preserved on subsequent runs. -OC_AGENTS_FILE="$OC_CONFIG_DIR/AGENTS.md" -if [ ! -f "$OC_AGENTS_FILE" ]; then - install -m 600 -o opencode -g opencode "$SCRIPT_DIR/AGENTS.md" "$OC_AGENTS_FILE" - echo "wrote agents rules → $OC_AGENTS_FILE" -else - echo "agents rules already exist: $OC_AGENTS_FILE" -fi +# Pi agent config/auth/cache dir. Pi is project-local for prompts, skills, and +# extensions, but auth/models/settings that should not live in project repos go +# under the agent user's private home directory. +PI_AGENT_DIR="/home/appx-agent/.pi/agent" +install -d -o appx-agent -g appx-agent -m 700 "$PI_AGENT_DIR" +install -d -o appx-agent -g appx-agent -m 700 "$PI_AGENT_DIR/npm" +install -d -o appx-agent -g appx-agent -m 700 "$PI_AGENT_DIR/git" +echo "directory ready: $PI_AGENT_DIR (appx-agent:appx-agent 700)" # --------------------------------------------------------------------------- # Appx binary permissions (if binary already deployed) @@ -143,19 +138,23 @@ fi cp "$SCRIPT_DIR/appx.service" /etc/systemd/system/appx.service -# OpenCode needs WorkingDirectory set to the shared projects dir. -# Since systemd can't expand env vars in WorkingDirectory, we substitute -# the resolved path into the service file before installing it. -sed "s|WorkingDirectory=.*|WorkingDirectory=$DATA_DIR/projects|" \ - "$SCRIPT_DIR/opencode.service" > /etc/systemd/system/opencode.service -echo "copied service files to /etc/systemd/system/" -echo "opencode WorkingDirectory → $DATA_DIR/projects" +systemctl disable --now opencode 2>/dev/null || true +rm -f /etc/systemd/system/opencode.service +if ! systemctl is-active --quiet agent-server 2>/dev/null; then + pkill -u appx-agent -f '(^|/)agent-server( |$)|agent-server/dist/server\.js' 2>/dev/null || true + if id -u opencode >/dev/null 2>&1; then + pkill -u opencode -f '(^|/)agent-server( |$)|agent-server/dist/server\.js' 2>/dev/null || true + fi +fi +sed "s|__APPX_PROJECTS_DIR__|$DATA_DIR/projects|g" \ + "$SCRIPT_DIR/agent-server.service" > /etc/systemd/system/agent-server.service +echo "copied appx.service and agent-server.service" systemctl daemon-reload echo "systemd reloaded" -systemctl enable appx opencode -echo "services enabled: appx, opencode" +systemctl enable appx agent-server +echo "services enabled: appx, agent-server" # --------------------------------------------------------------------------- # Summary diff --git a/deploy/tools-install.sh b/deploy/tools-install.sh index 92cd7ed..fd77edb 100755 --- a/deploy/tools-install.sh +++ b/deploy/tools-install.sh @@ -2,13 +2,14 @@ # deploy/tools-install.sh — install build and runtime tools system-wide. # # Must be run as root. Safe to run multiple times (idempotent). -# Installs everything to /usr/local/bin so all users (appx, opencode) have access. +# Installs everything to /usr/local/bin so all users (appx, appx-agent) have access. # # Tools installed: # - Go (version pinned to go.mod — build tool) # - Task (taskfile.dev build runner — build tool) # - Node.js 24 (via nvm, pinned to major version — runtime + agents) -# - OpenCode (AI agent backend, version pinned to deploy/opencode-version) +# - Pi (AI coding agent CLI/SDK, version pinned to deploy/pi-version) +# - agent-server (Pi SDK HTTP/SSE bridge, installed from sibling repo when present) # - Claude Code (Claude CLI for terminal use — self-update: npm update -g @anthropic-ai/claude-code) # - uv (Python version/package manager — self-update: uv self update) # @@ -116,27 +117,51 @@ fi # Follow the /usr/local/bin/node symlink back to the nvm versioned directory. NODE_BIN_DIR="$(dirname "$(readlink -f /usr/local/bin/node)")" +# Remove the old agent backend package/shims if an earlier install left them behind. +npm uninstall -g opencode-ai >/dev/null 2>&1 || true +rm -f /usr/local/bin/opencode "$NODE_BIN_DIR/opencode" + # --------------------------------------------------------------------------- -# OpenCode (installed via npm, pinned to deploy/opencode-version) +# Pi coding agent (installed via npm, pinned to deploy/pi-version) # --------------------------------------------------------------------------- -OPENCODE_VERSION="" -if [ -f "$SCRIPT_DIR/opencode-version" ]; then - OPENCODE_VERSION=$(cat "$SCRIPT_DIR/opencode-version" | tr -d '[:space:]') +PI_VERSION="" +if [ -f "$SCRIPT_DIR/pi-version" ]; then + PI_VERSION=$(cat "$SCRIPT_DIR/pi-version" | tr -d '[:space:]') +fi + +CURRENT_PI=$(/usr/local/bin/pi --version 2>&1 || echo "") + +if [ -n "$PI_VERSION" ] && [ "$CURRENT_PI" = "$PI_VERSION" ]; then + echo "pi already at $PI_VERSION" +else + echo "installing pi${PI_VERSION:+ $PI_VERSION} via npm..." + npm install -g "@earendil-works/pi-coding-agent@${PI_VERSION:-latest}" + ln -sf "$NODE_BIN_DIR/pi" /usr/local/bin/pi + echo "pi installed: $(/usr/local/bin/pi --version 2>&1)" fi -# Strip leading 'v' for npm version syntax. -OPENCODE_NPM_VERSION=$(echo "$OPENCODE_VERSION" | sed 's/^v//') +# --------------------------------------------------------------------------- +# Appx agent-server (installed from sibling checkout when present) +# --------------------------------------------------------------------------- -CURRENT=$(/usr/local/bin/opencode --version 2>/dev/null || echo "") +AGENT_SERVER_DIR="${AGENT_SERVER_DIR:-}" +if [ -z "$AGENT_SERVER_DIR" ] && [ -d "$REPO_DIR/../agent-server" ]; then + AGENT_SERVER_DIR="$(cd "$REPO_DIR/../agent-server" && pwd)" +fi -if [ -n "$OPENCODE_NPM_VERSION" ] && [ "$CURRENT" = "$OPENCODE_NPM_VERSION" ]; then - echo "opencode already at $OPENCODE_NPM_VERSION" +if [ -n "$AGENT_SERVER_DIR" ] && [ -f "$AGENT_SERVER_DIR/package.json" ]; then + echo "installing agent-server from $AGENT_SERVER_DIR..." + ( + cd "$AGENT_SERVER_DIR" + npm ci + npm run build + npm install -g . + ) + ln -sf "$NODE_BIN_DIR/agent-server" /usr/local/bin/agent-server + echo "agent-server installed: /usr/local/bin/agent-server" else - echo "installing opencode${OPENCODE_NPM_VERSION:+ $OPENCODE_NPM_VERSION} via npm..." - npm install -g "opencode-ai@${OPENCODE_NPM_VERSION:-latest}" - ln -sf "$NODE_BIN_DIR/opencode" /usr/local/bin/opencode - echo "opencode installed: $(/usr/local/bin/opencode --version 2>/dev/null)" + echo "agent-server repo not found; clone appx-org/agent-server next to appx or set AGENT_SERVER_DIR" fi # --------------------------------------------------------------------------- @@ -164,7 +189,7 @@ else # Installer puts it in ~/.local/bin/ — copy to system path. for candidate in \ /root/.local/bin/uv \ - /home/opencode/.local/bin/uv; do + /home/appx-agent/.local/bin/uv; do if [ -x "$candidate" ]; then install -m 755 "$candidate" /usr/local/bin/uv echo "copied uv → /usr/local/bin/uv" @@ -184,5 +209,6 @@ echo " task: $(task --version 2>/dev/null || echo 'not found')" echo " go: $(go version 2>/dev/null || echo 'not found')" echo " node: $(/usr/local/bin/node --version 2>/dev/null || echo 'not found')" echo " uv: $(/usr/local/bin/uv --version 2>/dev/null || echo 'not found')" -echo " opencode: $(/usr/local/bin/opencode --version 2>/dev/null || echo 'not found')" +echo " pi: $(/usr/local/bin/pi --version 2>&1 || echo 'not found')" +echo " agent-server: $(test -x /usr/local/bin/agent-server && echo installed || echo 'not found')" echo " claude: $(claude --version 2>/dev/null || echo 'not found')" diff --git a/deploy/verify-installation.sh b/deploy/verify-installation.sh index 91f520d..34bfce0 100755 --- a/deploy/verify-installation.sh +++ b/deploy/verify-installation.sh @@ -28,6 +28,7 @@ if [ -f "$ENV_FILE" ]; then fi fi echo "data directory: $DATA_DIR" +echo "agent backend: pi" echo "" # expect_ok: command should succeed @@ -71,23 +72,23 @@ echo "=== 1. Users and groups ===" # --------------------------------------------------------------------------- expect_ok "appx user exists" id appx -expect_ok "opencode user exists" id opencode +expect_ok "appx-agent user exists" id appx-agent expect_ok "projects group exists" getent group projects if id -nG appx | grep -qw projects >/dev/null 2>&1; then echo " PASS appx is in projects group"; PASS=$((PASS + 1)) else echo " FAIL appx is in projects group"; FAIL=$((FAIL + 1)) fi -if id -nG opencode | grep -qw projects >/dev/null 2>&1; then - echo " PASS opencode is in projects group"; PASS=$((PASS + 1)) +if id -nG appx-agent | grep -qw projects >/dev/null 2>&1; then + echo " PASS appx-agent is in projects group"; PASS=$((PASS + 1)) else - echo " FAIL opencode is in projects group"; FAIL=$((FAIL + 1)) + echo " FAIL appx-agent is in projects group"; FAIL=$((FAIL + 1)) fi expect_eq "appx shell is /bin/bash" \ "$(getent passwd appx | cut -d: -f7)" "/bin/bash" -expect_eq "opencode shell is /bin/bash" \ - "$(getent passwd opencode | cut -d: -f7)" "/bin/bash" +expect_eq "appx-agent shell is /bin/bash" \ + "$(getent passwd appx-agent | cut -d: -f7)" "/bin/bash" expect_eq "appx home dir is data dir" \ "$(getent passwd appx | cut -d: -f6)" "$DATA_DIR" @@ -112,25 +113,24 @@ expect_ok "projects dir exists" test -d "$DATA_DIR/projects" expect_eq "projects dir is appx:projects 2770" \ "$(stat -c '%U:%G %a' "$DATA_DIR/projects" 2>/dev/null)" "appx:projects 2770" -expect_ok "opencode home exists" test -d /home/opencode -expect_eq "opencode home is opencode:opencode 700" \ - "$(stat -c '%U:%G %a' /home/opencode 2>/dev/null)" "opencode:opencode 700" -expect_ok "opencode config sets anthropic model" \ - grep -q '"anthropic/' /home/opencode/.config/opencode/opencode.json -expect_ok "opencode AGENTS.md exists" \ - test -f /home/opencode/.config/opencode/AGENTS.md +expect_ok "appx-agent home exists" test -d /home/appx-agent +expect_eq "appx-agent home is appx-agent:appx-agent 700" \ + "$(stat -c '%U:%G %a' /home/appx-agent 2>/dev/null)" "appx-agent:appx-agent 700" +expect_ok "pi agent dir exists" test -d /home/appx-agent/.pi/agent +expect_eq "pi agent dir is appx-agent:appx-agent 700" \ + "$(stat -c '%U:%G %a' /home/appx-agent/.pi/agent 2>/dev/null)" "appx-agent:appx-agent 700" # --------------------------------------------------------------------------- echo "" -echo "=== 3. Isolation: opencode user ===" +echo "=== 3. Isolation: appx-agent user ===" # --------------------------------------------------------------------------- -expect_deny "opencode cannot list internals dir" su -s /bin/bash opencode -c "ls $DATA_DIR/.appx-internals/" -expect_deny "opencode cannot read DB file" su -s /bin/bash opencode -c "cat $DATA_DIR/.appx-internals/appx.db" -expect_deny "opencode cannot write to internals" su -s /bin/bash opencode -c "touch $DATA_DIR/.appx-internals/hack" -expect_deny "opencode cannot execute appx binary" su -s /bin/bash opencode -c "/usr/local/bin/appx --version" -expect_ok "opencode can list projects" su -s /bin/bash opencode -c "ls $DATA_DIR/projects/" -expect_ok "opencode can create file in projects" su -s /bin/bash opencode -c "touch $DATA_DIR/projects/.verify-oc && rm $DATA_DIR/projects/.verify-oc" +expect_deny "appx-agent cannot list internals dir" su -s /bin/bash appx-agent -c "ls $DATA_DIR/.appx-internals/" +expect_deny "appx-agent cannot read DB file" su -s /bin/bash appx-agent -c "cat $DATA_DIR/.appx-internals/appx.db" +expect_deny "appx-agent cannot write to internals" su -s /bin/bash appx-agent -c "touch $DATA_DIR/.appx-internals/hack" +expect_deny "appx-agent cannot execute appx binary" su -s /bin/bash appx-agent -c "/usr/local/bin/appx --version" +expect_ok "appx-agent can list projects" su -s /bin/bash appx-agent -c "ls $DATA_DIR/projects/" +expect_ok "appx-agent can create file in projects" su -s /bin/bash appx-agent -c "touch $DATA_DIR/projects/.verify-agent && rm $DATA_DIR/projects/.verify-agent" # --------------------------------------------------------------------------- echo "" @@ -139,7 +139,7 @@ echo "=== 4. Isolation: appx user ===" expect_ok "appx can list internals dir" su -s /bin/bash appx -c "ls $DATA_DIR/.appx-internals/" expect_ok "appx can create file in projects" su -s /bin/bash appx -c "touch $DATA_DIR/projects/.verify-ax && rm $DATA_DIR/projects/.verify-ax" -expect_deny "appx cannot read opencode home" su -s /bin/bash appx -c "ls /home/opencode/" +expect_deny "appx cannot read appx-agent home" su -s /bin/bash appx -c "ls /home/appx-agent/" expect_deny "appx cannot overwrite its own binary" su -s /bin/bash appx -c "cp /usr/local/bin/appx /usr/local/bin/appx.bak" # --------------------------------------------------------------------------- @@ -162,17 +162,26 @@ expect_ok "env file exists" test -f /etc/appx/appx.env expect_eq "env file is root:root 600" \ "$(stat -c '%U:%G %a' /etc/appx/appx.env 2>/dev/null)" "root:root 600" expect_ok "appx.service exists" test -f /etc/systemd/system/appx.service -expect_ok "opencode.service exists" test -f /etc/systemd/system/opencode.service expect_ok "appx service enabled" systemctl is-enabled appx -expect_ok "opencode service enabled" systemctl is-enabled opencode -expect_ok "opencode ExecStart is /usr/local/bin" \ - grep -q "ExecStart=/usr/local/bin/opencode" /etc/systemd/system/opencode.service +expect_deny "legacy opencode.service absent" test -f /etc/systemd/system/opencode.service +expect_ok "agent-server.service exists" test -f /etc/systemd/system/agent-server.service +expect_ok "agent-server service enabled" systemctl is-enabled agent-server +expect_ok "agent-server mode is multi" \ + grep -q "AGENT_SERVER_MODE=multi" /etc/systemd/system/agent-server.service +expect_ok "agent-server ExecStart is /usr/local/bin" \ + grep -q "ExecStart=/usr/local/bin/agent-server" /etc/systemd/system/agent-server.service +expect_ok "agent-server uses Node env proxy support" \ + grep -q "NODE_USE_ENV_PROXY=1" /etc/systemd/system/agent-server.service +expect_ok "agent-server routes HTTPS through egress proxy" \ + grep -q "HTTPS_PROXY=http://127.0.0.1:9080" /etc/systemd/system/agent-server.service +expect_ok "agent-server bypasses proxy for localhost" \ + grep -q "NO_PROXY=localhost,127.0.0.1" /etc/systemd/system/agent-server.service expect_ok "appx ExecStart is /usr/local/bin" \ grep -q "ExecStart=/usr/local/bin/appx" /etc/systemd/system/appx.service expect_ok "appx runs as appx user" \ grep -q "User=appx" /etc/systemd/system/appx.service -expect_ok "opencode runs as opencode user" \ - grep -q "User=opencode" /etc/systemd/system/opencode.service +expect_ok "agent-server runs as appx-agent user" \ + grep -q "User=appx-agent" /etc/systemd/system/agent-server.service # --------------------------------------------------------------------------- echo "" @@ -186,18 +195,20 @@ EXPECTED_NODE_MAJOR="24" ACTUAL_NODE_MAJOR=$(/usr/local/bin/node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1 || echo "0") expect_eq "node major version is $EXPECTED_NODE_MAJOR" \ "$ACTUAL_NODE_MAJOR" "$EXPECTED_NODE_MAJOR" -expect_ok "opencode binary in /usr/local/bin" test -x /usr/local/bin/opencode +expect_deny "legacy opencode binary absent from /usr/local/bin" test -x /usr/local/bin/opencode +expect_ok "agent-server binary in /usr/local/bin" test -x /usr/local/bin/agent-server +expect_ok "pi binary in /usr/local/bin" test -x /usr/local/bin/pi expect_ok "uv binary in /usr/local/bin" test -x /usr/local/bin/uv -EXPECTED_OC_VERSION="" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -if [ -f "$SCRIPT_DIR/opencode-version" ]; then - EXPECTED_OC_VERSION=$(cat "$SCRIPT_DIR/opencode-version" | tr -d '[:space:]' | sed 's/^v//') +EXPECTED_PI_VERSION="" +if [ -f "$SCRIPT_DIR/pi-version" ]; then + EXPECTED_PI_VERSION=$(cat "$SCRIPT_DIR/pi-version" | tr -d '[:space:]') fi -if [ -n "$EXPECTED_OC_VERSION" ]; then - ACTUAL_OC_VERSION=$(/usr/local/bin/opencode --version 2>/dev/null || echo "unknown") - expect_eq "opencode version matches deploy/opencode-version" \ - "$ACTUAL_OC_VERSION" "$EXPECTED_OC_VERSION" +if [ -n "$EXPECTED_PI_VERSION" ]; then + ACTUAL_PI_VERSION=$(/usr/local/bin/pi --version 2>&1 || echo "unknown") + expect_eq "pi version matches deploy/pi-version" \ + "$ACTUAL_PI_VERSION" "$EXPECTED_PI_VERSION" fi # Claude is optional (requires Node.js) — report status without failing. @@ -212,18 +223,18 @@ echo "" echo "=== 8. Runtime (if services are running) ===" # --------------------------------------------------------------------------- -if systemctl is-active --quiet opencode 2>/dev/null; then - expect_ok "opencode is running" systemctl is-active opencode - expect_ok "opencode responds on :4096" \ - curl -sf --max-time 3 http://127.0.0.1:4096/health - # Verify it's actually running as the opencode user. - OC_PID=$(systemctl show opencode --property=MainPID --value 2>/dev/null) - if [ -n "$OC_PID" ] && [ "$OC_PID" != "0" ]; then - OC_USER=$(ps -o user= -p "$OC_PID" 2>/dev/null || echo "unknown") - expect_eq "opencode process runs as opencode user" "$OC_USER" "opencode" +expect_deny "legacy opencode service inactive" systemctl is-active opencode +if systemctl is-active --quiet agent-server 2>/dev/null; then + expect_ok "agent-server is running" systemctl is-active agent-server + expect_ok "agent-server responds on :4001" \ + curl -sf --max-time 3 http://127.0.0.1:4001/v1/healthz + AS_PID=$(systemctl show agent-server --property=MainPID --value 2>/dev/null) + if [ -n "$AS_PID" ] && [ "$AS_PID" != "0" ]; then + AS_USER=$(ps -o user= -p "$AS_PID" 2>/dev/null || echo "unknown") + expect_eq "agent-server process runs as appx-agent user" "$AS_USER" "appx-agent" fi else - echo " SKIP opencode not running (start with: systemctl start opencode)" + echo " SKIP agent-server not running (start with: systemctl start agent-server)" fi if systemctl is-active --quiet appx 2>/dev/null; then diff --git a/docs/architecture/arch_pi_migration.md b/docs/architecture/arch_pi_migration.md new file mode 100644 index 0000000..020595e --- /dev/null +++ b/docs/architecture/arch_pi_migration.md @@ -0,0 +1,640 @@ +# Pi Migration — Architecture Reference + +Living reference for the `codex/pi-harness-distribution` branch (22 commits, 78 files, +~+4.7k / -3.3k lines). This branch replaces the OpenCode agent backend with the +Pi coding agent fronted by Appx's sibling `agent-server` service, and rebuilds +the supporting UI, settings, scaffolding, and deployment around it. + +## Table of Contents + +- [Plain-Language Summary](#plain-language-summary) +- [System Map](#system-map) +- [Code Review Guide](#code-review-guide) + - [1. Database & Project Model](#1-database--project-model) + - [2. Project Scaffolding & Pi Harness](#2-project-scaffolding--pi-harness) + - [3. Agent-Server Reverse Proxy](#3-agent-server-reverse-proxy) + - [4. Egress Integration](#4-egress-integration) + - [5. Frontend Pi Agent Stack](#5-frontend-pi-agent-stack) + - [6. Settings Rebuild](#6-settings-rebuild) + - [7. Deployment](#7-deployment) +- [Testing Guide](#testing-guide) +- [Architecture and Code Pitfalls](#architecture-and-code-pitfalls) +- [Fixed Pitfalls](#fixed-pitfalls) +- [TODOs and Future Improvements](#todos-and-future-improvements) + +--- + +## Plain-Language Summary + +**What changed, in one paragraph.** Appx used to run OpenCode as its built-in +coding agent. This branch rips OpenCode out and replaces it with the Pi CLI, +fronted by a sibling Node service called `agent-server` that turns Pi sessions +into a stable HTTP/SSE contract. Appx now reverse-proxies the browser to that +service, scopes every chat session to a single project, scaffolds a per-project +Pi harness (prompt, guardrail extension, egress skill) into `.pi/`, gives the +Settings page first-class support for Pi credentials (API keys + subscription +OAuth + custom LiteLLM-style providers), and routes Pi's outbound network +traffic through Appx's existing egress allowlist so the same approval UI +applies to model calls and `pip install`s alike. + +**What got removed.** `internal/opencode/` (client, startup polling), the +`/api/opencode/*` proxy and its WebSocket bits, OpenCode-specific settings +endpoints (`/api/settings/api-key`), the OpenCode systemd service, the +`opencode_project_id` column on `projects`, the `web/src/components/agent/` +chat stack, and the agent-core/agent-react event-stream library. + +**What got added.** + +- `internal/server/agent_proxy.go` — two reverse proxies (`/api/agent/*` and + `/api/projects/:id/agent/*`) targeting `agent-server` on `127.0.0.1:4001`, + injecting project context as headers. +- `internal/project/pi_harness.go` + `internal/project/templates/pi/` — embedded + per-project Pi assets: `AGENTS.md`, `extensions/appx-guardrails.ts`, + `skills/appx-egress/`, and `settings.json`. +- `web/src/lib/pi-agent/` — new state machine (reducer + sessions store + + `usePiSession` hook) that consumes `agent-server`'s SSE event stream, indexes + text/tool blocks by `contentIndex`, and falls back to polling when SSE drops. +- `web/src/components/pi-agent/` — `PiAgentPane`, `PiSessionList`, + `PiChatPanel`, `PiToolCallCard`, plus an inline "extension UI" panel that + surfaces Pi extensions' confirm/select/input prompts (used by the new + Appx guardrail extension). +- `web/src/pages/Settings.tsx` — full rewrite: Pi provider list with + configuration source, subscription OAuth flow with URL/code fallback, and a + custom provider editor for LiteLLM/OpenAI-Responses-compatible endpoints. +- `deploy/agent-server.service` — systemd unit running `agent-server` as + `appx-agent` in `AGENT_SERVER_MODE=multi`, with `HTTPS_PROXY` pointed at the + egress CONNECT proxy. +- `deploy/pi-version` — pinned Pi version for `tools-install.sh`. + +**The single most important architectural decision.** All Pi traffic — chat +sessions, model API calls, package installs — funnels through Appx-controlled +choke points: the agent-server proxy for browser→Pi, the egress CONNECT proxy +for Pi→internet. The browser never speaks directly to `agent-server`, and Pi +never speaks directly to `api.openai.com`. This is what makes per-project +scoping, session cookies, and the egress allowlist all work as a single +coherent permission model. + +--- + +## System Map + +### Request Topology + +``` + Browser + │ + │ HTTPS (single port) + ▼ + ┌────────────────────────┐ + │ appx (Go, this repo) │ + │ ─ auth middleware │ + │ ─ React SPA │ + │ ─ subdomain dispatch │ + └─────────────┬──────────┘ + │ + ┌────────────────────────────┼─────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + /api/projects/:id/agent/* /api/agent/* . + [NEW] project-scoped proxy [NEW] global Pi proxy existing app proxy + │ │ │ + └────────────┬───────────────┘ │ + ▼ │ + ┌──────────────────────────┐ │ + │ agent-server [NEW] │ │ + │ appx-agent user │ │ + │ 127.0.0.1:4001 │ │ + │ AGENT_SERVER_MODE=multi │ │ + │ spawns Pi sessions │ │ + └─────────────┬────────────┘ │ + │ provider HTTPS │ + ▼ ▼ + ┌──────────────────────────────┐ localhost: + │ egress CONNECT proxy │ (project dev server) + │ 127.0.0.1:9080 │ + │ allowlist + log │ + └──────────────┬───────────────┘ + │ CONNECT host:443 + ▼ + external internet + (api.anthropic.com, api.openai.com, etc.) + + + Pi skills helper ────POST /egress/request─▶ 127.0.0.1:9081 [internal listener] + │ + ▼ + appx PendingRegistry → dashboard UI + (approve = adds host:port to allowlist) +``` + +### New / Updated API Endpoints + +| Method | Path | Auth | Purpose | +| -------------------------- | ------------------------------------------------ | ---- | ------- | +| `*` [NEW] | `/api/projects/:id/agent/*` | yes | Project-scoped Pi session proxy. Injects `X-Appx-Project-Id/Name/Dir` headers. | +| `*` [NEW] | `/api/agent/auth/*` | yes | Pi provider auth list, API-key set/delete, subscription OAuth start/continue/cancel. | +| `*` [NEW] | `/api/agent/custom/providers*` | yes | List/upsert/delete custom (LiteLLM) providers in `models.json`. | +| `GET` [REMOVED] | `/api/opencode/health` | — | Removed with OpenCode. | +| `*` [REMOVED] | `/api/opencode/*` | — | Reverse proxy + WebSocket; removed. | +| `* ` [REMOVED] | `/api/settings/api-key` | — | Anthropic key was OpenCode-specific. Replaced by `/api/agent/auth/providers/...`. | + +Frontend session endpoints proxied via `/api/projects/:id/agent/*` (handled +upstream by `agent-server`, not Appx code) include: + +| Browser path | Purpose | +| ----------------------------------------------------------- | ------- | +| `GET /sessions` | List sessions for the project | +| `POST /sessions` | Create a new session | +| `GET /sessions/models` | List available models | +| `GET /sessions/{sid}` | Fetch session message history | +| `GET/PATCH /sessions/{sid}/settings` | Per-session model + thinking-level | +| `POST /sessions/{sid}/prompt` | Send a user message | +| `POST /sessions/{sid}/abort` | Cancel an in-flight turn | +| `GET /sessions/{sid}/events` (SSE) | Streaming agent event channel | +| `GET /sessions/{sid}/extension-ui` | Pending extension UI requests | +| `POST /sessions/{sid}/extension-ui/{rid}/response` | Resolve a confirm/select/input/editor prompt | + +### Database Schema + +| Table | Change | +| ---------- | ------ | +| `projects` | [UPDATED] migration 4 no longer adds `opencode_project_id`. Existing column is left in place on already-deployed databases (no destructive down) but ignored by code. The unique partial index on `assigned_port` is unchanged. | +| `settings` | unchanged structurally; `anthropic_api_key` setting is no longer read or written. | +| `egress_log`, `egress_allowed` | unchanged. | + +### Pi Session Lifecycle (Frontend State Machine) + +``` + ┌──────────┐ + │ idle │◀────────────────┐ + └────┬─────┘ │ + │ user submits prompt │ + ▼ │ + ┌──────────┐ │ + │ starting │ │ + └────┬─────┘ │ + │ first SSE event │ + ▼ │ + ┌──────────┐ message_update │ + │streaming │──────────────────┤ + └────┬─────┘ (text/thinking/ │ + │ tool_call deltas) + │ agent_end │ + └────────────────────────┘ + + Side channels active in any state: + - extensionRequests[] (confirm/select/input/editor — block UI) + - extensionStatus{} (status badges) + - extensionNotice (last notify) +``` + +The reducer in `web/src/lib/pi-agent/reducer.ts` keeps two parallel models in +sync: the raw `AgentMessage[]` history that `agent-server` returns from +`GET /sessions/:id`, and the rendered `UiMessage[]` that `PiChatPanel` paints. +Tool calls are addressed by `(toolCallId, contentIndex)` so out-of-order or +duplicate events resolve to the same UI block. + +--- + +## Code Review Guide + +Read in this order — each section assumes the previous ones. + +### 1. Database & Project Model + +**`internal/db/migrations/000004_project_model.{up,down}.sql`** — +`opencode_project_id` is dropped from the up/down scripts. This is safe because +migration 4 was the column's introduction; deployed databases that already ran +it keep the column (SQLite ignores unused columns), and fresh installs simply +never get it. **Verify:** that the partial unique index on `assigned_port` still +exists in `db_test.go`'s `TestMigration4ProjectModel`. + +**`internal/project/project.go`** — `Project` struct loses the +`OpenCodeProjectID` field. The on-disk field continues to coexist on legacy DBs +without breaking reads, because `projectColumns` no longer SELECTs it. + +**`internal/project/store.go`** — `projectColumns` shrinks; `SetOpenCodeProjectID` +deleted. **Verify:** every call site to that method is also gone (it was only +called by the now-deleted OpenCode startup integration). + +### 2. Project Scaffolding & Pi Harness + +**`internal/project/manager.go:113-148`** — `scaffoldProject` now also calls +`scaffoldPiHarness(dir, proj, domain)` between writing `AGENTS.md` and running +`git init`. The `.pi/` directory is committed as part of the initial scaffold +commit, which means project authors can inspect / modify the harness like any +other project file. + +**`internal/project/pi_harness.go`** — embeds `templates/pi/` via `embed.FS` +and walks it, applying `{{name}}/{{port}}/{{subdomain}}` token replacement and +giving `.py` files mode 0755 so the egress skill helper is executable. + +**`internal/project/templates/pi/`** — four assets: + +- `AGENTS.md` — project-local Pi system prompt. Tells the agent the assigned + port, subdomain, and gives explicit guidance to use the `appx-egress` skill + when network calls fail. +- `settings.json` — `enableSkillCommands: true` and an empty `packages` list. + Third-party Pi packages are intentionally not auto-installed because they + execute inside the agent process. +- `extensions/appx-guardrails.ts` — first-party Pi extension that intercepts + `bash`, `write`, and `edit` tool calls. Pattern-matches destructive commands + (`rm -rf`, `sudo`, `chmod -R`, `chmod 777`, `dd/mkfs`, `kill -9`) and + protected paths (`.appx-internals`, `/etc/appx`, `auth.json`, `.git`, + `.env`, `.pem/.key/.p12`). Routes confirmations to the agent-server extension + UI bridge so they bubble up through `/sessions/:id/extension-ui`. +- `skills/appx-egress/{SKILL.md, request_egress.py}` — skill the agent invokes + when an outbound connection is blocked. Posts to the internal listener + (`127.0.0.1:9081/egress/request`) and blocks for up to 70 s waiting for the + Appx user to approve, deny, or time out. + +**Verify:** +- The guardrail patterns are intentionally **first-party / local to the project + scaffold**, not loaded from a third-party Pi registry. Reasoning: anything + loaded as a third-party Pi extension runs inside the agent process and could + in theory disable itself. Confirm any future "shared extensions" feature + preserves this property. +- The protected-path matcher uses `String.includes` on a normalised forward-slash + path — adequate for the listed substrings but trivially bypassed by symlinks. + Treat it as defence-in-depth, not a sandbox. +- `request_egress.py` mode 0755 is set in `pi_harness.go` based on `.py` suffix, + not the embedded mode. Double-check by listing a freshly scaffolded project's + `.pi/skills/appx-egress/` after running `task local`. + +### 3. Agent-Server Reverse Proxy + +**`internal/server/agent_proxy.go`** — two factory functions, both returning +`http.Handler`. Key choices to review: + +- The `Director` strips inbound `Cookie` headers (so Appx's session cookie + never reaches `agent-server`) and strips the three `X-Appx-Project-*` + request headers before re-setting them with values from the resolved project. + Re-setting prevents a malicious client from spoofing `X-Appx-Project-Id` + to read another project's sessions. +- `FlushInterval: -1` is required for SSE; without it Go's `httputil` would + buffer event chunks until the response closes. +- `http.NewResponseController(w).SetWriteDeadline(time.Time{})` is called on + every request so the server's 60 s `WriteTimeout` doesn't cut long-lived + SSE streams or model-thinking pauses. + +**`internal/server/router.go:61-74`** — registration. Note the four explicit +methods on each path; `agent-server` uses `PATCH` for session settings, so +without `PATCH /api/projects/{id}/agent/{agentPath...}` the model-picker would +silently 405. + +**Verify:** +- The `agentServerProxyHandler` requires a non-empty `id` PathValue and 404s + on unknown projects before forwarding. Good — without this, an attacker + with a session cookie could send an arbitrary `agentPath` and have it + proxied verbatim to `/v1/projects//something` which `agent-server` may + treat as global. +- `cleanAgentServerPath` uses `path.Clean` after stripping the prefix. + Subtle: `path.Clean("/")` returns `/`, and the helper returns `prefix` + alone in that case, so requests like `/api/projects/X/agent/` map to + `/v1/projects/X` (no trailing slash). This is the contract `agent-server` + expects; verify it didn't change upstream. + +### 4. Egress Integration + +**`deploy/agent-server.service`** — `NODE_USE_ENV_PROXY=1`, +`HTTPS_PROXY=http://127.0.0.1:9080`, `NO_PROXY=localhost,127.0.0.1`. This is +how Pi's model calls go through the egress proxy without changes to the Pi +client itself. **Verify** this is set; without it, model traffic would bypass +the allowlist. + +**`internal/egress/listener.go`, `pending.go`** — unchanged in this branch but +exercised from a new caller: `request_egress.py`. The Pi skill posts host/port/ +reason; the listener creates a `PendingRequest`, the dashboard's +`EgressRequestDock` polls `/api/egress/pending`, and approval calls +`PendingRegistry.Resolve(id, true)` which adds the host to the allowlist +**and** unblocks the agent. + +**`web/src/components/EgressRequestDock.tsx`** — minor: switches to +`window.setTimeout/setInterval` and properly clears both, fixing a tiny leak +on unmount. + +### 5. Frontend Pi Agent Stack + +The Pi UI is a clean rewrite — the old `agent-core` / `agent-react` lib and the +`agent/SessionList,ChatPanel` components are deleted. Read in this order: + +**`web/src/api/piAgent.ts`** — typed thin wrapper over the project-scoped +proxy. All paths are built from `agentBase(projectId)`. `formatErrorBody` +unwraps both JSON `{error}` shapes and Go's plain-text `http.Error` responses. + +**`web/src/lib/pi-agent/types.ts`** — note that `AssistantMessageEvent` +includes both the new content-indexed shape (`text_start/_delta/_end`, +`thinking_*`, `toolcall_*`) and the older `tool_call_start/...` flat shape. +The reducer handles both because `agent-server` emits the new shape but +historic sessions (re-replayed via `GET /sessions/:id`) may include the old +one. + +**`web/src/lib/pi-agent/reducer.ts`** — long but methodical: + +- `partsFromContent` rebuilds `UiMessagePart[]` from a Pi message's + `content[]`, indexing each part by `contentIndex`. +- `applyTextDelta` first targets the part that matches `contentIndex`; falls + back to the last text part if the event lacks an index. This handles + agent-server emitting unindexed deltas during early-turn buffering. +- `upsertToolPart` matches by either `toolCallId` *or* `contentIndex`. This + is the trick that lets `tool_execution_start` (which has only `toolCallId`) + patch a tool block first registered by `toolcall_start` (which has only + `contentIndex` until `toolcall_end` arrives with `toolCall.id`). +- `loadHistory` does two passes: messages first, then tool results, so a + tool result for a tool call that lives in an earlier message can find it. +- `mergeExtensionRequests` only retains *blocking* extension requests + (`select/confirm/input/editor`) across reloads; non-blocking ones + (`setStatus/notify/setWidget/...`) are recorded separately and don't + persist. + +**`web/src/lib/pi-agent/sessionsStore.ts`** — singleton `Map` +that owns one `EventSource` per active session plus a 1.5 s polling fallback +(`refreshExtensionRequests`). The polling exists because: +1. SSE may drop and the user shouldn't notice — polling pulls extension + requests that were emitted while disconnected. +2. After an `abort`, the server-side session may still flush a final + `agent_end` event the EventSource missed; the poll re-syncs history. + +**`web/src/lib/pi-agent/useSession.ts`** — minimal `useSyncExternalStore` +adapter that hands components a stable snapshot. + +**Components.** `PiAgentPane` is the two-pane layout (sidebar + chat). The +chat panel `web/src/components/pi-agent/PiChatPanel.tsx` is the most +behaviour-dense file — read its `ExtensionRequestPanel` carefully: it's how +`appx-guardrails`'s "Approve `rm -rf`?" prompt actually surfaces in the UI +and how the user response (`{confirmed: true}`/`{value: ...}`/`{cancelled: true}`) +goes back to the Pi extension. + +**Verify:** +- `PiChatPanel` re-fetches model settings (`getPiSessionSettings`) when + `state.status` returns to `'idle'` after streaming. This is intentional — + Pi can change the model server-side mid-turn (e.g. when a subscription + refreshes) and the dropdown should reflect that. +- The reducer's `applyTextDelta` *appends* deltas to the matching text part. + If a text-delta arrives with a `contentIndex` that doesn't yet exist + (because `text_start` was lost), it inserts a new part rather than + silently dropping. Confirm this matches `agent-server`'s contract. +- `state.error` is set both on HTTP-level errors and on `agent_end` after + an aborted send. Inspect the error banner for non-fatal "info" cases — + if Pi reports a soft retry, the banner shouldn't stick. + +### 6. Settings Rebuild + +**`web/src/pages/Settings.tsx`** — single 1400-line page. Three logical zones: + +1. **Provider list** — `getAgentAuthProviders()` returns one row per Pi + provider with `configured/source/credentialType/supportsApiKey/supportsSubscription`. + Rows are sorted: configured first, then by curated priority (`anthropic`, + `openai-codex`, `openai`, `google`), then by available model count. +2. **Selected-provider editor** — exposes either an API-key input *or* the + subscription OAuth flow, depending on `credentialMode`. The subscription + flow polls `/api/agent/auth/subscription/{id}` every 2.5 s until the + `status` reaches `complete | error | cancelled`. There's a manual fallback + path (paste redirect URL or auth code) for `anthropic` and `openai-codex`, + triggered via "Use manual fallback". Switching `credentialMode` cancels any + in-flight OAuth flow first. +3. **Custom provider editor** — collapsible. `thinkingMap()` and `compatFor()` + produce the hairy `models.json` payload. The form distinguishes two + reasoning presets (`standard`, `deepseek`) so a single LiteLLM endpoint + can host both Anthropic-style and DeepSeek-style models. + +**Verify:** +- API keys and OAuth credentials never round-trip to the browser. The + `AgentAuthProvider` shape only reports whether something is set. +- The custom-provider save guards against `apiKey` being empty *unless* the + provider already has a stored key (`apiKeyConfigured`). Editing a saved + provider can therefore tweak metadata without re-entering the secret. Make + sure backend code on `/api/agent/custom/providers` enforces the same rule. + +### 7. Deployment + +**`deploy/system-setup.sh`** — creates `appx-agent` user (replaces `opencode`), +shared `projects` group with setgid'd `2770` perms on the projects directory, +and a private `/home/appx-agent/.pi/agent` for Pi auth/credentials. It also: + +- Stops and removes any pre-existing `opencode` service. +- Pkills lingering agent-server processes started by either the old `opencode` + user or `appx-agent` if the unit isn't running, before installing the new + unit. +- Migrates `/home/opencode/.pi → /home/appx-agent/.pi` if present, preserving + any auth blobs the user already configured. + +**`deploy/tools-install.sh`** — pins Pi via `deploy/pi-version` (currently +`0.75.4`), uninstalls leftover `opencode-ai` npm package, and installs +`agent-server` from a sibling checkout (`../agent-server`) via +`npm ci && npm run build && npm install -g .`. + +**`deploy/agent-server.service`** — annotated above. Note `Before=appx.service` +in `[Unit]` so systemd starts agent-server first; appx connects to it on +demand but logs less noise on first boot when both come up together. + +**Verify:** +- `task server:deploy` after this branch will leave the old `opencode.service` + disabled but file-removed. Confirm no other systemd unit references it. +- `agent-server` runs in the working directory of the projects root and reads + per-project files relative to `X-Appx-Project-Dir`. If `agent-server` is + ever started without `AGENT_SERVER_MODE=multi`, the project-scoped path + layout breaks silently. The unit pins it correctly; just confirm no tests + override it. + +--- + +## Testing Guide + +### Automated Coverage + +| File | What it covers | +| ---- | -------------- | +| `internal/db/db_test.go` | Migration 4 no longer probes `opencode_project_id`. | +| `internal/server/router_test.go` | Replaces every OpenCode-specific test (proxy, WebSocket upgrade, `/api/opencode/health`, API-key injection) with agent-server proxy assertions: project-scoping, header injection, write-deadline clear, 404 on unknown project. The `TestOpenCodeProxy_WebSocketUpgrade_Integration` test against a real backend is removed. | +| `internal/project/{store,manager}_test.go` | Updated test schema and assertions to drop `opencode_project_id`. | + +**Gaps worth noting:** + +- No automated test exercises the **Pi harness scaffold** end-to-end (writing + `.pi/extensions/...` and running `git init` with the new files). The + manager tests verify scaffolding succeeds; they don't assert specific files + exist under `.pi/`. Add at least a smoke test that lists `.pi/AGENTS.md` + and `.pi/skills/appx-egress/request_egress.py` in a fresh project. +- The frontend reducer is large and untested. Consider adding a minimal Vitest + suite that replays a recorded `events.jsonl` from a real session — most + reducer regressions show up as "tool block stays pending forever" or + "duplicate text". +- The egress skill helper (`request_egress.py`) has no test of its own. Manual + verification (below) is the only check. + +### Manual Verification Checklist + +``` +[ ] Build & run: `task local`. Dashboard loads at http://127.0.0.1.sslip.io:8080. +[ ] Sibling agent-server running on :4001 in multi mode (per README "Local development"). +[ ] Header on dashboard reads "PI" with a green dot (no OpenCode status). + +[ ] Settings → Agent Credentials: + [ ] Provider list shows at least anthropic / openai / openai-codex / google with + configured=false and "Not set" labels. + [ ] Select "anthropic". Mode toggle shows Subscription / API key. + [ ] In API key mode, paste an invalid key. Save. Expect server error in red banner. + [ ] Paste a real test key. Save → "Stored" appears, available model count > 0. + [ ] Click "Remove credential" → returns to "Not set". + [ ] In Subscription mode, click Subscription Login. A flow panel appears with + an authUrl link and a "Use manual fallback" toggle. + [ ] Cancel; flow disappears; selecting API-key cleanly cancels in-flight flow. + +[ ] Settings → Custom Provider: + [ ] Open the editor (+). Default is LiteLLM at 127.0.0.1:4000 with openai-responses. + [ ] Save with a fake key. Provider row appears with "Key stored" + 1 model. + [ ] Re-open by clicking the row; apiKey input shows "Stored" placeholder. + [ ] Edit context window to a non-integer; expect inline error. + [ ] Remove → row disappears. + +[ ] Create a project ("hello"). Verify: + [ ] {data}/projects/hello/.pi/AGENTS.md mentions the assigned port and subdomain. + [ ] {data}/projects/hello/.pi/extensions/appx-guardrails.ts exists. + [ ] {data}/projects/hello/.pi/skills/appx-egress/request_egress.py is +x. + [ ] git log shows one "Initial project scaffold" commit including the .pi/ tree. + +[ ] Open the project → Agent tab: + [ ] PiSessionList renders (left pane), empty state. + [ ] Click "+ New". A session appears, becomes selected, header shows "PI AGENT idle". + [ ] Model dropdown lists the configured providers' available models. + [ ] Send "say hi". Status flips to "starting" → "streaming" → "idle". + Assistant message renders with streaming text, no flicker. + [ ] Send "run rm -rf /tmp/foo". Guardrail extension intercepts; an extension + panel appears with Approve / Deny. Deny → tool result reports the block. + [ ] Send "fetch a Go module". The agent tries to reach proxy.golang.org; + if not in allowlist, the appx-egress skill posts a request and the + EgressRequestDock surfaces it. Approve → host added to allowlist; + deny → agent reports failure. + +[ ] Reload the page mid-stream. Session list still shows the session; opening + it should restore history (loadHistory) and resume any pending extension + request. + +[ ] Stop a streaming turn with the red Stop button → status returns to idle, + last message is finalised non-streaming. + +[ ] Visit a project subdomain (e.g. http://hello.127.0.0.1.sslip.io:8080) once + the project has a dev server running on its assigned port. Confirm the + appx auth cookie is honoured and the proxy passes through. +``` + +--- + +## Architecture and Code Pitfalls + +These are issues present after this branch lands. None are blocking; documenting +them so the next pass can pick them up. + +**Severity: Medium** + +- **`internal/server/agent_proxy.go:61-105`** — there's no max-body or + per-request timeout on the agent proxy. A pathological request body could + hold a connection open up to the global `WriteTimeout` (which is + intentionally cleared here). Consider a separate guard for non-SSE methods. +- **`web/src/lib/pi-agent/sessionsStore.ts:28`** — `entries` is module-global. + In dev mode with hot module reload it can leak old `EventSource`s if Vite + rebuilds this module without unmounting consumers. Run-time impact is small + (memory + dangling SSE connections to agent-server), but it complicates + debugging "why is my session not updating." Consider a `__resetForHmr` hook. +- **`internal/project/templates/pi/extensions/appx-guardrails.ts:42-45`** — + `pathRisk` checks substring matches. A path like `..//.git//config` will + match `.git/`, but a symlinked `safe/dir → .git` won't. This extension is + defence-in-depth on top of OS perms (the agent runs as `appx-agent`); good, + but worth labelling that explicitly. + +**Severity: Low** + +- **`web/src/lib/pi-agent/reducer.ts:412-421`** — when `message_start` for a + user message duplicates the locally-optimistic user prompt (text-equal), + the duplicate is dropped. If the agent emits the user message with extra + metadata (timestamp drift, content trimming), the dedup heuristic misses + and the user sees their prompt twice. The current heuristic is "exact text + match on first text part" — fragile. Consider dedup by `messageId` if + `agent-server` provides one. +- **`internal/server/agent_proxy.go:81`** — the proxy deletes the inbound + `Cookie` header. It does not delete `Authorization`. If a future feature + adds bearer auth to Appx itself, the bearer token will currently be + forwarded to agent-server. Add a `req.Header.Del("Authorization")` before + conditionally re-setting it from `token`. +- **`internal/project/pi_harness.go:55`** — `chmod +x` is applied to any + `.py` suffix. If a user names a project file `evil.py` the scaffolder + doesn't see (it only walks the embedded FS), so this is fine; but adding a + non-Python executable in the future will silently install at 0644. Move + the mode decision to a per-template manifest if the harness grows. +- **`PiChatPanel.tsx`** — `pinnedRef.current` decides whether to autoscroll. + When the user is not pinned, error banners and extension panels still push + the input bar down without scroll-correcting; a long extension prompt can + hide the agent's last response. Minor UX issue. + +--- + +## Fixed Pitfalls + +Issues found and corrected during this branch — recorded so reviewers +understand why the code looks the way it does today. + +> **Problem (commit `f52cb4a`):** session state could get stuck in `streaming` +> if SSE dropped right after `agent_end` without the frontend seeing it. +> **Fix:** the polling fallback in `sessionsStore.ts` re-fetches +> `getPiSessionSettings`; if `isStreaming === false` and the local status +> isn't idle, it reloads history and dispatches a synthetic `agent_end`. +> Without this, "Stop" was the only way out of a phantom streaming state. + +> **Problem (commit `e21d8be`):** Pi's HTTPS provider calls bypassed the egress +> allowlist because Node didn't honour `HTTPS_PROXY` by default. +> **Fix:** added `NODE_USE_ENV_PROXY=1` to `agent-server.service`. This is +> a one-line change that's easy to drop on a future systemd refactor; the +> service comment now documents why. + +> **Problem (commit `e4b22ae`):** when an extension request arrived during a +> page reload, it was emitted on SSE before the EventSource was attached and +> never resurfaced. **Fix:** the sessions store explicitly fetches +> `listPiExtensionUiRequests` once at attach, in addition to subscribing to +> SSE. Combined with the 1.5 s polling, this guarantees pending requests +> are always visible within ~1.5 s of opening the session. + +> **Problem (commit `5fa2afb`):** an attacker with a valid Appx session could +> send arbitrary `X-Appx-Project-*` headers to the agent proxy and read other +> projects' sessions. **Fix:** the proxy `Director` always deletes those +> headers off the inbound request and re-sets them from the resolved project +> only after `pm.Get(projectID)` succeeds. Verified by +> `TestAgentServerProxy_RejectsHeaderSpoofing` (in `router_test.go`). + +> **Problem (commit `941ecd6`):** Pi's content-indexed streaming events +> (`text_delta` with `contentIndex`) were being applied to whichever text +> part was last, leading to interleaved text when the model emitted multiple +> content blocks. **Fix:** `applyTextDelta` and `setTextContent` look up the +> target part by `contentIndex`. Falls back to "last text part" only when +> the event has no index (older sessions or partial events). + +> **Problem (commits `db7e47a`, `c752611`):** removing OpenCode while the +> health check was still hard-wired caused the dashboard to show a permanent +> red "OpenCode unhealthy" badge. **Fix:** dropped `OpenCodeStatus` entirely +> and replaced with a static "PI" indicator. The agent runtime is now an +> implementation detail, not a UI concern. + +--- + +## TODOs and Future Improvements + +- **`internal/project/templates/pi/extensions/appx-guardrails.ts`** — the + guardrail patterns are hard-coded. Likely want a future settings table for + user-editable allow/deny patterns. +- **`web/src/lib/pi-agent/reducer.ts`** — large enough to deserve unit tests + with recorded SSE fixtures. Currently regression coverage relies on manual + testing through the UI. +- **`internal/server/agent_proxy.go`** — only `GET/POST/PATCH/DELETE` are + registered. Add `PUT` if `agent-server` ever gains PUT-style endpoints + (currently it doesn't). +- **`deploy/agent-server.service`** — `SESSIONS_DIR` is a single global path + under `/home/appx-agent/.pi/agent/appx-default-sessions`. If/when + `agent-server` supports per-project sessions on disk, switch this to + derive from `X-Appx-Project-Dir`. +- **No explicit Pi version pinning UI.** `deploy/pi-version` is operator-only; + a small Settings card showing the running Pi + agent-server versions would + help self-hosters debug "why is feature X missing." `agent-server` likely + already exposes a `/v1/version` endpoint; surface it through `/api/agent/`. +- **`docs/architecture/`** — older phase docs (`arch_phase_*.md`) reference + OpenCode and Docker; they should be marked historical or refreshed. This + document does not attempt to do that. diff --git a/internal/agentserver/client.go b/internal/agentserver/client.go new file mode 100644 index 0000000..b5a7497 --- /dev/null +++ b/internal/agentserver/client.go @@ -0,0 +1,110 @@ +// Package agentserver is appx's client for the Pi agent-server's project +// lifecycle API. agent-server owns project identity, on-disk layout, and a +// durable registry; appx is a control plane that asks agent-server to +// create/remove projects and otherwise proxies session traffic to it. +// +// See agent-server's +// docs/architecture/project-lifecycle-and-workspace-layout.md. +package agentserver + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Project mirrors the agent-server ProjectInfo response shape. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + ProjectDir string `json:"projectDir"` + CreatedAt string `json:"createdAt"` +} + +// Client talks to a single agent-server instance over HTTP. It is safe for +// concurrent use. +type Client struct { + baseURL string + token string + http *http.Client +} + +// NewClient builds a client for the given agent-server base URL (e.g. +// "http://127.0.0.1:4001"). An empty token disables bearer auth. +func NewClient(baseURL, token string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// EnsureProject creates a project with the given name, or returns the existing +// one — the endpoint is idempotent on name, so this is safe to call on every +// create and to re-run after an agent-server restart. +func (c *Client) EnsureProject(ctx context.Context, name string) error { + body, err := json.Marshal(map[string]string{"name": name}) + if err != nil { + return fmt.Errorf("marshal create-project body: %w", err) + } + req, err := c.newRequest(ctx, http.MethodPost, "/v1/projects", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("call agent-server create-project: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return c.statusError("create project", resp) + } + // Drain the body so the connection can be reused; the response shape + // (id/projectDir) is derivable on the appx side and not needed here. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + return nil +} + +// DeleteProject removes a project (runtime, metadata, and on-disk dirs) from +// agent-server. A 404 is treated as success so deletes are idempotent. +func (c *Client) DeleteProject(ctx context.Context, id string) error { + req, err := c.newRequest(ctx, http.MethodDelete, "/v1/projects/"+id, nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("call agent-server delete-project: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound { + return nil + } + return c.statusError("delete project", resp) +} + +func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, fmt.Errorf("build agent-server request: %w", err) + } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + return req, nil +} + +// statusError reads a bounded slice of the error body for context. +func (c *Client) statusError(action string, resp *http.Response) error { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("agent-server %s failed: %s: %s", action, resp.Status, strings.TrimSpace(string(snippet))) +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 930dadd..b60b3ef 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -29,8 +29,8 @@ func New(store *Store) *Auth { } // AuthRequiredHeader is set on 401 responses from the auth middleware so that -// API clients can distinguish an appx session expiry from an OpenCode backend -// error. The frontend redirects to /login when it sees this header on a 401. +// API clients can distinguish an appx session expiry from an upstream agent or +// app error. The frontend redirects to /login when it sees this header on a 401. const AuthRequiredHeader = "X-Appx-Auth" // Middleware returns an HTTP middleware that enforces authentication. diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 63be498..d9e2233 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -126,8 +126,7 @@ func TestMigrate_SkipsAlreadyApplied(t *testing.T) { // TestMigration3ContainerSecret verifies that migration 3 adds the // container_secret column to the projects table. This column stores the -// password used for authenticating proxy requests to the container's -// opencode serve instance. +// password used for authenticating proxy requests to the container app. func TestMigration3ContainerSecret(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") if err != nil { @@ -156,11 +155,9 @@ func TestMigration3ContainerSecret(t *testing.T) { } } -// TestMigration4ProjectModel verifies that migration 4 adds the assigned_port -// and opencode_project_id columns to the projects table. assigned_port tracks -// the external port allocated for the project (nullable, with unique constraint), -// and opencode_project_id stores the ID of the project in the opencode repository -// within the container. +// TestMigration4ProjectModel verifies that migration 4 adds assigned_port to +// the projects table. assigned_port tracks the external port allocated for the +// project (nullable, with unique constraint). func TestMigration4ProjectModel(t *testing.T) { db, err := sql.Open("sqlite", ":memory:") if err != nil { @@ -193,21 +190,6 @@ func TestMigration4ProjectModel(t *testing.T) { if err != nil { t.Fatalf("insert with second NULL assigned_port: %v", err) } - - // Verify opencode_project_id column exists. - _, err = db.Exec("UPDATE projects SET opencode_project_id = 'oc-abc123' WHERE id = 'test-1'") - if err != nil { - t.Fatalf("update opencode_project_id: %v", err) - } - - var ocID sql.NullString - err = db.QueryRow("SELECT opencode_project_id FROM projects WHERE id = 'test-1'").Scan(&ocID) - if err != nil { - t.Fatalf("select opencode_project_id: %v", err) - } - if !ocID.Valid || ocID.String != "oc-abc123" { - t.Errorf("expected 'oc-abc123', got %v", ocID) - } } // TestMigration5EgressAllowedColumn verifies that migration 5 adds the allowed diff --git a/internal/db/migrations/000004_project_model.down.sql b/internal/db/migrations/000004_project_model.down.sql index 39885ed..ab18f31 100644 --- a/internal/db/migrations/000004_project_model.down.sql +++ b/internal/db/migrations/000004_project_model.down.sql @@ -1,3 +1,2 @@ DROP INDEX IF EXISTS idx_assigned_port_unique; ALTER TABLE projects DROP COLUMN assigned_port; -ALTER TABLE projects DROP COLUMN opencode_project_id; diff --git a/internal/db/migrations/000004_project_model.up.sql b/internal/db/migrations/000004_project_model.up.sql index 44e6c56..161ba61 100644 --- a/internal/db/migrations/000004_project_model.up.sql +++ b/internal/db/migrations/000004_project_model.up.sql @@ -1,3 +1,2 @@ ALTER TABLE projects ADD COLUMN assigned_port INTEGER; -ALTER TABLE projects ADD COLUMN opencode_project_id TEXT; CREATE UNIQUE INDEX idx_assigned_port_unique ON projects(assigned_port) WHERE assigned_port IS NOT NULL; diff --git a/internal/egress/store.go b/internal/egress/store.go index 40a7fb0..88c0359 100644 --- a/internal/egress/store.go +++ b/internal/egress/store.go @@ -1,6 +1,6 @@ // Package egress implements the egress CONNECT proxy, allowlist management, and -// connection logging. It controls which external hosts the opencode agent can -// reach by intercepting HTTP CONNECT requests on 127.0.0.1:9080. +// connection logging. It controls which external hosts the Pi agent can reach by +// intercepting HTTP CONNECT requests on 127.0.0.1:9080. package egress import ( @@ -13,12 +13,12 @@ import ( // DefaultAllowlist is the set of host:port entries permitted when no custom // allowlist has been configured. Contains the minimum set required for basic -// agent functionality: Claude API, OpenCode's own API, and common package -// registries agents use to build projects. +// agent functionality: model provider APIs and common package registries agents +// use to build projects. var DefaultAllowlist = []string{ // AI / agent infrastructure "api.anthropic.com:443", - "opencode.ai:443", + "api.openai.com:443", // Go modules "proxy.golang.org:443", "sum.golang.org:443", diff --git a/internal/opencode/client.go b/internal/opencode/client.go deleted file mode 100644 index e8ca86c..0000000 --- a/internal/opencode/client.go +++ /dev/null @@ -1,127 +0,0 @@ -package opencode - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -const clientTimeout = 5 * time.Second -const maxResponseSize = 10 << 20 - -// OpenCodeProject represents a project as returned by GET /project. -type OpenCodeProject struct { - ID string `json:"id"` - Name string `json:"name"` - AbsolutePath string `json:"absolutePath"` -} - -// Client is a thin HTTP client for the OpenCode server REST API. -// It communicates with the opencode process running inside a project container -// at its well-known port (4096 by default). -type Client struct { - baseURL string - httpClient *http.Client -} - -// NewClient creates a Client targeting the given base URL. Trailing slashes are trimmed -// so callers don't need to worry about double-slash paths. -func NewClient(baseURL string) *Client { - return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), - httpClient: &http.Client{Timeout: clientTimeout}, - } -} - -// HealthCheck calls GET /global/health. Returns nil on 200, error otherwise. -// Used by WaitForHealthy to poll readiness during container startup. -func (c *Client) HealthCheck() error { - resp, err := c.httpClient.Get(c.baseURL + "/global/health") - if err != nil { - return fmt.Errorf("opencode health check: %w", err) - } - defer resp.Body.Close() - io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseSize)) - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("opencode health check: status %d", resp.StatusCode) - } - return nil -} - -// ListProjects calls GET /project and returns the discovered projects. -// Returns an empty slice (not nil) when the server returns an empty JSON array. -func (c *Client) ListProjects() ([]OpenCodeProject, error) { - resp, err := c.httpClient.Get(c.baseURL + "/project") - if err != nil { - return nil, fmt.Errorf("opencode list projects: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseSize)) - return nil, fmt.Errorf("opencode list projects: status %d", resp.StatusCode) - } - var projects []OpenCodeProject - if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseSize)).Decode(&projects); err != nil { - return nil, fmt.Errorf("opencode list projects: decode: %w", err) - } - return projects, nil -} - -// DisposeAll calls POST /global/dispose to tear down all cached instances. -// The next request triggers fresh initialization, picking up any auth changes. -func (c *Client) DisposeAll() error { - req, err := http.NewRequest(http.MethodPost, c.baseURL+"/global/dispose", nil) - if err != nil { - return fmt.Errorf("opencode dispose: new request: %w", err) - } - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("opencode dispose: %w", err) - } - defer resp.Body.Close() - io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseSize)) - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("opencode dispose: status %d", resp.StatusCode) - } - return nil -} - -// SetAuth injects an API key for the given provider into the OpenCode server. -// It calls PUT /auth/:providerID with body {"type":"api","key":""}. -// This matches the OpenCode server's auth endpoint schema (server.ts:99-129). -// Verified against OpenCode source: method=PUT, path param=providerID, -// body is a discriminated union — ApiAuth uses type="api" + key field. -func (c *Client) SetAuth(providerID, apiKey string) error { - body := struct { - Type string `json:"type"` - Key string `json:"key"` - }{Type: "api", Key: apiKey} - - jsonBody, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("opencode set auth: marshal: %w", err) - } - - url := c.baseURL + "/auth/" + providerID - req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(jsonBody)) - if err != nil { - return fmt.Errorf("opencode set auth: new request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("opencode set auth: %w", err) - } - defer resp.Body.Close() - io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseSize)) - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("opencode set auth: status %d", resp.StatusCode) - } - return nil -} diff --git a/internal/opencode/client_test.go b/internal/opencode/client_test.go deleted file mode 100644 index 05485d4..0000000 --- a/internal/opencode/client_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package opencode - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestHealthCheck_Healthy(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/global/health" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"ok"}`)) - })) - defer srv.Close() - - c := NewClient(srv.URL) - if err := c.HealthCheck(); err != nil { - t.Errorf("expected nil error, got: %v", err) - } -} - -func TestHealthCheck_Unhealthy(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer srv.Close() - - c := NewClient(srv.URL) - if err := c.HealthCheck(); err == nil { - t.Error("expected error for 503, got nil") - } -} - -func TestHealthCheck_ConnectionRefused(t *testing.T) { - c := NewClient("http://127.0.0.1:1") - if err := c.HealthCheck(); err == nil { - t.Error("expected error for unreachable server, got nil") - } -} - -func TestListProjects_Success(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/project" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]OpenCodeProject{ - {ID: "proj-abc", Name: "myapp", AbsolutePath: "/home/opencode/projects/myapp"}, - }) - })) - defer srv.Close() - - c := NewClient(srv.URL) - projects, err := c.ListProjects() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(projects) != 1 { - t.Fatalf("expected 1 project, got %d", len(projects)) - } - if projects[0].ID != "proj-abc" { - t.Errorf("expected ID 'proj-abc', got %q", projects[0].ID) - } -} - -func TestListProjects_EmptyList(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`[]`)) - })) - defer srv.Close() - - c := NewClient(srv.URL) - projects, err := c.ListProjects() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(projects) != 0 { - t.Errorf("expected 0, got %d", len(projects)) - } -} - -func TestListProjects_ServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer srv.Close() - - c := NewClient(srv.URL) - _, err := c.ListProjects() - if err == nil { - t.Error("expected error for 500, got nil") - } -} - -func TestSetAuth_Success(t *testing.T) { - var gotProvider, gotKey, gotType string - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Correct endpoint: PUT /auth/:providerID - if r.Method != http.MethodPut { - t.Errorf("expected PUT, got %s", r.Method) - } - // Provider ID is in the path, not the body - gotProvider = strings.TrimPrefix(r.URL.Path, "/auth/") - var body struct { - Type string `json:"type"` - Key string `json:"key"` - } - json.NewDecoder(r.Body).Decode(&body) - gotType = body.Type - gotKey = body.Key - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - c := NewClient(srv.URL) - err := c.SetAuth("anthropic", "sk-ant-test-key") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gotProvider != "anthropic" { - t.Errorf("expected provider 'anthropic' in path, got %q", gotProvider) - } - if gotType != "api" { - t.Errorf("expected type 'api', got %q", gotType) - } - if gotKey != "sk-ant-test-key" { - t.Errorf("expected key 'sk-ant-test-key', got %q", gotKey) - } -} - -func TestSetAuth_ServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - })) - defer srv.Close() - - c := NewClient(srv.URL) - if err := c.SetAuth("bad", "key"); err == nil { - t.Error("expected error for 400, got nil") - } -} - -func TestNewClient_BaseURL(t *testing.T) { - c := NewClient("http://localhost:4096/") - if c.baseURL != "http://localhost:4096" { - t.Errorf("expected trimmed URL, got %q", c.baseURL) - } -} diff --git a/internal/opencode/startup.go b/internal/opencode/startup.go deleted file mode 100644 index a74ef51..0000000 --- a/internal/opencode/startup.go +++ /dev/null @@ -1,58 +0,0 @@ -package opencode - -import ( - "context" - "fmt" - "log" - "time" -) - -// WaitForHealthy polls the OpenCode health endpoint at the given interval -// until it returns 200 OK or the context is cancelled. An immediate check -// is performed before the first tick to minimise latency when the server -// is already up. -func (c *Client) WaitForHealthy(ctx context.Context, interval time.Duration) error { - if err := c.HealthCheck(); err == nil { - return nil - } - - ticker := time.NewTicker(interval) - defer ticker.Stop() - - attempt := 0 - for { - select { - case <-ctx.Done(): - return fmt.Errorf("opencode not healthy: %w", ctx.Err()) - case <-ticker.C: - attempt++ - if err := c.HealthCheck(); err == nil { - log.Printf("opencode: healthy after %d retries", attempt) - return nil - } - log.Printf("opencode: waiting for health (attempt %d)...", attempt) - } - } -} - -// InjectAPIKey waits for OpenCode to be healthy, then injects the Anthropic API key -// via POST /auth. If apiKey is empty, the injection step is skipped. SetAuth failures -// are logged but not fatal — the user can re-inject via the Settings page. -func (c *Client) InjectAPIKey(ctx context.Context, pollInterval time.Duration, apiKey string) error { - if err := c.WaitForHealthy(ctx, pollInterval); err != nil { - return err - } - - if apiKey == "" { - log.Printf("opencode: no API key configured, skipping auth injection") - return nil - } - - if err := c.SetAuth("anthropic", apiKey); err != nil { - log.Printf("opencode: failed to inject API key: %v (user can re-inject via Settings)", err) - return nil // non-fatal - } - - log.Printf("opencode: API key injected successfully") - return nil -} diff --git a/internal/opencode/startup_test.go b/internal/opencode/startup_test.go deleted file mode 100644 index 6ef4805..0000000 --- a/internal/opencode/startup_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package opencode - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" -) - -func TestWaitForHealthy_ImmediateSuccess(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - c := NewClient(srv.URL) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := c.WaitForHealthy(ctx, 50*time.Millisecond); err != nil { - t.Errorf("expected nil, got: %v", err) - } -} - -func TestWaitForHealthy_EventualSuccess(t *testing.T) { - var calls atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if calls.Add(1) < 3 { - w.WriteHeader(http.StatusServiceUnavailable) - return - } - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - c := NewClient(srv.URL) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := c.WaitForHealthy(ctx, 50*time.Millisecond); err != nil { - t.Errorf("expected nil, got: %v", err) - } - if n := calls.Load(); n < 3 { - t.Errorf("expected at least 3 calls, got %d", n) - } -} - -func TestWaitForHealthy_ContextCancelled(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer srv.Close() - - c := NewClient(srv.URL) - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - - if err := c.WaitForHealthy(ctx, 50*time.Millisecond); err == nil { - t.Error("expected error for cancelled context, got nil") - } -} - -func TestInjectAPIKey_InjectsWhenHealthy(t *testing.T) { - var gotKey string - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/global/health": - w.WriteHeader(http.StatusOK) - case strings.HasPrefix(r.URL.Path, "/auth/") && r.Method == http.MethodPut: - var body struct { - Key string `json:"key"` - } - json.NewDecoder(r.Body).Decode(&body) - gotKey = body.Key - w.WriteHeader(http.StatusOK) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer srv.Close() - - c := NewClient(srv.URL) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := c.InjectAPIKey(ctx, 50*time.Millisecond, "sk-ant-abc"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gotKey != "sk-ant-abc" { - t.Errorf("expected key 'sk-ant-abc', got %q", gotKey) - } -} - -func TestInjectAPIKey_SkipsWhenNoKey(t *testing.T) { - authCalled := false - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/global/health": - w.WriteHeader(http.StatusOK) - case "/auth": - authCalled = true - w.WriteHeader(http.StatusOK) - } - })) - defer srv.Close() - - c := NewClient(srv.URL) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := c.InjectAPIKey(ctx, 50*time.Millisecond, ""); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if authCalled { - t.Error("expected /auth not to be called when apiKey is empty") - } -} diff --git a/internal/project/manager.go b/internal/project/manager.go index e4ee30b..d7c8c19 100644 --- a/internal/project/manager.go +++ b/internal/project/manager.go @@ -1,43 +1,48 @@ package project import ( + "context" + "errors" "fmt" - "os" - "os/exec" "path/filepath" - "strings" ) -// agentsTemplate is the AGENTS.md content scaffolded into every new project -// directory. Placeholders {{name}}, {{port}}, {{subdomain}} are replaced at creation. -const agentsTemplate = `# Project: {{name}} - -## App Port - -When running a dev server, always use port {{port}}. -This port is assigned by appx and has proxy routing configured. -Your app will be accessible at {{subdomain}}. - -## Guidelines - -- Use this port for ALL dev servers (Vite, Next.js, Express, etc.) -- Do not change the port — it is mapped to a subdomain by the appx proxy -- The project directory is the working directory for all commands -` +// AgentRegistrar registers/removes projects with the Pi agent-server, which +// owns project identity, on-disk layout (including each project's `.pi/`), and +// the durable runtime registry. Kept as an interface (rather than importing the +// agentserver package) so the project package stays dependency-light and easy +// to test with a fake. +type AgentRegistrar interface { + // EnsureProject registers a project by name (idempotent on name). The + // agent-server creates WORKSPACE_DIR/{id}/ and persists project metadata. + EnsureProject(ctx context.Context, name string) error + // DeleteProject removes a project by its agent-server id (idempotent), + // including its directory and session transcripts. + DeleteProject(ctx context.Context, id string) error +} -// Manager provides project lifecycle operations. It delegates to the Store for -// database CRUD and handles filesystem operations (directory creation, git init, -// AGENTS.md scaffolding) for new projects. +// Manager provides project lifecycle operations. appx is a control plane: it +// owns a per-project record (assigned port, subdomain, owning user, health) in +// its own store and asks the agent-server to create/remove the project. The +// agent-server owns the project directory, its `.pi/` harness, and session +// transcripts — appx no longer scaffolds the filesystem (see +// .superpowers/specs/2026-06-09-project-ownership-and-agent-chat-integration-adr.md). +// +// agent-server's project id equals the appx project name (appx names already +// satisfy the slug grammar, so `slugify(name) == name`). type Manager struct { Store *Store ProjectRoot string - BaseDomain string // e.g. "localhost" or "user.appx.app" + // BaseDomain is retained for control-plane URL construction and future + // harness templating; it no longer drives any filesystem scaffolding. + BaseDomain string + Agent AgentRegistrar // optional; nil disables agent-server registration } // NewManager creates a Manager backed by the given project store. The projectRoot -// is the base directory where project subdirectories are created. It is resolved -// to an absolute path so that ProjectDir returns paths that match what OpenCode -// stores internally (OpenCode resolves symlinks and relative paths). +// is the base directory where project subdirectories live (in a co-located +// deployment it must equal agent-server's WORKSPACE_DIR). It is resolved to an +// absolute path so ProjectDir always returns a stable host path. func NewManager(store *Store, projectRoot string) *Manager { abs, err := filepath.Abs(projectRoot) if err == nil { @@ -49,37 +54,51 @@ func NewManager(store *Store, projectRoot string) *Manager { } } -// Create creates a new project: inserts a DB record with an auto-assigned port, -// creates the project directory, scaffolds AGENTS.md, runs git init, stages all -// files, and makes an initial commit. The git repo is required for OpenCode to -// discover the project. If filesystem operations fail, the DB record is rolled back. -func (m *Manager) Create(name string) (*Project, error) { +// Create registers a new project. It first reserves the appx control-plane +// record (name validation + atomic port assignment), then asks the agent-server +// to create the project (which owns the on-disk directory + durable registry). +// +// The appx record is inserted first because it is the cheap, transactional, +// trivially-rolled-back half; agent-server registration (an idempotent upsert) +// follows. If registration fails, the appx record is removed so a failed create +// leaves no partial state. We deliberately do NOT delete the agent-server +// project on rollback: EnsureProject is an idempotent upsert that may have +// matched a pre-existing project, and deleting it could destroy another owner's +// directory and transcripts. +func (m *Manager) Create(ctx context.Context, name string) (*Project, error) { proj, err := m.Store.Create(name) if err != nil { return nil, err } - projectDir := filepath.Join(m.ProjectRoot, name) - if err := m.scaffoldProject(projectDir, proj); err != nil { - os.RemoveAll(projectDir) // clean up partial directory before DB rollback - m.Store.Delete(proj.ID) - return nil, fmt.Errorf("scaffold project: %w", err) + if m.Agent != nil { + if err := m.Agent.EnsureProject(ctx, proj.Name); err != nil { + // Roll back only our own freshly-created record. + _ = m.Store.Delete(proj.ID) + return nil, fmt.Errorf("register project with agent-server: %w", err) + } } return proj, nil } -// Delete removes a project's directory from disk and its record from the database. -// Returns ErrNotFound if the project does not exist. -func (m *Manager) Delete(id string) error { +// Delete removes a project. The agent-server owns the directory and session +// transcripts, so DeleteProject (idempotent) removes WORKSPACE_DIR/{id}/ and +// .pi-global/sessions/{id}/. appx then drops its own control-plane record. appx +// never touches the filesystem — in the target container deployment it has no +// access to the agent-server volume. Returns ErrNotFound if the project does +// not exist. +func (m *Manager) Delete(ctx context.Context, id string) error { proj, err := m.Store.Get(id) if err != nil { return err } - projectDir := filepath.Join(m.ProjectRoot, proj.Name) - if err := os.RemoveAll(projectDir); err != nil { - return fmt.Errorf("remove project directory: %w", err) + if m.Agent != nil { + // agent-server id == project name. + if err := m.Agent.DeleteProject(ctx, proj.Name); err != nil { + return fmt.Errorf("deregister project from agent-server: %w", err) + } } return m.Store.Delete(id) @@ -101,60 +120,33 @@ func (m *Manager) GetByName(name string) (*Project, error) { } // ProjectDir returns the absolute path to the directory for the project with -// the given name. The directory may or may not exist; this is purely a path -// construction helper for use by API handlers populating the ProjectDir field. +// the given name. The directory is created and owned by the agent-server; this +// is purely a path-construction helper for control-plane features that run on a +// shared filesystem (e.g. the local terminal). The directory may or may not +// exist. func (m *Manager) ProjectDir(name string) string { return filepath.Join(m.ProjectRoot, name) } -// scaffoldProject creates the project directory, writes AGENTS.md, initializes -// a git repo, stages files, and makes an initial commit. -func (m *Manager) scaffoldProject(dir string, proj *Project) error { - if err := os.MkdirAll(dir, 0770); err != nil { - return fmt.Errorf("mkdir: %w", err) - } - - domain := m.BaseDomain - if domain == "" { - domain = "localhost" - } - - content := agentsTemplate - content = strings.ReplaceAll(content, "{{name}}", proj.Name) - content = strings.ReplaceAll(content, "{{port}}", fmt.Sprintf("%d", proj.AssignedPort)) - content = strings.ReplaceAll(content, "{{subdomain}}", fmt.Sprintf("%s.%s", proj.Name, domain)) - - agentsPath := filepath.Join(dir, "AGENTS.md") - if err := os.WriteFile(agentsPath, []byte(content), 0644); err != nil { - return fmt.Errorf("write AGENTS.md: %w", err) - } - - if err := runGit(dir, "init"); err != nil { - return fmt.Errorf("git init: %w", err) - } - if err := runGit(dir, "add", "."); err != nil { - return fmt.Errorf("git add: %w", err) - } - if err := runGit(dir, "commit", "-m", "Initial project scaffold"); err != nil { - return fmt.Errorf("git commit: %w", err) +// ReconcileAgentProjects re-registers every known project with the agent-server. +// Registration is idempotent, so this is safe to run at startup to (a) register +// projects that predate agent-server ownership and (b) rehydrate the agent-server +// after it (or appx) restarts. It is best-effort: individual failures are +// returned joined but never abort the caller's boot — the per-project create/ +// proxy paths remain the authoritative registration points. +func (m *Manager) ReconcileAgentProjects(ctx context.Context) error { + if m.Agent == nil { + return nil } - - return nil -} - -// runGit executes a git command in the given directory with minimal git config. -func runGit(dir string, args ...string) error { - cmd := exec.Command("git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), - "GIT_AUTHOR_NAME=appx", - "GIT_AUTHOR_EMAIL=appx@localhost", - "GIT_COMMITTER_NAME=appx", - "GIT_COMMITTER_EMAIL=appx@localhost", - ) - out, err := cmd.CombinedOutput() + projects, err := m.Store.List() if err != nil { - return fmt.Errorf("%s: %s", err, string(out)) + return fmt.Errorf("list projects for reconcile: %w", err) + } + var errs []error + for _, proj := range projects { + if err := m.Agent.EnsureProject(ctx, proj.Name); err != nil { + errs = append(errs, fmt.Errorf("register %q: %w", proj.Name, err)) + } } - return nil + return errors.Join(errs...) } diff --git a/internal/project/manager_test.go b/internal/project/manager_test.go index 3a07fa0..18f6839 100644 --- a/internal/project/manager_test.go +++ b/internal/project/manager_test.go @@ -1,8 +1,9 @@ package project import ( + "context" "database/sql" - "os" + "errors" "path/filepath" "strings" "testing" @@ -10,8 +11,34 @@ import ( _ "modernc.org/sqlite" ) -// setupManagerTest creates an in-memory DB, temp project root dir, and returns a Manager. -func setupManagerTest(t *testing.T) (*Manager, *sql.DB) { +// fakeAgent is an in-memory AgentRegistrar that records calls and can be primed +// to fail, so Manager lifecycle behaviour is tested without a real agent-server. +type fakeAgent struct { + ensured []string + deleted []string + ensureErr error + deleteErr error +} + +func (f *fakeAgent) EnsureProject(_ context.Context, name string) error { + if f.ensureErr != nil { + return f.ensureErr + } + f.ensured = append(f.ensured, name) + return nil +} + +func (f *fakeAgent) DeleteProject(_ context.Context, id string) error { + if f.deleteErr != nil { + return f.deleteErr + } + f.deleted = append(f.deleted, id) + return nil +} + +// setupManagerTest creates an in-memory DB, temp project root dir, and returns a +// Manager plus the fake agent wired into it. +func setupManagerTest(t *testing.T) (*Manager, *fakeAgent, *sql.DB) { t.Helper() db, err := sql.Open("sqlite", ":memory:") if err != nil { @@ -33,8 +60,7 @@ func setupManagerTest(t *testing.T) (*Manager, *sql.DB) { last_error TEXT, resources TEXT, container_secret TEXT, - assigned_port INTEGER, - opencode_project_id TEXT + assigned_port INTEGER ); CREATE UNIQUE INDEX IF NOT EXISTS idx_assigned_port ON projects(assigned_port) WHERE assigned_port IS NOT NULL; `) @@ -45,11 +71,13 @@ func setupManagerTest(t *testing.T) (*Manager, *sql.DB) { store := NewStore(db) projectRoot := t.TempDir() mgr := NewManager(store, projectRoot) - return mgr, db + agent := &fakeAgent{} + mgr.Agent = agent + return mgr, agent, db } func TestNewManager(t *testing.T) { - mgr, _ := setupManagerTest(t) + mgr, _, _ := setupManagerTest(t) if mgr == nil { t.Fatal("expected non-nil manager") } @@ -58,158 +86,144 @@ func TestNewManager(t *testing.T) { } } -func TestManagerCreate_CreatesDirectory(t *testing.T) { - mgr, _ := setupManagerTest(t) +func TestManagerCreate_RegistersWithAgentServer(t *testing.T) { + mgr, agent, _ := setupManagerTest(t) - p, err := mgr.Create("my-app") + p, err := mgr.Create(context.Background(), "my-app") if err != nil { t.Fatalf("Create: %v", err) } - projectDir := filepath.Join(mgr.ProjectRoot, "my-app") - - // Directory exists - info, err := os.Stat(projectDir) - if err != nil { - t.Fatalf("project dir not created: %v", err) + if p.Name != "my-app" { + t.Errorf("expected name my-app, got %q", p.Name) } - if !info.IsDir() { - t.Fatal("expected directory") + // agent-server id == project name; the manager registers by name. + if len(agent.ensured) != 1 || agent.ensured[0] != "my-app" { + t.Errorf("expected EnsureProject(\"my-app\"), got %v", agent.ensured) + } + // First project gets the bottom of the port range. + if p.AssignedPort != 10000 { + t.Errorf("expected port 10000, got %d", p.AssignedPort) } - // .git directory exists - gitDir := filepath.Join(projectDir, ".git") - if _, err := os.Stat(gitDir); err != nil { - t.Fatalf(".git dir not created: %v", err) + // The appx control-plane record exists. + if _, err := mgr.Get(p.ID); err != nil { + t.Fatalf("expected project record to exist: %v", err) } +} + +func TestManagerCreate_NoAgentStillCreatesRecord(t *testing.T) { + mgr, _, _ := setupManagerTest(t) + mgr.Agent = nil // co-located/test deployments may run without registration - // AGENTS.md exists with correct port - agentsPath := filepath.Join(projectDir, "AGENTS.md") - content, err := os.ReadFile(agentsPath) + p, err := mgr.Create(context.Background(), "my-app") if err != nil { - t.Fatalf("AGENTS.md not created: %v", err) - } - if !strings.Contains(string(content), "10000") { - t.Errorf("AGENTS.md missing port number, content: %s", content) + t.Fatalf("Create: %v", err) } - if !strings.Contains(string(content), "my-app") { - t.Errorf("AGENTS.md missing project name, content: %s", content) - } - - // Project has correct assigned port - if p.AssignedPort != 10000 { - t.Errorf("expected port 10000, got %d", p.AssignedPort) + if _, err := mgr.Get(p.ID); err != nil { + t.Fatalf("expected project record to exist: %v", err) } } -func TestManagerCreate_GitHasInitialCommit(t *testing.T) { - mgr, _ := setupManagerTest(t) - mgr.Create("my-app") +func TestManagerCreate_InvalidNameSkipsRegistration(t *testing.T) { + mgr, agent, _ := setupManagerTest(t) - projectDir := filepath.Join(mgr.ProjectRoot, "my-app") - headPath := filepath.Join(projectDir, ".git", "HEAD") - if _, err := os.Stat(headPath); err != nil { - t.Fatalf("git HEAD not created: %v", err) + _, err := mgr.Create(context.Background(), "A") + if err != ErrInvalidName { + t.Errorf("expected ErrInvalidName, got %v", err) + } + if len(agent.ensured) != 0 { + t.Errorf("expected no agent registration for invalid name, got %v", agent.ensured) } } -func TestManagerCreate_InvalidName(t *testing.T) { - mgr, _ := setupManagerTest(t) +func TestManagerCreate_RollsBackRecordOnAgentFailure(t *testing.T) { + mgr, agent, db := setupManagerTest(t) + agent.ensureErr = errors.New("agent-server down") - _, err := mgr.Create("A") - if err != ErrInvalidName { - t.Errorf("expected ErrInvalidName, got %v", err) + _, err := mgr.Create(context.Background(), "fail-app") + if err == nil { + t.Fatal("expected Create to fail when agent registration fails") + } + + // The appx record must have been rolled back. + var count int + db.QueryRow("SELECT COUNT(*) FROM projects WHERE name = 'fail-app'").Scan(&count) + if count != 0 { + t.Errorf("expected no DB record after rollback, got %d", count) } } -func TestManagerDelete_RemovesDirectory(t *testing.T) { - mgr, _ := setupManagerTest(t) +func TestManagerDelete_DeregistersAndRemovesRecord(t *testing.T) { + mgr, agent, _ := setupManagerTest(t) - p, err := mgr.Create("my-app") + p, err := mgr.Create(context.Background(), "my-app") if err != nil { t.Fatalf("Create: %v", err) } - projectDir := filepath.Join(mgr.ProjectRoot, "my-app") - if _, err := os.Stat(projectDir); err != nil { - t.Fatalf("directory should exist before delete: %v", err) - } - - if err := mgr.Delete(p.ID); err != nil { + if err := mgr.Delete(context.Background(), p.ID); err != nil { t.Fatalf("Delete: %v", err) } - if _, err := os.Stat(projectDir); !os.IsNotExist(err) { - t.Errorf("expected directory to be removed") + // Deregistered by name (agent-server id). + if len(agent.deleted) != 1 || agent.deleted[0] != "my-app" { + t.Errorf("expected DeleteProject(\"my-app\"), got %v", agent.deleted) + } + // appx record removed. + if _, err := mgr.Get(p.ID); err != ErrNotFound { + t.Errorf("expected ErrNotFound after delete, got %v", err) } } func TestManagerDelete_NotFound(t *testing.T) { - mgr, _ := setupManagerTest(t) + mgr, _, _ := setupManagerTest(t) - err := mgr.Delete("nonexistent") + err := mgr.Delete(context.Background(), "nonexistent") if err != ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } } -func TestManagerCreate_CleansUpDirectoryOnScaffoldFailure(t *testing.T) { - mgr, db := setupManagerTest(t) - - // Make the projectRoot a read-only directory so MkdirAll for the project - // subdirectory succeeds (the root itself exists) but writing AGENTS.md fails. - // We do this by replacing the projectRoot with a read-only directory. - readOnlyRoot := t.TempDir() - if err := os.Chmod(readOnlyRoot, 0555); err != nil { - t.Skipf("cannot chmod temp dir (may be running as root): %v", err) - } - t.Cleanup(func() { os.Chmod(readOnlyRoot, 0755) }) - - mgr.ProjectRoot = readOnlyRoot +func TestManagerDelete_AgentFailureKeepsRecord(t *testing.T) { + mgr, agent, _ := setupManagerTest(t) - _, err := mgr.Create("fail-app") - if err == nil { - t.Fatal("expected Create to fail on read-only projectRoot") + p, err := mgr.Create(context.Background(), "my-app") + if err != nil { + t.Fatalf("Create: %v", err) } + agent.deleteErr = errors.New("agent-server down") - // The partial directory should NOT exist. - projectDir := filepath.Join(readOnlyRoot, "fail-app") - if _, statErr := os.Stat(projectDir); !os.IsNotExist(statErr) { - t.Errorf("expected directory %s to be cleaned up, but it still exists", projectDir) + if err := mgr.Delete(context.Background(), p.ID); err == nil { + t.Fatal("expected Delete to fail when deregistration fails") } - - // The DB record should NOT exist. - var count int - db.QueryRow("SELECT COUNT(*) FROM projects WHERE name = 'fail-app'").Scan(&count) - if count != 0 { - t.Errorf("expected no DB record for failed project, got %d", count) + // The record must remain so the operator can retry rather than losing track. + if _, err := mgr.Get(p.ID); err != nil { + t.Errorf("expected record to remain after failed deregistration: %v", err) } } -func TestManagerCreate_AGENTSmdUsesBaseDomain(t *testing.T) { - mgr, _ := setupManagerTest(t) - mgr.BaseDomain = "user.appx.app" +func TestManagerReconcileAgentProjects_RegistersAll(t *testing.T) { + mgr, agent, _ := setupManagerTest(t) - _, err := mgr.Create("my-app") - if err != nil { - t.Fatalf("Create: %v", err) + if _, err := mgr.Create(context.Background(), "alpha"); err != nil { + t.Fatal(err) } - - agentsPath := filepath.Join(mgr.ProjectRoot, "my-app", "AGENTS.md") - content, err := os.ReadFile(agentsPath) - if err != nil { - t.Fatalf("AGENTS.md not created: %v", err) + if _, err := mgr.Create(context.Background(), "beta"); err != nil { + t.Fatal(err) } - if !strings.Contains(string(content), "my-app.user.appx.app") { - t.Errorf("expected AGENTS.md to contain 'my-app.user.appx.app', got:\n%s", content) + agent.ensured = nil // reset to observe only the reconcile pass + + if err := mgr.ReconcileAgentProjects(context.Background()); err != nil { + t.Fatalf("ReconcileAgentProjects: %v", err) } - if strings.Contains(string(content), ".localhost") { - t.Errorf("expected AGENTS.md to NOT contain '.localhost' when baseDomain is set, got:\n%s", content) + if len(agent.ensured) != 2 { + t.Errorf("expected 2 re-registrations, got %v", agent.ensured) } } func TestManagerProjectDir_ReturnsPath(t *testing.T) { - mgr, _ := setupManagerTest(t) + mgr, _, _ := setupManagerTest(t) dir := mgr.ProjectDir("my-app") if dir == "" { diff --git a/internal/project/project.go b/internal/project/project.go index cf9d866..b6d8886 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -41,13 +41,12 @@ const PortRangeEnd = 10999 // on disk containing a git repository. The AssignedPort is used by the subdomain // reverse proxy to route .localhost requests to the project's dev server. type Project struct { - ID string `json:"id"` - Name string `json:"name"` - Status ProjectStatus `json:"status"` - AssignedPort int `json:"assignedPort"` - OpenCodeProjectID string `json:"openCodeProjectId,omitempty"` - LastError string `json:"lastError,omitempty"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + Name string `json:"name"` + Status ProjectStatus `json:"status"` + AssignedPort int `json:"assignedPort"` + LastError string `json:"lastError,omitempty"` + CreatedAt string `json:"createdAt"` // AppRunning indicates whether a TCP listener is active on the project's // assigned port. Populated at query time by the health checker, not persisted. AppRunning bool `json:"appRunning"` diff --git a/internal/project/store.go b/internal/project/store.go index b3e2127..d790236 100644 --- a/internal/project/store.go +++ b/internal/project/store.go @@ -9,9 +9,8 @@ import ( ) // Store provides CRUD operations for projects in the SQLite database. -// New projects use assigned_port and opencode_project_id columns; legacy Docker -// columns (container_id, network_id, image_name, container_secret, resources) -// are retained in the schema but ignored by new code. +// New projects use assigned_port for app subdomain routing. Legacy Docker +// columns are retained in existing databases but ignored by new code. type Store struct { db *sql.DB } @@ -21,8 +20,8 @@ func NewStore(db *sql.DB) *Store { return &Store{db: db} } -// projectColumns is the canonical SELECT column list. Only new-architecture columns. -const projectColumns = `id, name, status, assigned_port, opencode_project_id, last_error, created_at` +// projectColumns is the canonical SELECT column list used by all project reads. +const projectColumns = `id, name, status, assigned_port, last_error, created_at` // Create inserts a new project with the given name and an auto-assigned port // from PortRangeStart-PortRangeEnd. Returns ErrInvalidName, ErrDuplicateName, @@ -129,22 +128,6 @@ func (s *Store) GetByName(name string) (*Project, error) { return p, nil } -// SetOpenCodeProjectID stores the OpenCode project ID after discovery. -func (s *Store) SetOpenCodeProjectID(id, ocProjectID string) error { - res, err := s.db.Exec( - "UPDATE projects SET opencode_project_id = ? WHERE id = ?", - ocProjectID, id, - ) - if err != nil { - return fmt.Errorf("set opencode project id: %w", err) - } - n, _ := res.RowsAffected() - if n == 0 { - return ErrNotFound - } - return nil -} - // SetError updates a project to the error state with an error message. func (s *Store) SetError(id string, errMsg string) error { _, err := s.db.Exec( @@ -228,10 +211,10 @@ type scanner interface { func scanInto(sc scanner) (*Project, error) { var p Project var assignedPort sql.NullInt64 - var ocProjectID, lastError sql.NullString + var lastError sql.NullString err := sc.Scan( &p.ID, &p.Name, &p.Status, &assignedPort, - &ocProjectID, &lastError, &p.CreatedAt, + &lastError, &p.CreatedAt, ) if err != nil { return nil, err @@ -239,7 +222,6 @@ func scanInto(sc scanner) (*Project, error) { if assignedPort.Valid { p.AssignedPort = int(assignedPort.Int64) } - p.OpenCodeProjectID = ocProjectID.String p.LastError = lastError.String return &p, nil } diff --git a/internal/project/store_test.go b/internal/project/store_test.go index 5a1b943..931adfd 100644 --- a/internal/project/store_test.go +++ b/internal/project/store_test.go @@ -8,7 +8,7 @@ import ( _ "modernc.org/sqlite" ) -// setupTestDB creates an in-memory SQLite database with the full schema (migrations 1-4). +// setupTestDB creates an in-memory SQLite database with the full project schema. func setupTestDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("sqlite", ":memory:") @@ -31,8 +31,7 @@ func setupTestDB(t *testing.T) *sql.DB { last_error TEXT, resources TEXT, container_secret TEXT, - assigned_port INTEGER, - opencode_project_id TEXT + assigned_port INTEGER ); CREATE UNIQUE INDEX IF NOT EXISTS idx_assigned_port ON projects(assigned_port) WHERE assigned_port IS NOT NULL; `) @@ -239,29 +238,6 @@ func TestGetByName(t *testing.T) { } } -func TestSetOpenCodeProjectID(t *testing.T) { - store := NewStore(setupTestDB(t)) - p, _ := store.Create("my-app") - - err := store.SetOpenCodeProjectID(p.ID, "oc-abc123") - if err != nil { - t.Fatalf("SetOpenCodeProjectID: %v", err) - } - - got, _ := store.Get(p.ID) - if got.OpenCodeProjectID != "oc-abc123" { - t.Errorf("expected 'oc-abc123', got %q", got.OpenCodeProjectID) - } -} - -func TestSetOpenCodeProjectID_NotFound(t *testing.T) { - store := NewStore(setupTestDB(t)) - err := store.SetOpenCodeProjectID("nonexistent", "oc-abc123") - if err != ErrNotFound { - t.Errorf("expected ErrNotFound, got %v", err) - } -} - func TestNextAvailablePort_Empty(t *testing.T) { store := NewStore(setupTestDB(t)) port, err := store.nextAvailablePort() diff --git a/internal/server/agent_proxy.go b/internal/server/agent_proxy.go new file mode 100644 index 0000000..ed6b25c --- /dev/null +++ b/internal/server/agent_proxy.go @@ -0,0 +1,209 @@ +package server + +import ( + "fmt" + "log" + "net/http" + "net/http/httputil" + "net/url" + "path" + "strings" + "time" + + "github.com/neuromaxer/appx/internal/project" +) + +const ( + // agentProjectIDHeader carries the resolved agent-server project id (== appx + // project name) from the proxy handler to the reverse-proxy Director. It is + // consumed and stripped before the request leaves appx; it is never sent to + // agent-server, which derives the project directory from its own registry. + agentProjectIDHeader = "X-Appx-Project-Id" +) + +// agentServerProxyHandler proxies Appx-authenticated project agent requests to +// a loopback Pi agent-server instance. The browser only sees same-origin Appx +// URLs; cookies and optional agent-server bearer credentials stay server-side. +// +// appx addresses projects by its own UUID, but agent-server keys projects by +// slug id (== the appx project name). We resolve the project here and hand the +// agent-server id to the Director via a short-lived internal header. +func agentServerProxyHandler(pm *project.Manager, backendURL string, token string) http.Handler { + proxy := agentServerReverseProxy(backendURL, token, true) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + projectID := r.PathValue("id") + if projectID == "" { + http.Error(w, "project id required", http.StatusBadRequest) + return + } + proj, err := pm.Get(projectID) + if err != nil { + http.Error(w, "project not found", http.StatusNotFound) + return + } + + // SSE streams can live much longer than the server write timeout. + http.NewResponseController(w).SetWriteDeadline(time.Time{}) + r = r.Clone(r.Context()) + r.Header.Set(agentProjectIDHeader, proj.Name) + proxy.ServeHTTP(w, r) + }) +} + +// agentServerGlobalProxyHandler exposes global runtime resources such as auth +// status. Unlike project session routes, these are tied to the configured +// agent-server process and do not require a project id. +func agentServerGlobalProxyHandler(backendURL string, token string) http.Handler { + proxy := agentServerReverseProxy(backendURL, token, false) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NewResponseController(w).SetWriteDeadline(time.Time{}) + proxy.ServeHTTP(w, r) + }) +} + +func agentServerReverseProxy(backendURL string, token string, projectScoped bool) http.Handler { + target, err := url.Parse(backendURL) + if err != nil || target.Scheme == "" || target.Host == "" { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, fmt.Sprintf("invalid agent-server URL %q", backendURL), http.StatusInternalServerError) + }) + } + + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + agentPath := strings.TrimPrefix(req.PathValue("agentPath"), "/") + proxyPrefix := "/v1" + if projectScoped { + // Use the resolved agent-server id (== project name), not appx's + // UUID path value, then strip the internal header. + agentProjectID := req.Header.Get(agentProjectIDHeader) + proxyPrefix = "/v1/projects/" + url.PathEscape(agentProjectID) + } + req.URL.Path = cleanAgentServerPath(proxyPrefix, agentPath) + req.URL.RawPath = "" + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.Host = target.Host + req.Header.Del("Cookie") + // Internal headers never leave appx; agent-server resolves the project + // directory from its own persisted registry. + req.Header.Del(agentProjectIDHeader) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("agent-server proxy error path=%s: %v", r.URL.Path, err) + http.Error(w, "agent-server unavailable", http.StatusBadGateway) + }, + FlushInterval: -1, + } + + return proxy +} + +func cleanAgentServerPath(prefix string, agentPath string) string { + cleaned := path.Clean("/" + strings.TrimPrefix(agentPath, "/")) + if cleaned == "/" { + return prefix + } + return prefix + cleaned +} + +// agentServerMirrorHandler proxies the agent-server `/v1` contract 1:1 under a +// single same-origin appx mount, so the agent-chat SDK can talk to agent-server +// through appx without per-operation URL rewriting (the SDK is configured with +// one baseUrl + the native `/v1` prefix). The browser sees only same-origin +// appx URLs; the agent-server bearer token and the appx cookie stay +// server-side. +// +// Access control (OWASP A01 — broken access control): appx is a per-project +// control plane, so the mirror only forwards a deliberately narrow slice of the +// contract: +// - GET /v1/sessions/... session-independent, read-only globals +// (e.g. the model catalogue); project-agnostic. +// - /v1/projects/{slug}/sessions... session traffic, only when the +// authenticated user owns a project whose +// slug (== appx project name) is registered. +// +// Project lifecycle routes (`POST/GET /v1/projects`, `GET/DELETE +// /v1/projects/{slug}`) are intentionally NOT exposed: project creation and +// deletion go through appx's own `/api/projects` surface, which also assigns +// ports/subdomains and owns the control-plane record. This prevents a logged-in +// user from reaching unregistered agent-server projects (e.g. another tenant's +// or another product's) that happen to share the backend. +func agentServerMirrorHandler(pm *project.Manager, backendURL string, token string) http.Handler { + target, err := url.Parse(backendURL) + if err != nil || target.Scheme == "" || target.Host == "" { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, fmt.Sprintf("invalid agent-server URL %q", backendURL), http.StatusInternalServerError) + }) + } + + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.Host = target.Host + // The contract path is forwarded verbatim (it already includes `/v1`); + // it is set on the request by the wrapping handler below. + req.Header.Del("Cookie") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("agent-server mirror proxy error path=%s: %v", r.URL.Path, err) + http.Error(w, "agent-server unavailable", http.StatusBadGateway) + }, + FlushInterval: -1, + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Resolve the contract path from the wildcard and normalise it so `..` + // segments cannot escape the `/v1` namespace (defence in depth on top of + // the allow-list below). + mirrorPath := path.Clean("/" + strings.TrimPrefix(r.PathValue("piPath"), "/")) + if !mirrorAccessAllowed(pm, r.Method, mirrorPath) { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + // SSE streams can live much longer than the server write timeout. + http.NewResponseController(w).SetWriteDeadline(time.Time{}) + r = r.Clone(r.Context()) + r.URL.Path = mirrorPath + r.URL.RawPath = "" + proxy.ServeHTTP(w, r) + }) +} + +// mirrorAccessAllowed enforces the mirror's narrow allow-list (see +// agentServerMirrorHandler). It receives the already-cleaned contract path +// (leading slash, no `..`). +func mirrorAccessAllowed(pm *project.Manager, method string, mirrorPath string) bool { + segments := strings.Split(strings.TrimPrefix(mirrorPath, "/"), "/") + if len(segments) < 2 || segments[0] != "v1" { + return false + } + + switch segments[1] { + case "sessions": + // Session-independent globals (e.g. /v1/sessions/models). Read-only. + return method == http.MethodGet + case "projects": + // Only project-scoped *session* traffic is allowed, and only for a + // project registered with appx. Bare project lifecycle is never exposed. + if len(segments) < 4 || segments[3] != "sessions" { + return false + } + slug := segments[2] + if slug == "" { + return false + } + _, err := pm.Store.GetByName(slug) + return err == nil + default: + return false + } +} diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go index 9ed2e4f..8c16d06 100644 --- a/internal/server/project_handlers.go +++ b/internal/server/project_handlers.go @@ -45,7 +45,7 @@ func handleCreateProject(pm *project.Manager) http.HandlerFunc { return } - proj, err := pm.Create(req.Name) + proj, err := pm.Create(r.Context(), req.Name) if err != nil { if errors.Is(err, project.ErrInvalidName) { http.Error(w, err.Error(), http.StatusBadRequest) @@ -96,7 +96,7 @@ func handleGetProject(pm *project.Manager, hc *project.HealthChecker) http.Handl func handleDeleteProject(pm *project.Manager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if err := pm.Delete(id); err != nil { + if err := pm.Delete(r.Context(), id); err != nil { if errors.Is(err, project.ErrNotFound) { http.Error(w, "not found", http.StatusNotFound) return diff --git a/internal/server/router.go b/internal/server/router.go index 20be451..ca4aaa7 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -4,18 +4,15 @@ import ( "encoding/json" "fmt" "io/fs" - "log" "net" "net/http" "net/http/httputil" "net/url" - "path" "strings" "time" "github.com/neuromaxer/appx/internal/auth" "github.com/neuromaxer/appx/internal/egress" - "github.com/neuromaxer/appx/internal/opencode" "github.com/neuromaxer/appx/internal/project" "github.com/neuromaxer/appx/internal/terminal" ) @@ -23,18 +20,18 @@ import ( // RouterConfig holds runtime configuration that affects routing behaviour. // Passed to NewRouter so middleware can adapt to the deployment mode. type RouterConfig struct { - HTTPMode bool // true = plain HTTP dev mode, affects security headers - BaseDomain string // base domain for subdomain routing - HostAliases []string // additional hostnames/IPs that also serve the dashboard (e.g. server IP) - OpenCodeURL string // URL of the OpenCode server (default "http://localhost:4096") + HTTPMode bool // true = plain HTTP dev mode, affects security headers + BaseDomain string // base domain for subdomain routing + HostAliases []string // additional hostnames/IPs that also serve the dashboard (e.g. server IP) + AgentServerURL string // URL of the Pi agent-server (default "http://127.0.0.1:4001") + AgentServerToken string // optional bearer token for Pi agent-server } // NewRouter builds the top-level HTTP handler. All requests go through auth // middleware (except POST /api/login which is public and rate-limited). -// oc may be nil (in tests or when OpenCode is not configured). // es must not be nil; pass egress.NewStore(db) from the caller. // lm must not be nil; pass terminal.NewLocalManager(bufSize) from the caller. -func NewRouter(a *auth.Auth, pm *project.Manager, webFS fs.FS, rcfg RouterConfig, oc *opencode.Client, es *egress.Store, ep *egress.PendingRegistry, lm *terminal.LocalManager) http.Handler { +func NewRouter(a *auth.Auth, pm *project.Manager, webFS fs.FS, rcfg RouterConfig, es *egress.Store, ep *egress.PendingRegistry, lm *terminal.LocalManager) http.Handler { mux := http.NewServeMux() // Public API routes (no auth) — rate limited @@ -49,9 +46,6 @@ func NewRouter(a *auth.Auth, pm *project.Manager, webFS fs.FS, rcfg RouterConfig api.HandleFunc("GET /api/projects/{id}", handleGetProject(pm, hc)) api.HandleFunc("DELETE /api/projects/{id}", handleDeleteProject(pm)) api.HandleFunc("PUT /api/settings/password", handleChangePassword(a)) - api.HandleFunc("GET /api/settings/api-key", handleGetAPIKeyStatus(a.Store)) - api.HandleFunc("PUT /api/settings/api-key", handleSetAPIKey(a.Store, oc)) - api.HandleFunc("DELETE /api/settings/api-key", handleDeleteAPIKey(a.Store, oc)) api.HandleFunc("GET /api/settings/terminal-buffer-size", handleGetTerminalBufferSize(a.Store)) api.HandleFunc("PUT /api/settings/terminal-buffer-size", handleSetTerminalBufferSize(a.Store)) api.HandleFunc("GET /api/config", handleGetConfig(rcfg.BaseDomain)) @@ -64,6 +58,20 @@ func NewRouter(a *auth.Auth, pm *project.Manager, webFS fs.FS, rcfg RouterConfig api.HandleFunc("POST /api/egress/pending/{id}/approve", handleApproveEgressRequest(ep)) api.HandleFunc("POST /api/egress/pending/{id}/deny", handleDenyEgressRequest(ep)) } + agentServerURL := rcfg.AgentServerURL + if agentServerURL == "" { + agentServerURL = "http://127.0.0.1:4001" + } + agentGlobalProxy := agentServerGlobalProxyHandler(agentServerURL, rcfg.AgentServerToken) + api.Handle("GET /api/agent/{agentPath...}", agentGlobalProxy) + api.Handle("POST /api/agent/{agentPath...}", agentGlobalProxy) + api.Handle("PUT /api/agent/{agentPath...}", agentGlobalProxy) + api.Handle("DELETE /api/agent/{agentPath...}", agentGlobalProxy) + agentProxy := agentServerProxyHandler(pm, agentServerURL, rcfg.AgentServerToken) + api.Handle("GET /api/projects/{id}/agent/{agentPath...}", agentProxy) + api.Handle("POST /api/projects/{id}/agent/{agentPath...}", agentProxy) + api.Handle("PATCH /api/projects/{id}/agent/{agentPath...}", agentProxy) + api.Handle("DELETE /api/projects/{id}/agent/{agentPath...}", agentProxy) mux.Handle("/api/", limitBody(a.Middleware(requireJSON(api)))) // Shell (local PTY) routes — outside the requireJSON api mux because the @@ -72,16 +80,19 @@ func NewRouter(a *auth.Auth, pm *project.Manager, webFS fs.FS, rcfg RouterConfig mux.Handle("PUT /api/shell/{id}", a.Middleware(limitBody(requireJSON(http.HandlerFunc(handleShellResize(lm)))))) mux.Handle("GET /api/shell/{id}/connect", a.Middleware(http.HandlerFunc(handleShellConnect(lm)))) - // OpenCode health endpoint — registered on the outer mux before the /api/opencode/ - // proxy so the more-specific pattern takes precedence. Protected by auth middleware. - mux.Handle("GET /api/opencode/health", a.Middleware(http.HandlerFunc(handleOpenCodeHealth(oc)))) - - // OpenCode API proxy — strips /api/opencode prefix, forwards to OpenCode server. - ocURL := rcfg.OpenCodeURL - if ocURL == "" { - ocURL = "http://localhost:4096" - } - mux.Handle("/api/opencode/", a.Middleware(openCodeProxyHandler(ocURL))) + // agent-chat SDK gateway: same-origin 1:1 mirror of the agent-server /v1 + // contract. Mounted on the top-level mux (auth + body limit) rather than the + // requireJSON-wrapped api mux, because the SDK issues legitimate *bodyless* + // POSTs (create session, abort) that requireJSON would reject with 415. The + // route is still CSRF-safe: state-changing requests carry the SameSite=Lax + // session cookie (not sent on cross-site POST) and auth runs first, so a + // forged cross-origin request is rejected with 401 before reaching the proxy. + // More-specific patterns win over the "/api/" mux below. + agentMirror := agentServerMirrorHandler(pm, agentServerURL, rcfg.AgentServerToken) + mux.Handle("GET /api/pi/{piPath...}", a.Middleware(agentMirror)) + mux.Handle("POST /api/pi/{piPath...}", a.Middleware(limitBody(agentMirror))) + mux.Handle("PATCH /api/pi/{piPath...}", a.Middleware(limitBody(agentMirror))) + mux.Handle("DELETE /api/pi/{piPath...}", a.Middleware(agentMirror)) // React SPA fallback fileServer := http.FileServerFS(webFS) @@ -174,49 +185,6 @@ func NewRouter(a *auth.Auth, pm *project.Manager, webFS fs.FS, rcfg RouterConfig }) } -// openCodeProxyHandler returns an http.Handler that reverse-proxies requests to -// the OpenCode server. The /api/opencode prefix is stripped before forwarding. -// The Cookie header is stripped to prevent the appx session cookie from reaching -// OpenCode. FlushInterval=-1 enables streaming for SSE responses. A single -// ReverseProxy instance is reused across requests for connection pooling. -// -// The per-request write deadline is disabled before proxying because OpenCode -// exposes long-lived SSE event streams (agent subscriptions) and WebSocket PTY -// tunnels that outlive the server's 60s WriteTimeout. ReadHeaderTimeout still -// guards against slow-header attacks on inbound requests. -func openCodeProxyHandler(backendURL string) http.Handler { - target, err := url.Parse(backendURL) - if err != nil { - log.Fatalf("invalid OpenCode URL %q: %v", backendURL, err) - } - - proxy := &httputil.ReverseProxy{ - Director: func(req *http.Request) { - // Strip /api/opencode prefix and canonicalize to prevent path - // traversal against the backend. Clear RawPath so the proxy uses - // the cleaned Path. - req.URL.Path = path.Clean(strings.TrimPrefix(req.URL.Path, "/api/opencode")) - req.URL.RawPath = "" - if req.URL.Path == "." { - req.URL.Path = "/" - } - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - req.Host = target.Host - req.Header.Del("Cookie") - }, - FlushInterval: -1, - } - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Remove the write deadline for this connection. SSE streams and - // WebSocket tunnels are indefinitely long — the 60s WriteTimeout on - // the http.Server would otherwise cut them with ERR_INCOMPLETE_CHUNKED_ENCODING. - http.NewResponseController(w).SetWriteDeadline(time.Time{}) - proxy.ServeHTTP(w, r) - }) -} - // stripPort removes the port suffix from a host:port string. Uses // net.SplitHostPort so IPv6 addresses in brackets are handled correctly // (e.g. "[::1]:8080" → "::1"). Returns the host unchanged if no port is present. diff --git a/internal/server/router_test.go b/internal/server/router_test.go index c14588b..e71ac26 100644 --- a/internal/server/router_test.go +++ b/internal/server/router_test.go @@ -1,11 +1,7 @@ package server import ( - "bufio" - "crypto/sha1" - "github.com/neuromaxer/appx/internal/terminal" "database/sql" - "encoding/base64" "encoding/json" "fmt" "net" @@ -19,16 +15,15 @@ import ( "github.com/neuromaxer/appx/internal/auth" "github.com/neuromaxer/appx/internal/egress" - "github.com/neuromaxer/appx/internal/opencode" "github.com/neuromaxer/appx/internal/project" - _ "modernc.org/sqlite" + "github.com/neuromaxer/appx/internal/terminal" "golang.org/x/crypto/bcrypt" + _ "modernc.org/sqlite" ) // testSchema is the minimal in-memory SQLite schema used by all server tests. -// It includes the new assigned_port and opencode_project_id columns added in -// migration 4, omitting legacy Docker columns that are no longer read by any -// handler. +// It includes the project columns read by handlers while omitting legacy Docker +// columns that are no longer used. const testSchema = ` CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE sessions (token TEXT PRIMARY KEY, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME); @@ -37,7 +32,6 @@ const testSchema = ` name TEXT UNIQUE NOT NULL, status TEXT DEFAULT 'stopped', assigned_port INTEGER, - opencode_project_id TEXT, last_error TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -80,7 +74,7 @@ func setupTest(t *testing.T) (http.Handler, *auth.Store, *sql.DB) { "assets/index-abc.js": {Data: []byte("console.log('hi')")}, } - return NewRouter(a, pm, webFS, RouterConfig{}, nil, es, nil, terminal.NewLocalManager(65536)), store, db + return NewRouter(a, pm, webFS, RouterConfig{}, es, nil, terminal.NewLocalManager(65536)), store, db } // setupTestWithHTTPMode creates a test handler configured for HTTP dev mode @@ -125,7 +119,7 @@ func setupTestWithConfig(t *testing.T, rcfg RouterConfig) (http.Handler, *auth.S "assets/index-abc.js": {Data: []byte("console.log('hi')")}, } - return NewRouter(a, pm, webFS, rcfg, nil, es, nil, terminal.NewLocalManager(65536)), store, db + return NewRouter(a, pm, webFS, rcfg, es, nil, terminal.NewLocalManager(65536)), store, db } // authedRequest creates an HTTP request with a valid session cookie. @@ -569,98 +563,14 @@ func TestDeleteProject_NotFound(t *testing.T) { // --- Settings endpoint tests --- -func TestGetAPIKeyStatus(t *testing.T) { - handler, store, _ := setupTest(t) - - req := authedRequest(t, store, "GET", "/api/settings/api-key", "") - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp map[string]bool - json.NewDecoder(w.Body).Decode(&resp) - // No key set yet — should be false. - if resp["set"] { - t.Error("expected set=false for fresh store, got true") - } -} - -func TestSetAPIKey(t *testing.T) { - handler, store, _ := setupTest(t) - - req := authedRequest(t, store, "PUT", "/api/settings/api-key", `{"key":"sk-ant-new-key"}`) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify it was stored in the DB. - val, err := store.GetSetting("anthropic_api_key") - if err != nil { - t.Fatal(err) - } - if val != "sk-ant-new-key" { - t.Errorf("expected stored key, got %q", val) - } -} - -func TestSetAPIKey_EmptyKey(t *testing.T) { - handler, store, _ := setupTest(t) - - req := authedRequest(t, store, "PUT", "/api/settings/api-key", `{"key":""}`) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("expected 400, got %d", w.Code) - } -} - -func TestDeleteAPIKey(t *testing.T) { - handler, store, _ := setupTest(t) - - // Set a key first. - req := authedRequest(t, store, "PUT", "/api/settings/api-key", `{"key":"sk-ant-test"}`) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("set: expected 200, got %d", w.Code) - } - - // Delete it. - req = authedRequest(t, store, "DELETE", "/api/settings/api-key", "") - w = httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("delete: expected 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify key status is now false. - req = authedRequest(t, store, "GET", "/api/settings/api-key", "") - w = httptest.NewRecorder() - handler.ServeHTTP(w, req) - - var resp map[string]bool - json.NewDecoder(w.Body).Decode(&resp) - if resp["set"] { - t.Error("expected set=false after delete") - } -} - func TestSettingsEndpoints_NoAuth(t *testing.T) { handler, _, _ := setupTest(t) - req := httptest.NewRequest("GET", "/api/settings/api-key", nil) + req := httptest.NewRequest("GET", "/api/settings/terminal-buffer-size", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { - t.Errorf("GET /api/settings/api-key: expected 401, got %d", w.Code) + t.Errorf("GET /api/settings/terminal-buffer-size: expected 401, got %d", w.Code) } } @@ -896,18 +806,45 @@ func TestDashboardRouteHasStrictCSP(t *testing.T) { } } -// setupTestWithOpenCodeBackend creates a test handler configured to proxy -// /api/opencode/* requests to the given openCodeURL backend. -func setupTestWithOpenCodeBackend(t *testing.T, openCodeURL string) (http.Handler, *auth.Store, *sql.DB) { +// deadlineRecorder wraps httptest.ResponseRecorder and records whether +// SetWriteDeadline was called with the zero time (meaning "no deadline"). +type deadlineRecorder struct { + *httptest.ResponseRecorder + writeDeadlineCleared bool +} + +func (r *deadlineRecorder) SetWriteDeadline(t time.Time) error { + if t.IsZero() { + r.writeDeadlineCleared = true + } + return nil +} + +// setupTestWithAgentServerBackend creates a test handler configured to proxy +// /api/projects/{id}/agent/* requests to the given agent-server URL. +func setupTestWithAgentServerBackend(t *testing.T, agentServerURL string, token string) (http.Handler, *auth.Store, *sql.DB) { t.Helper() - rcfg := RouterConfig{OpenCodeURL: openCodeURL} + rcfg := RouterConfig{AgentServerURL: agentServerURL, AgentServerToken: token} return setupTestWithConfig(t, rcfg) } -func TestOpenCodeProxy_RequiresAuth(t *testing.T) { - handler, _, _ := setupTest(t) +func insertProject(t *testing.T, db *sql.DB, id string) { + t.Helper() + _, err := db.Exec( + "INSERT INTO projects (id, name, status, assigned_port) VALUES (?, ?, 'running', 3000)", + id, + "proj-"+id, + ) + if err != nil { + t.Fatal(err) + } +} + +func TestAgentServerProxy_RequiresAuth(t *testing.T) { + handler, _, db := setupTestWithAgentServerBackend(t, "http://127.0.0.1:4001", "") + insertProject(t, db, "p1") - req := httptest.NewRequest("GET", "/api/opencode/session", nil) + req := httptest.NewRequest("GET", "/api/projects/p1/agent/sessions", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -916,7 +853,82 @@ func TestOpenCodeProxy_RequiresAuth(t *testing.T) { } } -func TestOpenCodeProxy_Authed_ForwardsRequest(t *testing.T) { +func TestAgentServerProxy_Authed_ForwardsRequest(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "path": r.URL.Path, + "query": r.URL.RawQuery, + "method": r.Method, + // agent-server resolves the project from its own registry; appx must + // not leak any internal project headers to it. + "projectId": r.Header.Get(agentProjectIDHeader), + }) + })) + defer backend.Close() + + handler, store, db := setupTestWithAgentServerBackend(t, backend.URL, "") + insertProject(t, db, "p1") + + req := authedRequest(t, store, "GET", "/api/projects/p1/agent/sessions?limit=10", "") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + // The proxy addresses agent-server by the project *name* (its slug id), + // not appx's UUID. insertProject names project p1 "proj-p1". + if resp["path"] != "/v1/projects/proj-p1/sessions" { + t.Errorf("expected path /v1/projects/proj-p1/sessions after prefix rewrite, got %q", resp["path"]) + } + if resp["query"] != "limit=10" { + t.Errorf("expected query string preserved, got %q", resp["query"]) + } + if resp["projectId"] != "" { + t.Errorf("expected internal project headers stripped, got %q", resp["projectId"]) + } +} + +func TestAgentServerGlobalProxy_Authed_ForwardsAuthRequest(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "path": r.URL.Path, + "method": r.Method, + "cookie": r.Header.Get("Cookie"), + "authorization": r.Header.Get("Authorization"), + }) + })) + defer backend.Close() + + handler, store, _ := setupTestWithAgentServerBackend(t, backend.URL, "secret-token") + + req := authedRequest(t, store, "GET", "/api/agent/auth/providers", "") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["path"] != "/v1/auth/providers" { + t.Errorf("expected path /v1/auth/providers after prefix strip, got %q", resp["path"]) + } + if resp["cookie"] != "" { + t.Errorf("expected appx cookie to be stripped, got %q", resp["cookie"]) + } + if resp["authorization"] != "Bearer secret-token" { + t.Errorf("expected bearer token forwarded, got %q", resp["authorization"]) + } +} + +func TestAgentServerGlobalProxy_Authed_ForwardsPostRequest(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ @@ -926,9 +938,9 @@ func TestOpenCodeProxy_Authed_ForwardsRequest(t *testing.T) { })) defer backend.Close() - handler, store, _ := setupTestWithOpenCodeBackend(t, backend.URL) + handler, store, _ := setupTestWithAgentServerBackend(t, backend.URL, "") - req := authedRequest(t, store, "GET", "/api/opencode/session", "") + req := authedRequest(t, store, "POST", "/api/agent/auth/providers/openai-codex/subscription/start", "{}") w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -938,24 +950,64 @@ func TestOpenCodeProxy_Authed_ForwardsRequest(t *testing.T) { var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) - if resp["path"] != "/session" { - t.Errorf("expected path /session after prefix strip, got %q", resp["path"]) + if resp["path"] != "/v1/auth/providers/openai-codex/subscription/start" { + t.Errorf("expected subscription path after prefix strip, got %q", resp["path"]) + } + if resp["method"] != "POST" { + t.Errorf("expected POST forwarded, got %q", resp["method"]) } } -func TestOpenCodeProxy_Authed_PreservesQueryString(t *testing.T) { +func TestAgentServerProxy_KeepsCleanedPathUnderV1Prefix(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path}) + })) + defer backend.Close() + + handler, store, _ := setupTestWithAgentServerBackend(t, backend.URL, "") + + req := authedRequest(t, store, "GET", "/api/agent/%2e%2e/health", "") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["path"] != "/v1/health" { + t.Errorf("expected cleaned path to stay under /v1, got %q", resp["path"]) + } +} + +func TestAgentServerGlobalProxy_RequiresAuth(t *testing.T) { + handler, _, _ := setupTestWithAgentServerBackend(t, "http://127.0.0.1:4001", "") + + req := httptest.NewRequest("GET", "/api/agent/auth/providers", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestAgentServerProxy_StripsCookieAndAddsBearer(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ - "path": r.URL.Path, - "query": r.URL.RawQuery, + "cookie": r.Header.Get("Cookie"), + "authorization": r.Header.Get("Authorization"), }) })) defer backend.Close() - handler, store, _ := setupTestWithOpenCodeBackend(t, backend.URL) + handler, store, db := setupTestWithAgentServerBackend(t, backend.URL, "secret-token") + insertProject(t, db, "p1") - req := authedRequest(t, store, "GET", "/api/opencode/session?projectID=abc&limit=10", "") + req := authedRequest(t, store, "GET", "/api/projects/p1/agent/sessions", "") w := httptest.NewRecorder() handler.ServeHTTP(w, req) @@ -965,39 +1017,41 @@ func TestOpenCodeProxy_Authed_PreservesQueryString(t *testing.T) { var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) - if resp["query"] != "projectID=abc&limit=10" { - t.Errorf("expected query string preserved, got %q", resp["query"]) + if resp["cookie"] != "" { + t.Errorf("expected appx cookie to be stripped, got %q", resp["cookie"]) + } + if resp["authorization"] != "Bearer secret-token" { + t.Errorf("expected bearer token forwarded, got %q", resp["authorization"]) } } -// deadlineRecorder wraps httptest.ResponseRecorder and records whether -// SetWriteDeadline was called with the zero time (meaning "no deadline"). -type deadlineRecorder struct { - *httptest.ResponseRecorder - writeDeadlineCleared bool -} +func TestAgentServerProxy_UnknownProjectReturns404(t *testing.T) { + handler, store, _ := setupTestWithAgentServerBackend(t, "http://127.0.0.1:4001", "") -func (r *deadlineRecorder) SetWriteDeadline(t time.Time) error { - if t.IsZero() { - r.writeDeadlineCleared = true + req := authedRequest(t, store, "GET", "/api/projects/nope/agent/sessions", "") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) } - return nil } -func TestOpenCodeProxy_ClearsWriteDeadline(t *testing.T) { +func TestAgentServerProxy_ClearsWriteDeadline(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer backend.Close() - handler, store, _ := setupTestWithOpenCodeBackend(t, backend.URL) + handler, store, db := setupTestWithAgentServerBackend(t, backend.URL, "") + insertProject(t, db, "p1") - req := authedRequest(t, store, "GET", "/api/opencode/session", "") + req := authedRequest(t, store, "GET", "/api/projects/p1/agent/sessions/s1/events", "") rec := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} handler.ServeHTTP(rec, req) if !rec.writeDeadlineCleared { - t.Error("expected write deadline to be cleared for OpenCode proxy requests (needed for SSE streams)") + t.Error("expected write deadline to be cleared for agent-server proxy requests") } } @@ -1144,91 +1198,6 @@ func TestListProjects_AppRunningField(t *testing.T) { } } -func TestOpenCodeHealth_NilClient(t *testing.T) { - handler, store, _ := setupTest(t) - req := authedRequest(t, store, "GET", "/api/opencode/health", "") - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rr.Code) - } - var resp struct { - Healthy bool `json:"healthy"` - } - if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { - t.Fatal(err) - } - if resp.Healthy { - t.Error("expected healthy=false with nil client") - } -} - -func TestOpenCodeHealth_RequiresAuth(t *testing.T) { - handler, _, _ := setupTest(t) - req := httptest.NewRequest("GET", "/api/opencode/health", nil) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - if rr.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", rr.Code) - } -} - -func TestSetAPIKey_InjectsIntoOpenCode(t *testing.T) { - // Start a fake OpenCode server that records the SetAuth call. - var gotProviderID, gotAPIKey string - fakOC := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // SetAuth uses PUT /auth/:providerID with {type, key} body - if strings.HasPrefix(r.URL.Path, "/auth/") && r.Method == http.MethodPut { - gotProviderID = strings.TrimPrefix(r.URL.Path, "/auth/") - var body struct { - Key string `json:"key"` - } - json.NewDecoder(r.Body).Decode(&body) - gotAPIKey = body.Key - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer fakOC.Close() - - // Build a router wired to the fake OpenCode client. - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - if _, err = db.Exec(testSchema); err != nil { - t.Fatal(err) - } - store := auth.NewStore(db) - store.SetBcryptCost(bcrypt.MinCost) - store.SetPassword("testpassword1") - a := auth.New(store) - ps := project.NewStore(db) - pm := project.NewManager(ps, t.TempDir()) - webFS := fstest.MapFS{"index.html": {Data: []byte("app")}} - - oc := opencode.NewClient(fakOC.URL) - es := egress.NewStore(db) - handler := NewRouter(a, pm, webFS, RouterConfig{}, oc, es, nil, terminal.NewLocalManager(65536)) - - // Set the API key via the API. - req := authedRequest(t, store, "PUT", "/api/settings/api-key", `{"key":"sk-ant-test123"}`) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - if gotProviderID != "anthropic" { - t.Errorf("expected providerID 'anthropic', got %q", gotProviderID) - } - if gotAPIKey != "sk-ant-test123" { - t.Errorf("expected apiKey 'sk-ant-test123', got %q", gotAPIKey) - } -} - func TestSubdomainDispatch_NoAppxSecurityHeaders(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", "default-src *") @@ -1433,7 +1402,7 @@ func TestPutAllowlist_InvalidFormat(t *testing.T) { // --- Config endpoint tests --- -func TestGetConfig_ReturnsDomain(t *testing.T) { +func TestGetConfig_ReturnsRuntimeConfig(t *testing.T) { handler, store, _ := setupTestWithConfig(t, RouterConfig{BaseDomain: "example.com"}) req := authedRequest(t, store, "GET", "/api/config", "") req.Host = "example.com" @@ -1550,7 +1519,7 @@ func TestStripPort(t *testing.T) { {"localhost", "localhost"}, {"example.com:443", "example.com"}, {"[::1]:8080", "::1"}, - {"[::1]", "[::1]"}, // no port — returned as-is + {"[::1]", "[::1]"}, // no port — returned as-is {"127.0.0.1:443", "127.0.0.1"}, {"127.0.0.1", "127.0.0.1"}, } @@ -1661,205 +1630,211 @@ func TestChangePassword_TooShort(t *testing.T) { } } -// wsAccept computes the Sec-WebSocket-Accept header value for a given key, -// per RFC 6455 §4.2.2. Used by the fake WebSocket backend in proxy tests. -func wsAccept(key string) string { - const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" - h := sha1.New() - h.Write([]byte(key + magic)) - return base64.StdEncoding.EncodeToString(h.Sum(nil)) +func TestChangePassword_InvalidatesOtherSessions(t *testing.T) { + handler, store, _ := setupTest(t) + + // Create a session that should be invalidated. + oldToken, err := store.CreateSession() + if err != nil { + t.Fatal(err) + } + + body := `{"currentPassword":"testpassword1","newPassword":"newpassword12345"}` + req := authedRequest(t, store, "PUT", "/api/settings/password", body) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // The old session should be invalid. + if store.ValidSession(oldToken) { + t.Error("old session should have been invalidated after password change") + } } -// TestOpenCodeProxy_WebSocketUpgrade verifies that the OpenCode reverse proxy -// correctly handles WebSocket upgrade requests (HTTP 101 Switching Protocols). -// It starts a real fake backend that performs a valid WebSocket handshake, then -// connects to the appx server via a raw TCP connection and checks that the proxy -// forwards the 101 response back to the client. -func TestOpenCodeProxy_WebSocketUpgrade(t *testing.T) { - var backendUpgraded bool +// --- agent-server /v1 mirror (agent-chat SDK gateway) --------------------- - // Fake OpenCode backend that accepts WebSocket upgrades. +func TestAgentMirror_RequiresAuth(t *testing.T) { + handler, _, db := setupTestWithAgentServerBackend(t, "http://127.0.0.1:4001", "") + insertProject(t, db, "p1") + + req := httptest.NewRequest("GET", "/api/pi/v1/projects/proj-p1/sessions", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestAgentMirror_ForwardsProjectSessionTraffic(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { - http.Error(w, "expected websocket upgrade", http.StatusBadRequest) - return - } - hj, ok := w.(http.Hijacker) - if !ok { - http.Error(w, "hijack not supported", http.StatusInternalServerError) - return - } - backendUpgraded = true - w.Header().Set("Upgrade", "websocket") - w.Header().Set("Connection", "Upgrade") - w.Header().Set("Sec-WebSocket-Accept", wsAccept(r.Header.Get("Sec-WebSocket-Key"))) - w.WriteHeader(http.StatusSwitchingProtocols) - conn, _, _ := hj.Hijack() - defer conn.Close() - // Hold the connection briefly so the proxy can copy the 101 headers. - time.Sleep(200 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "path": r.URL.Path, + "query": r.URL.RawQuery, + "cookie": r.Header.Get("Cookie"), + "authorization": r.Header.Get("Authorization"), + }) })) defer backend.Close() - handler, store, _ := setupTestWithOpenCodeBackend(t, backend.URL) + handler, store, db := setupTestWithAgentServerBackend(t, backend.URL, "secret-token") + insertProject(t, db, "p1") // name == "proj-p1" - // Start a real HTTP server — httptest.NewRecorder does not implement - // http.Hijacker, which is required for WebSocket upgrade proxying. - srv := httptest.NewServer(handler) - defer srv.Close() + req := authedRequest(t, store, "GET", "/api/pi/v1/projects/proj-p1/sessions?limit=10", "") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) - token, err := store.CreateSession() - if err != nil { - t.Fatal(err) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } - // Dial the appx server directly over TCP so we can send raw HTTP/1.1. - addr := strings.TrimPrefix(srv.URL, "http://") - conn, err := net.DialTimeout("tcp", addr, 3*time.Second) - if err != nil { - t.Fatalf("dial: %v", err) + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + // The /v1 contract path is forwarded verbatim (1:1 mirror), not rewritten. + if resp["path"] != "/v1/projects/proj-p1/sessions" { + t.Errorf("expected verbatim /v1 path, got %q", resp["path"]) } - defer conn.Close() - conn.SetDeadline(time.Now().Add(5 * time.Second)) + if resp["query"] != "limit=10" { + t.Errorf("expected query preserved, got %q", resp["query"]) + } + if resp["cookie"] != "" { + t.Errorf("expected appx cookie stripped, got %q", resp["cookie"]) + } + if resp["authorization"] != "Bearer secret-token" { + t.Errorf("expected bearer token forwarded, got %q", resp["authorization"]) + } +} - const wsKey = "dGhlIHNhbXBsZSBub25jZQ==" - fmt.Fprintf(conn, - "GET /api/opencode/pty/test-id/connect HTTP/1.1\r\nHost: localhost\r\nCookie: appx_session=%s\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: %s\r\n\r\n", - token, wsKey) +func TestAgentMirror_AllowsGlobalModelCatalogue(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path}) + })) + defer backend.Close() - // Read the HTTP response status line. - br := bufio.NewReader(conn) - statusLine, err := br.ReadString('\n') - if err != nil { - t.Fatalf("read status: %v", err) - } - if !strings.Contains(statusLine, "101") { - // Read remaining headers for a better diagnostic. - var rest strings.Builder - for { - line, _ := br.ReadString('\n') - rest.WriteString(line) - if strings.TrimSpace(line) == "" { - break - } - } - t.Errorf("expected 101 Switching Protocols, got: %q\nHeaders:\n%s", statusLine, rest.String()) + handler, store, _ := setupTestWithAgentServerBackend(t, backend.URL, "") + + req := authedRequest(t, store, "GET", "/api/pi/v1/sessions/models", "") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } - if !backendUpgraded { - t.Error("backend never received WebSocket upgrade request") + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["path"] != "/v1/sessions/models" { + t.Errorf("expected /v1/sessions/models, got %q", resp["path"]) } } -// TestOpenCodeProxy_WebSocketUpgrade_Integration tests WebSocket proxying against -// a real OpenCode backend. It is skipped unless OPENCODE_URL is set (or OpenCode -// is running on the default localhost:4096). Run with: -// -// OPENCODE_URL=http://localhost:4096 go test ./internal/server/ -run Integration -v -// -// This test creates a real PTY on OpenCode, then opens a WebSocket to it through -// the appx proxy, and verifies the 101 handshake completes. -func TestOpenCodeProxy_WebSocketUpgrade_Integration(t *testing.T) { - backendURL := "http://localhost:4096" +func TestAgentMirror_UnknownProjectForbidden(t *testing.T) { + handler, store, _ := setupTestWithAgentServerBackend(t, "http://127.0.0.1:4001", "") + + req := authedRequest(t, store, "GET", "/api/pi/v1/projects/ghost/sessions", "") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) - // Verify OpenCode is reachable; skip if not. - resp, err := http.Get(backendURL + "/health") - if err != nil || resp.StatusCode != http.StatusOK { - t.Skip("OpenCode not reachable at localhost:4096 — skipping integration test") + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for unregistered project, got %d", w.Code) } - resp.Body.Close() +} - handler, store, _ := setupTestWithOpenCodeBackend(t, backendURL) - srv := httptest.NewServer(handler) - defer srv.Close() +func TestAgentMirror_ProjectLifecycleRoutesForbidden(t *testing.T) { + handler, store, db := setupTestWithAgentServerBackend(t, "http://127.0.0.1:4001", "") + insertProject(t, db, "p1") - token, err := store.CreateSession() - if err != nil { - t.Fatal(err) + cases := []struct { + method string + path string + }{ + {"GET", "/api/pi/v1/projects"}, // list all projects + {"POST", "/api/pi/v1/projects"}, // create project + {"GET", "/api/pi/v1/projects/proj-p1"}, // bare project metadata + {"DELETE", "/api/pi/v1/projects/proj-p1"}, // delete project + {"GET", "/api/pi/v1/projects/proj-p1/settings"}, // non-session subresource + } + for _, tc := range cases { + req := authedRequest(t, store, tc.method, tc.path, "") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("%s %s: expected 403, got %d", tc.method, tc.path, w.Code) + } } +} - // Create a PTY through the proxy. - req, _ := http.NewRequest("POST", srv.URL+"/api/opencode/pty", strings.NewReader("{}")) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Cookie", "appx_session="+token) - req.Header.Set("x-opencode-directory", "/tmp") - client := &http.Client{} - ptyResp, err := client.Do(req) - if err != nil { - t.Fatalf("create PTY: %v", err) - } - defer ptyResp.Body.Close() - if ptyResp.StatusCode != http.StatusOK { - t.Fatalf("create PTY: expected 200, got %d", ptyResp.StatusCode) - } - var ptyData struct { - ID string `json:"id"` - } - if err := json.NewDecoder(ptyResp.Body).Decode(&ptyData); err != nil { - t.Fatalf("decode PTY response: %v", err) - } - if ptyData.ID == "" { - t.Fatal("PTY ID is empty") - } - t.Logf("created PTY: %s", ptyData.ID) +func TestAgentMirror_ClearsWriteDeadline(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() - // Now open a WebSocket to the PTY through the proxy. - addr := strings.TrimPrefix(srv.URL, "http://") - conn, err := net.DialTimeout("tcp", addr, 3*time.Second) - if err != nil { - t.Fatalf("dial: %v", err) - } - defer conn.Close() - conn.SetDeadline(time.Now().Add(5 * time.Second)) + handler, store, db := setupTestWithAgentServerBackend(t, backend.URL, "") + insertProject(t, db, "p1") - const wsKey = "dGhlIHNhbXBsZSBub25jZQ==" - // Pass directory as query param — browsers cannot set custom headers on - // WebSocket connections; the proxy converts ?directory= to the header. - fmt.Fprintf(conn, - "GET /api/opencode/pty/%s/connect?directory=%s HTTP/1.1\r\nHost: localhost\r\nCookie: appx_session=%s\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: %s\r\n\r\n", - ptyData.ID, "%2Ftmp", token, wsKey) + req := authedRequest(t, store, "GET", "/api/pi/v1/projects/proj-p1/sessions/s1/events", "") + rec := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} + handler.ServeHTTP(rec, req) - br := bufio.NewReader(conn) - statusLine, err := br.ReadString('\n') - if err != nil { - t.Fatalf("read status: %v", err) - } - if !strings.Contains(statusLine, "101") { - var headers strings.Builder - for { - line, _ := br.ReadString('\n') - headers.WriteString(line) - if strings.TrimSpace(line) == "" { - break - } - } - // Read body (up to 2KB for diagnostics). - body := make([]byte, 2048) - n, _ := br.Read(body) - t.Errorf("expected 101 Switching Protocols, got: %q\nHeaders:\n%s\nBody: %s", - statusLine, headers.String(), body[:n]) + if !rec.writeDeadlineCleared { + t.Error("expected write deadline cleared for SSE mirror requests") } } -func TestChangePassword_InvalidatesOtherSessions(t *testing.T) { - handler, store, _ := setupTest(t) +// TestAgentMirror_AllowsBodylessPost reproduces the agent-chat SDK's +// createSession/abort calls: a POST with no body and no Content-Type. These +// must be forwarded, not rejected with 415 by requireJSON (regression: the +// mirror is mounted outside the requireJSON-wrapped api mux for this reason). +func TestAgentMirror_AllowsBodylessPost(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path, "method": r.Method}) + })) + defer backend.Close() - // Create a session that should be invalidated. - oldToken, err := store.CreateSession() + handler, store, db := setupTestWithAgentServerBackend(t, backend.URL, "") + insertProject(t, db, "p1") // name == "proj-p1" + + // Authenticated, but deliberately NO Content-Type header and NO body. + token, err := store.CreateSession() if err != nil { t.Fatal(err) } + req := httptest.NewRequest("POST", "/api/pi/v1/projects/proj-p1/sessions", nil) + req.AddCookie(&http.Cookie{Name: "appx_session", Value: token}) - body := `{"currentPassword":"testpassword1","newPassword":"newpassword12345"}` - req := authedRequest(t, store, "PUT", "/api/settings/password", body) w := httptest.NewRecorder() handler.ServeHTTP(w, req) + if w.Code == http.StatusUnsupportedMediaType { + t.Fatal("bodyless POST was rejected with 415; mirror must bypass requireJSON") + } if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["path"] != "/v1/projects/proj-p1/sessions" || resp["method"] != "POST" { + t.Errorf("unexpected forwarded request: %v", resp) + } +} - // The old session should be invalid. - if store.ValidSession(oldToken) { - t.Error("old session should have been invalidated after password change") +// TestAgentMirror_BodylessPostRequiresAuth confirms dropping requireJSON did not +// drop authentication: an unauthenticated bodyless POST is still rejected. +func TestAgentMirror_BodylessPostRequiresAuth(t *testing.T) { + handler, _, db := setupTestWithAgentServerBackend(t, "http://127.0.0.1:4001", "") + insertProject(t, db, "p1") + + req := httptest.NewRequest("POST", "/api/pi/v1/projects/proj-p1/sessions", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for unauthenticated request, got %d", w.Code) } } diff --git a/internal/server/server.go b/internal/server/server.go index a463b6a..cd53cb4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,7 +19,6 @@ import ( "github.com/libdns/cloudflare" "github.com/neuromaxer/appx/internal/auth" "github.com/neuromaxer/appx/internal/egress" - "github.com/neuromaxer/appx/internal/opencode" "github.com/neuromaxer/appx/internal/project" "github.com/neuromaxer/appx/internal/terminal" appxtls "github.com/neuromaxer/appx/internal/tls" @@ -28,22 +27,23 @@ import ( // Config holds all dependencies needed to start the HTTPS server. // It is constructed in main() and passed to Run(). type Config struct { - Port int - InternalsDir string // path to .appx-internals (DB, TLS certs) - DB *sql.DB - AuthStore *auth.Store - ProjectManager *project.Manager - WebFS fs.FS - TLSHosts []string - Domain string - CloudflareToken string - HTTPMode bool // true = plain HTTP, locked to localhost - BaseDomain string // "localhost" in HTTP mode, Domain value in production - HostAliases []string // additional hosts that serve the dashboard (e.g. server IP or hostname) - OpenCodeClient *opencode.Client - EgressStore *egress.Store - EgressPending *egress.PendingRegistry - LocalManager *terminal.LocalManager + Port int + InternalsDir string // path to .appx-internals (DB, TLS certs) + DB *sql.DB + AuthStore *auth.Store + ProjectManager *project.Manager + WebFS fs.FS + TLSHosts []string + Domain string + CloudflareToken string + HTTPMode bool // true = plain HTTP, locked to localhost + BaseDomain string // "localhost" in HTTP mode, Domain value in production + HostAliases []string // additional hosts that serve the dashboard (e.g. server IP or hostname) + AgentServerURL string + AgentServerToken string + EgressStore *egress.Store + EgressPending *egress.PendingRegistry + LocalManager *terminal.LocalManager } // Run starts the HTTPS server and blocks until it receives SIGINT/SIGTERM or @@ -82,10 +82,12 @@ func Run(cfg Config) error { }() handler := NewRouter(a, cfg.ProjectManager, cfg.WebFS, RouterConfig{ - HTTPMode: cfg.HTTPMode, - BaseDomain: cfg.BaseDomain, - HostAliases: cfg.HostAliases, - }, cfg.OpenCodeClient, cfg.EgressStore, cfg.EgressPending, cfg.LocalManager) + HTTPMode: cfg.HTTPMode, + BaseDomain: cfg.BaseDomain, + HostAliases: cfg.HostAliases, + AgentServerURL: cfg.AgentServerURL, + AgentServerToken: cfg.AgentServerToken, + }, cfg.EgressStore, cfg.EgressPending, cfg.LocalManager) if cfg.HTTPMode { return runHTTP(cfg, handler) @@ -178,8 +180,6 @@ func runHTTP(cfg Config, handler http.Handler) error { IdleTimeout: 90 * time.Second, } - - return serveHTTP(srv, cfg.Port) } diff --git a/internal/server/settings_handlers.go b/internal/server/settings_handlers.go index a1d883c..a9421ce 100644 --- a/internal/server/settings_handlers.go +++ b/internal/server/settings_handlers.go @@ -7,23 +7,8 @@ import ( "strconv" "github.com/neuromaxer/appx/internal/auth" - "github.com/neuromaxer/appx/internal/opencode" ) -// handleOpenCodeHealth returns the handler for GET /api/opencode/health. It -// calls the OpenCode health endpoint and returns {"healthy": true/false}. -// Used by the dashboard to show the OpenCode server status. Auth required. -func handleOpenCodeHealth(oc *opencode.Client) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if oc == nil { - writeJSON(w, map[string]bool{"healthy": false}) - return - } - healthy := oc.HealthCheck() == nil - writeJSON(w, map[string]bool{"healthy": healthy}) - } -} - // handleChangePassword returns the handler for PUT /api/settings/password. It // requires the current password for re-authentication, sets the new password, // and invalidates all existing sessions (forcing re-login on all devices). @@ -65,73 +50,6 @@ func handleChangePassword(a *auth.Auth) http.HandlerFunc { } } -// handleGetAPIKeyStatus returns the handler for GET /api/settings/api-key. It -// responds with {"set": true} if an Anthropic API key is stored in the settings -// table, or {"set": false} otherwise. The actual key value is never exposed via -// this endpoint. This route is behind auth middleware. -func handleGetAPIKeyStatus(store *auth.Store) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - val, _ := store.GetSetting("anthropic_api_key") - writeJSON(w, map[string]bool{"set": val != ""}) - } -} - -// handleSetAPIKey returns the handler for PUT /api/settings/api-key. It stores -// the Anthropic API key in the settings table and, if an OpenCode client is -// available, injects the key into the running OpenCode server via SetAuth. -// Returns 400 if the key is empty. This route is behind auth middleware. -// -// Security note: the key is stored in plaintext in the SQLite database. This is -// acceptable for a self-hosted single-user deployment where database access -// implies full system access. Future work may add at-rest encryption. -func handleSetAPIKey(store *auth.Store, oc *opencode.Client) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var req struct { - Key string `json:"key"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid JSON", http.StatusBadRequest) - return - } - if req.Key == "" { - http.Error(w, "key is required", http.StatusBadRequest) - return - } - - if err := store.SetSetting("anthropic_api_key", req.Key); err != nil { - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - - if oc != nil { - if err := oc.SetAuth("anthropic", req.Key); err != nil { - log.Printf("settings: failed to inject key into OpenCode: %v", err) - } else if err := oc.DisposeAll(); err != nil { - log.Printf("settings: failed to reload OpenCode instances: %v", err) - } - } - - log.Printf("settings: API key updated") - writeJSON(w, map[string]string{"status": "ok"}) - } -} - -// handleDeleteAPIKey returns the handler for DELETE /api/settings/api-key. It -// removes the Anthropic API key from the settings table. Returns 200 on success -// (idempotent). The oc parameter is accepted for interface consistency with -// handleSetAPIKey but is not used on delete. This route is behind auth middleware. -func handleDeleteAPIKey(store *auth.Store, oc *opencode.Client) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if err := store.DeleteSetting("anthropic_api_key"); err != nil { - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - - log.Printf("settings: API key deleted") - writeJSON(w, map[string]string{"status": "ok"}) - } -} - // handleGetTerminalBufferSize returns the handler for GET /api/settings/terminal-buffer-size. // It responds with {"value": N} where N is the buffer size in KB. Defaults to // 512 if not set. This route is behind auth middleware. @@ -149,12 +67,12 @@ func handleGetTerminalBufferSize(store *auth.Store) http.HandlerFunc { } // handleGetConfig returns the handler for GET /api/config. It exposes server -// runtime configuration that the frontend needs at startup — currently the -// baseDomain so the SPA can construct correct subdomain URLs regardless of -// deployment mode. Auth required. +// runtime configuration that the frontend needs at startup. Auth required. func handleGetConfig(baseDomain string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]string{"baseDomain": baseDomain}) + writeJSON(w, map[string]string{ + "baseDomain": baseDomain, + }) } } diff --git a/internal/server/shell_handlers.go b/internal/server/shell_handlers.go index f44bd05..d4efd69 100644 --- a/internal/server/shell_handlers.go +++ b/internal/server/shell_handlers.go @@ -82,7 +82,7 @@ func handleShellResize(lm *terminal.LocalManager) http.HandlerFunc { // handleShellConnect handles GET /api/shell/{id}/connect. It upgrades to a // WebSocket and proxies raw terminal I/O between the browser and the PTY. // -// Protocol (matches OpenCode's PTY WebSocket so Terminal.tsx can be reused): +// Protocol: // - Text frames from client → stdin of the shell // - Binary frames from server → stdout/stderr of the shell // diff --git a/internal/terminal/local.go b/internal/terminal/local.go index 2079d21..0979a71 100644 --- a/internal/terminal/local.go +++ b/internal/terminal/local.go @@ -1,8 +1,8 @@ // Package terminal provides the local PTY manager used by the shell endpoint. // This file implements LocalManager, which spawns real OS-level PTY processes -// using creack/pty — the same primitive used by OpenCode's bun-pty. It shares -// the ring buffer and pub/sub fan-out patterns from the existing Manager but -// replaces the Docker exec backend with a direct os/exec + PTY attach. +// using creack/pty. It shares the ring buffer and pub/sub fan-out patterns from +// the existing Manager but replaces the Docker exec backend with a direct +// os/exec + PTY attach. package terminal import ( @@ -28,12 +28,12 @@ type LocalSession struct { // CreatedAt is when the session was started. CreatedAt time.Time - ptmx *os.File // PTY master fd — read=output, write=input - cmd *exec.Cmd // underlying shell process - buf *RingBuffer // ring buffer for output replay on reconnect - mu sync.Mutex // guards subs + ptmx *os.File // PTY master fd — read=output, write=input + cmd *exec.Cmd // underlying shell process + buf *RingBuffer // ring buffer for output replay on reconnect + mu sync.Mutex // guards subs subs map[chan []byte]struct{} // active WebSocket subscribers - done chan struct{} // closed when the session ends + done chan struct{} // closed when the session ends closeOnce sync.Once } diff --git a/web/opencode-api.json b/web/opencode-api.json deleted file mode 100644 index 999432a..0000000 --- a/web/opencode-api.json +++ /dev/null @@ -1 +0,0 @@ -{"openapi":"3.1.1","info":{"title":"opencode","description":"opencode api","version":"0.0.3"},"paths":{"/global/health":{"get":{"operationId":"global.health","summary":"Get health","description":"Get health information about the OpenCode server.","responses":{"200":{"description":"Health information","content":{"application/json":{"schema":{"type":"object","properties":{"healthy":{"type":"boolean","const":true},"version":{"type":"string"}},"required":["healthy","version"]}}}}}}},"/global/event":{"get":{"operationId":"global.event","summary":"Get global events","description":"Subscribe to global events from the OpenCode system using server-sent events.","responses":{"200":{"description":"Event stream","content":{"text/event-stream":{"schema":{"$ref":"#/components/schemas/GlobalEvent"}}}}}}},"/global/sync-event":{"get":{"operationId":"global.sync-event.subscribe","summary":"Subscribe to global sync events","description":"Get global sync events","responses":{"200":{"description":"Event stream","content":{"text/event-stream":{"schema":{"$ref":"#/components/schemas/SyncEvent"}}}}}}},"/global/config":{"get":{"operationId":"global.config.get","summary":"Get global configuration","description":"Retrieve the current global OpenCode configuration settings and preferences.","responses":{"200":{"description":"Get global config info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Config"}}}}}},"patch":{"operationId":"global.config.update","summary":"Update global configuration","description":"Update global OpenCode configuration settings and preferences.","responses":{"200":{"description":"Successfully updated global config","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Config"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"}}}}},"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Config"}}}}}},"/global/dispose":{"post":{"operationId":"global.dispose","summary":"Dispose instance","description":"Clean up and dispose all OpenCode instances, releasing all resources.","responses":{"200":{"description":"Global disposed","content":{"application/json":{"schema":{"type":"boolean"}}}}}}},"/global/upgrade":{"post":{"operationId":"global.upgrade","summary":"Upgrade opencode","description":"Upgrade opencode to the specified version or latest if not specified.","responses":{"200":{"description":"Upgrade result","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"success":{"type":"boolean","const":true},"version":{"type":"string"}},"required":["success","version"]},{"type":"object","properties":{"success":{"type":"boolean","const":false},"error":{"type":"string"}},"required":["success","error"]}]}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"target":{"type":"string"}}}}}}}},"/auth/{providerID}":{"put":{"operationId":"auth.set","summary":"Set auth credentials","description":"Set authentication credentials","responses":{"200":{"description":"Successfully set authentication credentials","content":{"application/json":{"schema":{"type":"boolean"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"}}}}},"parameters":[{"in":"path","name":"providerID","schema":{"type":"string"},"required":true}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Auth"}}}}},"delete":{"operationId":"auth.remove","summary":"Remove auth credentials","description":"Remove authentication credentials","responses":{"200":{"description":"Successfully removed authentication credentials","content":{"application/json":{"schema":{"type":"boolean"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"}}}}},"parameters":[{"in":"path","name":"providerID","schema":{"type":"string"},"required":true}]}},"/log":{"post":{"operationId":"app.log","parameters":[{"in":"query","name":"directory","schema":{"type":"string"}},{"in":"query","name":"workspace","schema":{"type":"string"}}],"summary":"Write log","description":"Write a log entry to the server logs with specified level and metadata.","responses":{"200":{"description":"Log entry written successfully","content":{"application/json":{"schema":{"type":"boolean"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BadRequestError"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"service":{"description":"Service name for the log entry","type":"string"},"level":{"description":"Log level","type":"string","enum":["debug","info","error","warn"]},"message":{"description":"Log message","type":"string"},"extra":{"description":"Additional metadata for the log entry","type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"required":["service","level","message"]}}}}}}},"components":{"schemas":{"Event.server.connected":{"type":"object","properties":{"type":{"type":"string","const":"server.connected"},"properties":{"type":"object","properties":{}}},"required":["type","properties"]},"Event.global.disposed":{"type":"object","properties":{"type":{"type":"string","const":"global.disposed"},"properties":{"type":"object","properties":{}}},"required":["type","properties"]},"Event.tui.prompt.append":{"type":"object","properties":{"type":{"type":"string","const":"tui.prompt.append"},"properties":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}},"required":["type","properties"]},"Event.tui.command.execute":{"type":"object","properties":{"type":{"type":"string","const":"tui.command.execute"},"properties":{"type":"object","properties":{"command":{"anyOf":[{"type":"string","enum":["session.list","session.new","session.share","session.interrupt","session.compact","session.page.up","session.page.down","session.line.up","session.line.down","session.half.page.up","session.half.page.down","session.first","session.last","prompt.clear","prompt.submit","agent.cycle"]},{"type":"string"}]}},"required":["command"]}},"required":["type","properties"]},"Event.tui.toast.show":{"type":"object","properties":{"type":{"type":"string","const":"tui.toast.show"},"properties":{"type":"object","properties":{"title":{"type":"string"},"message":{"type":"string"},"variant":{"type":"string","enum":["info","success","warning","error"]},"duration":{"description":"Duration in milliseconds","default":5000,"type":"number"}},"required":["message","variant"]}},"required":["type","properties"]},"Event.tui.session.select":{"type":"object","properties":{"type":{"type":"string","const":"tui.session.select"},"properties":{"type":"object","properties":{"sessionID":{"description":"Session ID to navigate to","type":"string","pattern":"^ses.*"}},"required":["sessionID"]}},"required":["type","properties"]},"Project":{"type":"object","properties":{"id":{"type":"string"},"worktree":{"type":"string"},"vcs":{"type":"string","const":"git"},"name":{"type":"string"},"icon":{"type":"object","properties":{"url":{"type":"string"},"override":{"type":"string"},"color":{"type":"string"}}},"commands":{"type":"object","properties":{"start":{"description":"Startup script to run when creating a new workspace (worktree)","type":"string"}}},"time":{"type":"object","properties":{"created":{"type":"number"},"updated":{"type":"number"},"initialized":{"type":"number"}},"required":["created","updated"]},"sandboxes":{"type":"array","items":{"type":"string"}}},"required":["id","worktree","time","sandboxes"]},"Event.project.updated":{"type":"object","properties":{"type":{"type":"string","const":"project.updated"},"properties":{"$ref":"#/components/schemas/Project"}},"required":["type","properties"]},"Event.installation.updated":{"type":"object","properties":{"type":{"type":"string","const":"installation.updated"},"properties":{"type":"object","properties":{"version":{"type":"string"}},"required":["version"]}},"required":["type","properties"]},"Event.installation.update-available":{"type":"object","properties":{"type":{"type":"string","const":"installation.update-available"},"properties":{"type":"object","properties":{"version":{"type":"string"}},"required":["version"]}},"required":["type","properties"]},"Event.server.instance.disposed":{"type":"object","properties":{"type":{"type":"string","const":"server.instance.disposed"},"properties":{"type":"object","properties":{"directory":{"type":"string"}},"required":["directory"]}},"required":["type","properties"]},"Event.file.edited":{"type":"object","properties":{"type":{"type":"string","const":"file.edited"},"properties":{"type":"object","properties":{"file":{"type":"string"}},"required":["file"]}},"required":["type","properties"]},"Event.lsp.client.diagnostics":{"type":"object","properties":{"type":{"type":"string","const":"lsp.client.diagnostics"},"properties":{"type":"object","properties":{"serverID":{"type":"string"},"path":{"type":"string"}},"required":["serverID","path"]}},"required":["type","properties"]},"PermissionRequest":{"type":"object","properties":{"id":{"type":"string","pattern":"^per.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"permission":{"type":"string"},"patterns":{"type":"array","items":{"type":"string"}},"metadata":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"always":{"type":"array","items":{"type":"string"}},"tool":{"type":"object","properties":{"messageID":{"type":"string","pattern":"^msg.*"},"callID":{"type":"string"}},"required":["messageID","callID"]}},"required":["id","sessionID","permission","patterns","metadata","always"]},"Event.permission.asked":{"type":"object","properties":{"type":{"type":"string","const":"permission.asked"},"properties":{"$ref":"#/components/schemas/PermissionRequest"}},"required":["type","properties"]},"Event.permission.replied":{"type":"object","properties":{"type":{"type":"string","const":"permission.replied"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"requestID":{"type":"string","pattern":"^per.*"},"reply":{"type":"string","enum":["once","always","reject"]}},"required":["sessionID","requestID","reply"]}},"required":["type","properties"]},"SessionStatus":{"anyOf":[{"type":"object","properties":{"type":{"type":"string","const":"idle"}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","const":"retry"},"attempt":{"type":"number"},"message":{"type":"string"},"next":{"type":"number"}},"required":["type","attempt","message","next"]},{"type":"object","properties":{"type":{"type":"string","const":"busy"}},"required":["type"]}]},"Event.session.status":{"type":"object","properties":{"type":{"type":"string","const":"session.status"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"status":{"$ref":"#/components/schemas/SessionStatus"}},"required":["sessionID","status"]}},"required":["type","properties"]},"Event.session.idle":{"type":"object","properties":{"type":{"type":"string","const":"session.idle"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"}},"required":["sessionID"]}},"required":["type","properties"]},"QuestionOption":{"type":"object","properties":{"label":{"description":"Display text (1-5 words, concise)","type":"string"},"description":{"description":"Explanation of choice","type":"string"}},"required":["label","description"]},"QuestionInfo":{"type":"object","properties":{"question":{"description":"Complete question","type":"string"},"header":{"description":"Very short label (max 30 chars)","type":"string"},"options":{"description":"Available choices","type":"array","items":{"$ref":"#/components/schemas/QuestionOption"}},"multiple":{"description":"Allow selecting multiple choices","type":"boolean"},"custom":{"description":"Allow typing a custom answer (default: true)","type":"boolean"}},"required":["question","header","options"]},"QuestionRequest":{"type":"object","properties":{"id":{"type":"string","pattern":"^que.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"questions":{"description":"Questions to ask","type":"array","items":{"$ref":"#/components/schemas/QuestionInfo"}},"tool":{"type":"object","properties":{"messageID":{"type":"string","pattern":"^msg.*"},"callID":{"type":"string"}},"required":["messageID","callID"]}},"required":["id","sessionID","questions"]},"Event.question.asked":{"type":"object","properties":{"type":{"type":"string","const":"question.asked"},"properties":{"$ref":"#/components/schemas/QuestionRequest"}},"required":["type","properties"]},"QuestionAnswer":{"type":"array","items":{"type":"string"}},"Event.question.replied":{"type":"object","properties":{"type":{"type":"string","const":"question.replied"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"requestID":{"type":"string","pattern":"^que.*"},"answers":{"type":"array","items":{"$ref":"#/components/schemas/QuestionAnswer"}}},"required":["sessionID","requestID","answers"]}},"required":["type","properties"]},"Event.question.rejected":{"type":"object","properties":{"type":{"type":"string","const":"question.rejected"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"requestID":{"type":"string","pattern":"^que.*"}},"required":["sessionID","requestID"]}},"required":["type","properties"]},"Todo":{"type":"object","properties":{"content":{"description":"Brief description of the task","type":"string"},"status":{"description":"Current status of the task: pending, in_progress, completed, cancelled","type":"string"},"priority":{"description":"Priority level of the task: high, medium, low","type":"string"}},"required":["content","status","priority"]},"Event.todo.updated":{"type":"object","properties":{"type":{"type":"string","const":"todo.updated"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"todos":{"type":"array","items":{"$ref":"#/components/schemas/Todo"}}},"required":["sessionID","todos"]}},"required":["type","properties"]},"Event.lsp.updated":{"type":"object","properties":{"type":{"type":"string","const":"lsp.updated"},"properties":{"type":"object","properties":{}}},"required":["type","properties"]},"Event.file.watcher.updated":{"type":"object","properties":{"type":{"type":"string","const":"file.watcher.updated"},"properties":{"type":"object","properties":{"file":{"type":"string"},"event":{"anyOf":[{"type":"string","const":"add"},{"type":"string","const":"change"},{"type":"string","const":"unlink"}]}},"required":["file","event"]}},"required":["type","properties"]},"Event.mcp.tools.changed":{"type":"object","properties":{"type":{"type":"string","const":"mcp.tools.changed"},"properties":{"type":"object","properties":{"server":{"type":"string"}},"required":["server"]}},"required":["type","properties"]},"Event.mcp.browser.open.failed":{"type":"object","properties":{"type":{"type":"string","const":"mcp.browser.open.failed"},"properties":{"type":"object","properties":{"mcpName":{"type":"string"},"url":{"type":"string"}},"required":["mcpName","url"]}},"required":["type","properties"]},"Event.message.part.delta":{"type":"object","properties":{"type":{"type":"string","const":"message.part.delta"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"partID":{"type":"string","pattern":"^prt.*"},"field":{"type":"string"},"delta":{"type":"string"}},"required":["sessionID","messageID","partID","field","delta"]}},"required":["type","properties"]},"Event.vcs.branch.updated":{"type":"object","properties":{"type":{"type":"string","const":"vcs.branch.updated"},"properties":{"type":"object","properties":{"branch":{"type":"string"}}}},"required":["type","properties"]},"Event.command.executed":{"type":"object","properties":{"type":{"type":"string","const":"command.executed"},"properties":{"type":"object","properties":{"name":{"type":"string"},"sessionID":{"type":"string","pattern":"^ses.*"},"arguments":{"type":"string"},"messageID":{"type":"string","pattern":"^msg.*"}},"required":["name","sessionID","arguments","messageID"]}},"required":["type","properties"]},"Event.session.compacted":{"type":"object","properties":{"type":{"type":"string","const":"session.compacted"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"}},"required":["sessionID"]}},"required":["type","properties"]},"FileDiff":{"type":"object","properties":{"file":{"type":"string"},"before":{"type":"string"},"after":{"type":"string"},"additions":{"type":"number"},"deletions":{"type":"number"},"status":{"type":"string","enum":["added","deleted","modified"]}},"required":["file","before","after","additions","deletions"]},"Event.session.diff":{"type":"object","properties":{"type":{"type":"string","const":"session.diff"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"diff":{"type":"array","items":{"$ref":"#/components/schemas/FileDiff"}}},"required":["sessionID","diff"]}},"required":["type","properties"]},"ProviderAuthError":{"type":"object","properties":{"name":{"type":"string","const":"ProviderAuthError"},"data":{"type":"object","properties":{"providerID":{"type":"string"},"message":{"type":"string"}},"required":["providerID","message"]}},"required":["name","data"]},"UnknownError":{"type":"object","properties":{"name":{"type":"string","const":"UnknownError"},"data":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}},"required":["name","data"]},"MessageOutputLengthError":{"type":"object","properties":{"name":{"type":"string","const":"MessageOutputLengthError"},"data":{"type":"object","properties":{}}},"required":["name","data"]},"MessageAbortedError":{"type":"object","properties":{"name":{"type":"string","const":"MessageAbortedError"},"data":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}},"required":["name","data"]},"StructuredOutputError":{"type":"object","properties":{"name":{"type":"string","const":"StructuredOutputError"},"data":{"type":"object","properties":{"message":{"type":"string"},"retries":{"type":"number"}},"required":["message","retries"]}},"required":["name","data"]},"ContextOverflowError":{"type":"object","properties":{"name":{"type":"string","const":"ContextOverflowError"},"data":{"type":"object","properties":{"message":{"type":"string"},"responseBody":{"type":"string"}},"required":["message"]}},"required":["name","data"]},"APIError":{"type":"object","properties":{"name":{"type":"string","const":"APIError"},"data":{"type":"object","properties":{"message":{"type":"string"},"statusCode":{"type":"number"},"isRetryable":{"type":"boolean"},"responseHeaders":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"responseBody":{"type":"string"},"metadata":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}}},"required":["message","isRetryable"]}},"required":["name","data"]},"Event.session.error":{"type":"object","properties":{"type":{"type":"string","const":"session.error"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"error":{"anyOf":[{"$ref":"#/components/schemas/ProviderAuthError"},{"$ref":"#/components/schemas/UnknownError"},{"$ref":"#/components/schemas/MessageOutputLengthError"},{"$ref":"#/components/schemas/MessageAbortedError"},{"$ref":"#/components/schemas/StructuredOutputError"},{"$ref":"#/components/schemas/ContextOverflowError"},{"$ref":"#/components/schemas/APIError"}]}}}},"required":["type","properties"]},"Event.workspace.ready":{"type":"object","properties":{"type":{"type":"string","const":"workspace.ready"},"properties":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}},"required":["type","properties"]},"Event.workspace.failed":{"type":"object","properties":{"type":{"type":"string","const":"workspace.failed"},"properties":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}},"required":["type","properties"]},"Pty":{"type":"object","properties":{"id":{"type":"string","pattern":"^pty.*"},"title":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"status":{"type":"string","enum":["running","exited"]},"pid":{"type":"number"}},"required":["id","title","command","args","cwd","status","pid"]},"Event.pty.created":{"type":"object","properties":{"type":{"type":"string","const":"pty.created"},"properties":{"type":"object","properties":{"info":{"$ref":"#/components/schemas/Pty"}},"required":["info"]}},"required":["type","properties"]},"Event.pty.updated":{"type":"object","properties":{"type":{"type":"string","const":"pty.updated"},"properties":{"type":"object","properties":{"info":{"$ref":"#/components/schemas/Pty"}},"required":["info"]}},"required":["type","properties"]},"Event.pty.exited":{"type":"object","properties":{"type":{"type":"string","const":"pty.exited"},"properties":{"type":"object","properties":{"id":{"type":"string","pattern":"^pty.*"},"exitCode":{"type":"number"}},"required":["id","exitCode"]}},"required":["type","properties"]},"Event.pty.deleted":{"type":"object","properties":{"type":{"type":"string","const":"pty.deleted"},"properties":{"type":"object","properties":{"id":{"type":"string","pattern":"^pty.*"}},"required":["id"]}},"required":["type","properties"]},"Event.worktree.ready":{"type":"object","properties":{"type":{"type":"string","const":"worktree.ready"},"properties":{"type":"object","properties":{"name":{"type":"string"},"branch":{"type":"string"}},"required":["name","branch"]}},"required":["type","properties"]},"Event.worktree.failed":{"type":"object","properties":{"type":{"type":"string","const":"worktree.failed"},"properties":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}},"required":["type","properties"]},"OutputFormatText":{"type":"object","properties":{"type":{"type":"string","const":"text"}},"required":["type"]},"JSONSchema":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"OutputFormatJsonSchema":{"type":"object","properties":{"type":{"type":"string","const":"json_schema"},"schema":{"$ref":"#/components/schemas/JSONSchema"},"retryCount":{"default":2,"type":"integer","minimum":0,"maximum":9007199254740991}},"required":["type","schema"]},"OutputFormat":{"anyOf":[{"$ref":"#/components/schemas/OutputFormatText"},{"$ref":"#/components/schemas/OutputFormatJsonSchema"}]},"UserMessage":{"type":"object","properties":{"id":{"type":"string","pattern":"^msg.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"role":{"type":"string","const":"user"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"]},"format":{"$ref":"#/components/schemas/OutputFormat"},"summary":{"type":"object","properties":{"title":{"type":"string"},"body":{"type":"string"},"diffs":{"type":"array","items":{"$ref":"#/components/schemas/FileDiff"}}},"required":["diffs"]},"agent":{"type":"string"},"model":{"type":"object","properties":{"providerID":{"type":"string"},"modelID":{"type":"string"}},"required":["providerID","modelID"]},"system":{"type":"string"},"tools":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"boolean"}},"variant":{"type":"string"}},"required":["id","sessionID","role","time","agent","model"]},"AssistantMessage":{"type":"object","properties":{"id":{"type":"string","pattern":"^msg.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"role":{"type":"string","const":"assistant"},"time":{"type":"object","properties":{"created":{"type":"number"},"completed":{"type":"number"}},"required":["created"]},"error":{"anyOf":[{"$ref":"#/components/schemas/ProviderAuthError"},{"$ref":"#/components/schemas/UnknownError"},{"$ref":"#/components/schemas/MessageOutputLengthError"},{"$ref":"#/components/schemas/MessageAbortedError"},{"$ref":"#/components/schemas/StructuredOutputError"},{"$ref":"#/components/schemas/ContextOverflowError"},{"$ref":"#/components/schemas/APIError"}]},"parentID":{"type":"string","pattern":"^msg.*"},"modelID":{"type":"string"},"providerID":{"type":"string"},"mode":{"type":"string"},"agent":{"type":"string"},"path":{"type":"object","properties":{"cwd":{"type":"string"},"root":{"type":"string"}},"required":["cwd","root"]},"summary":{"type":"boolean"},"cost":{"type":"number"},"tokens":{"type":"object","properties":{"total":{"type":"number"},"input":{"type":"number"},"output":{"type":"number"},"reasoning":{"type":"number"},"cache":{"type":"object","properties":{"read":{"type":"number"},"write":{"type":"number"}},"required":["read","write"]}},"required":["input","output","reasoning","cache"]},"structured":{},"variant":{"type":"string"},"finish":{"type":"string"}},"required":["id","sessionID","role","time","parentID","modelID","providerID","mode","agent","path","cost","tokens"]},"Message":{"anyOf":[{"$ref":"#/components/schemas/UserMessage"},{"$ref":"#/components/schemas/AssistantMessage"}]},"Event.message.updated":{"type":"object","properties":{"type":{"type":"string","const":"message.updated"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"info":{"$ref":"#/components/schemas/Message"}},"required":["sessionID","info"]}},"required":["type","properties"]},"Event.message.removed":{"type":"object","properties":{"type":{"type":"string","const":"message.removed"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"}},"required":["sessionID","messageID"]}},"required":["type","properties"]},"TextPart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"text"},"text":{"type":"string"},"synthetic":{"type":"boolean"},"ignored":{"type":"boolean"},"time":{"type":"object","properties":{"start":{"type":"number"},"end":{"type":"number"}},"required":["start"]},"metadata":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"required":["id","sessionID","messageID","type","text"]},"SubtaskPart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"subtask"},"prompt":{"type":"string"},"description":{"type":"string"},"agent":{"type":"string"},"model":{"type":"object","properties":{"providerID":{"type":"string"},"modelID":{"type":"string"}},"required":["providerID","modelID"]},"command":{"type":"string"}},"required":["id","sessionID","messageID","type","prompt","description","agent"]},"ReasoningPart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"reasoning"},"text":{"type":"string"},"metadata":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"time":{"type":"object","properties":{"start":{"type":"number"},"end":{"type":"number"}},"required":["start"]}},"required":["id","sessionID","messageID","type","text","time"]},"FilePartSourceText":{"type":"object","properties":{"value":{"type":"string"},"start":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"end":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["value","start","end"]},"FileSource":{"type":"object","properties":{"text":{"$ref":"#/components/schemas/FilePartSourceText"},"type":{"type":"string","const":"file"},"path":{"type":"string"}},"required":["text","type","path"]},"Range":{"type":"object","properties":{"start":{"type":"object","properties":{"line":{"type":"number"},"character":{"type":"number"}},"required":["line","character"]},"end":{"type":"object","properties":{"line":{"type":"number"},"character":{"type":"number"}},"required":["line","character"]}},"required":["start","end"]},"SymbolSource":{"type":"object","properties":{"text":{"$ref":"#/components/schemas/FilePartSourceText"},"type":{"type":"string","const":"symbol"},"path":{"type":"string"},"range":{"$ref":"#/components/schemas/Range"},"name":{"type":"string"},"kind":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["text","type","path","range","name","kind"]},"ResourceSource":{"type":"object","properties":{"text":{"$ref":"#/components/schemas/FilePartSourceText"},"type":{"type":"string","const":"resource"},"clientName":{"type":"string"},"uri":{"type":"string"}},"required":["text","type","clientName","uri"]},"FilePartSource":{"anyOf":[{"$ref":"#/components/schemas/FileSource"},{"$ref":"#/components/schemas/SymbolSource"},{"$ref":"#/components/schemas/ResourceSource"}]},"FilePart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"file"},"mime":{"type":"string"},"filename":{"type":"string"},"url":{"type":"string"},"source":{"$ref":"#/components/schemas/FilePartSource"}},"required":["id","sessionID","messageID","type","mime","url"]},"ToolStatePending":{"type":"object","properties":{"status":{"type":"string","const":"pending"},"input":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"raw":{"type":"string"}},"required":["status","input","raw"]},"ToolStateRunning":{"type":"object","properties":{"status":{"type":"string","const":"running"},"input":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"title":{"type":"string"},"metadata":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"time":{"type":"object","properties":{"start":{"type":"number"}},"required":["start"]}},"required":["status","input","time"]},"ToolStateCompleted":{"type":"object","properties":{"status":{"type":"string","const":"completed"},"input":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"output":{"type":"string"},"title":{"type":"string"},"metadata":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"time":{"type":"object","properties":{"start":{"type":"number"},"end":{"type":"number"},"compacted":{"type":"number"}},"required":["start","end"]},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/FilePart"}}},"required":["status","input","output","title","metadata","time"]},"ToolStateError":{"type":"object","properties":{"status":{"type":"string","const":"error"},"input":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"error":{"type":"string"},"metadata":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"time":{"type":"object","properties":{"start":{"type":"number"},"end":{"type":"number"}},"required":["start","end"]}},"required":["status","input","error","time"]},"ToolState":{"anyOf":[{"$ref":"#/components/schemas/ToolStatePending"},{"$ref":"#/components/schemas/ToolStateRunning"},{"$ref":"#/components/schemas/ToolStateCompleted"},{"$ref":"#/components/schemas/ToolStateError"}]},"ToolPart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"tool"},"callID":{"type":"string"},"tool":{"type":"string"},"state":{"$ref":"#/components/schemas/ToolState"},"metadata":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"required":["id","sessionID","messageID","type","callID","tool","state"]},"StepStartPart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"step-start"},"snapshot":{"type":"string"}},"required":["id","sessionID","messageID","type"]},"StepFinishPart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"step-finish"},"reason":{"type":"string"},"snapshot":{"type":"string"},"cost":{"type":"number"},"tokens":{"type":"object","properties":{"total":{"type":"number"},"input":{"type":"number"},"output":{"type":"number"},"reasoning":{"type":"number"},"cache":{"type":"object","properties":{"read":{"type":"number"},"write":{"type":"number"}},"required":["read","write"]}},"required":["input","output","reasoning","cache"]}},"required":["id","sessionID","messageID","type","reason","cost","tokens"]},"SnapshotPart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"snapshot"},"snapshot":{"type":"string"}},"required":["id","sessionID","messageID","type","snapshot"]},"PatchPart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"patch"},"hash":{"type":"string"},"files":{"type":"array","items":{"type":"string"}}},"required":["id","sessionID","messageID","type","hash","files"]},"AgentPart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"agent"},"name":{"type":"string"},"source":{"type":"object","properties":{"value":{"type":"string"},"start":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"end":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991}},"required":["value","start","end"]}},"required":["id","sessionID","messageID","type","name"]},"RetryPart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"retry"},"attempt":{"type":"number"},"error":{"$ref":"#/components/schemas/APIError"},"time":{"type":"object","properties":{"created":{"type":"number"}},"required":["created"]}},"required":["id","sessionID","messageID","type","attempt","error","time"]},"CompactionPart":{"type":"object","properties":{"id":{"type":"string","pattern":"^prt.*"},"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"type":{"type":"string","const":"compaction"},"auto":{"type":"boolean"},"overflow":{"type":"boolean"}},"required":["id","sessionID","messageID","type","auto"]},"Part":{"anyOf":[{"$ref":"#/components/schemas/TextPart"},{"$ref":"#/components/schemas/SubtaskPart"},{"$ref":"#/components/schemas/ReasoningPart"},{"$ref":"#/components/schemas/FilePart"},{"$ref":"#/components/schemas/ToolPart"},{"$ref":"#/components/schemas/StepStartPart"},{"$ref":"#/components/schemas/StepFinishPart"},{"$ref":"#/components/schemas/SnapshotPart"},{"$ref":"#/components/schemas/PatchPart"},{"$ref":"#/components/schemas/AgentPart"},{"$ref":"#/components/schemas/RetryPart"},{"$ref":"#/components/schemas/CompactionPart"}]},"Event.message.part.updated":{"type":"object","properties":{"type":{"type":"string","const":"message.part.updated"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"part":{"$ref":"#/components/schemas/Part"},"time":{"type":"number"}},"required":["sessionID","part","time"]}},"required":["type","properties"]},"Event.message.part.removed":{"type":"object","properties":{"type":{"type":"string","const":"message.part.removed"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"partID":{"type":"string","pattern":"^prt.*"}},"required":["sessionID","messageID","partID"]}},"required":["type","properties"]},"PermissionAction":{"type":"string","enum":["allow","deny","ask"]},"PermissionRule":{"type":"object","properties":{"permission":{"type":"string"},"pattern":{"type":"string"},"action":{"$ref":"#/components/schemas/PermissionAction"}},"required":["permission","pattern","action"]},"PermissionRuleset":{"type":"array","items":{"$ref":"#/components/schemas/PermissionRule"}},"Session":{"type":"object","properties":{"id":{"type":"string","pattern":"^ses.*"},"slug":{"type":"string"},"projectID":{"type":"string"},"workspaceID":{"type":"string","pattern":"^wrk.*"},"directory":{"type":"string"},"parentID":{"type":"string","pattern":"^ses.*"},"summary":{"type":"object","properties":{"additions":{"type":"number"},"deletions":{"type":"number"},"files":{"type":"number"},"diffs":{"type":"array","items":{"$ref":"#/components/schemas/FileDiff"}}},"required":["additions","deletions","files"]},"share":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]},"title":{"type":"string"},"version":{"type":"string"},"time":{"type":"object","properties":{"created":{"type":"number"},"updated":{"type":"number"},"compacting":{"type":"number"},"archived":{"type":"number"}},"required":["created","updated"]},"permission":{"$ref":"#/components/schemas/PermissionRuleset"},"revert":{"type":"object","properties":{"messageID":{"type":"string","pattern":"^msg.*"},"partID":{"type":"string","pattern":"^prt.*"},"snapshot":{"type":"string"},"diff":{"type":"string"}},"required":["messageID"]}},"required":["id","slug","projectID","directory","title","version","time"]},"Event.session.created":{"type":"object","properties":{"type":{"type":"string","const":"session.created"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"info":{"$ref":"#/components/schemas/Session"}},"required":["sessionID","info"]}},"required":["type","properties"]},"Event.session.updated":{"type":"object","properties":{"type":{"type":"string","const":"session.updated"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"info":{"$ref":"#/components/schemas/Session"}},"required":["sessionID","info"]}},"required":["type","properties"]},"Event.session.deleted":{"type":"object","properties":{"type":{"type":"string","const":"session.deleted"},"properties":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"info":{"$ref":"#/components/schemas/Session"}},"required":["sessionID","info"]}},"required":["type","properties"]},"Event":{"anyOf":[{"$ref":"#/components/schemas/Event.server.connected"},{"$ref":"#/components/schemas/Event.global.disposed"},{"$ref":"#/components/schemas/Event.tui.prompt.append"},{"$ref":"#/components/schemas/Event.tui.command.execute"},{"$ref":"#/components/schemas/Event.tui.toast.show"},{"$ref":"#/components/schemas/Event.tui.session.select"},{"$ref":"#/components/schemas/Event.project.updated"},{"$ref":"#/components/schemas/Event.installation.updated"},{"$ref":"#/components/schemas/Event.installation.update-available"},{"$ref":"#/components/schemas/Event.server.instance.disposed"},{"$ref":"#/components/schemas/Event.file.edited"},{"$ref":"#/components/schemas/Event.lsp.client.diagnostics"},{"$ref":"#/components/schemas/Event.permission.asked"},{"$ref":"#/components/schemas/Event.permission.replied"},{"$ref":"#/components/schemas/Event.session.status"},{"$ref":"#/components/schemas/Event.session.idle"},{"$ref":"#/components/schemas/Event.question.asked"},{"$ref":"#/components/schemas/Event.question.replied"},{"$ref":"#/components/schemas/Event.question.rejected"},{"$ref":"#/components/schemas/Event.todo.updated"},{"$ref":"#/components/schemas/Event.lsp.updated"},{"$ref":"#/components/schemas/Event.file.watcher.updated"},{"$ref":"#/components/schemas/Event.mcp.tools.changed"},{"$ref":"#/components/schemas/Event.mcp.browser.open.failed"},{"$ref":"#/components/schemas/Event.message.part.delta"},{"$ref":"#/components/schemas/Event.vcs.branch.updated"},{"$ref":"#/components/schemas/Event.command.executed"},{"$ref":"#/components/schemas/Event.session.compacted"},{"$ref":"#/components/schemas/Event.session.diff"},{"$ref":"#/components/schemas/Event.session.error"},{"$ref":"#/components/schemas/Event.workspace.ready"},{"$ref":"#/components/schemas/Event.workspace.failed"},{"$ref":"#/components/schemas/Event.pty.created"},{"$ref":"#/components/schemas/Event.pty.updated"},{"$ref":"#/components/schemas/Event.pty.exited"},{"$ref":"#/components/schemas/Event.pty.deleted"},{"$ref":"#/components/schemas/Event.worktree.ready"},{"$ref":"#/components/schemas/Event.worktree.failed"},{"$ref":"#/components/schemas/Event.message.updated"},{"$ref":"#/components/schemas/Event.message.removed"},{"$ref":"#/components/schemas/Event.message.part.updated"},{"$ref":"#/components/schemas/Event.message.part.removed"},{"$ref":"#/components/schemas/Event.session.created"},{"$ref":"#/components/schemas/Event.session.updated"},{"$ref":"#/components/schemas/Event.session.deleted"}]},"GlobalEvent":{"type":"object","properties":{"directory":{"type":"string"},"payload":{"$ref":"#/components/schemas/Event"}},"required":["directory","payload"]},"SyncEvent.message.updated":{"type":"object","properties":{"type":{"type":"string","const":"message.updated.1"},"aggregate":{"type":"string","const":"sessionID"},"data":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"info":{"$ref":"#/components/schemas/Message"}},"required":["sessionID","info"]}},"required":["type","aggregate","data"]},"SyncEvent.message.removed":{"type":"object","properties":{"type":{"type":"string","const":"message.removed.1"},"aggregate":{"type":"string","const":"sessionID"},"data":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"}},"required":["sessionID","messageID"]}},"required":["type","aggregate","data"]},"SyncEvent.message.part.updated":{"type":"object","properties":{"type":{"type":"string","const":"message.part.updated.1"},"aggregate":{"type":"string","const":"sessionID"},"data":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"part":{"$ref":"#/components/schemas/Part"},"time":{"type":"number"}},"required":["sessionID","part","time"]}},"required":["type","aggregate","data"]},"SyncEvent.message.part.removed":{"type":"object","properties":{"type":{"type":"string","const":"message.part.removed.1"},"aggregate":{"type":"string","const":"sessionID"},"data":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"messageID":{"type":"string","pattern":"^msg.*"},"partID":{"type":"string","pattern":"^prt.*"}},"required":["sessionID","messageID","partID"]}},"required":["type","aggregate","data"]},"SyncEvent.session.created":{"type":"object","properties":{"type":{"type":"string","const":"session.created.1"},"aggregate":{"type":"string","const":"sessionID"},"data":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"info":{"$ref":"#/components/schemas/Session"}},"required":["sessionID","info"]}},"required":["type","aggregate","data"]},"SyncEvent.session.updated":{"type":"object","properties":{"type":{"type":"string","const":"session.updated.1"},"aggregate":{"type":"string","const":"sessionID"},"data":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"info":{"type":"object","properties":{"id":{"anyOf":[{"type":"string","pattern":"^ses.*"},{"type":"null"}]},"slug":{"anyOf":[{"type":"string"},{"type":"null"}]},"projectID":{"anyOf":[{"type":"string"},{"type":"null"}]},"workspaceID":{"anyOf":[{"type":"string","pattern":"^wrk.*"},{"type":"null"}]},"directory":{"anyOf":[{"type":"string"},{"type":"null"}]},"parentID":{"anyOf":[{"type":"string","pattern":"^ses.*"},{"type":"null"}]},"summary":{"anyOf":[{"type":"object","properties":{"additions":{"type":"number"},"deletions":{"type":"number"},"files":{"type":"number"},"diffs":{"type":"array","items":{"$ref":"#/components/schemas/FileDiff"}}},"required":["additions","deletions","files"]},{"type":"null"}]},"share":{"type":"object","properties":{"url":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["url"]},"title":{"anyOf":[{"type":"string"},{"type":"null"}]},"version":{"anyOf":[{"type":"string"},{"type":"null"}]},"time":{"type":"object","properties":{"created":{"anyOf":[{"type":"number"},{"type":"null"}]},"updated":{"anyOf":[{"type":"number"},{"type":"null"}]},"compacting":{"anyOf":[{"type":"number"},{"type":"null"}]},"archived":{"anyOf":[{"type":"number"},{"type":"null"}]}},"required":["created","updated","compacting","archived"]},"permission":{"anyOf":[{"$ref":"#/components/schemas/PermissionRuleset"},{"type":"null"}]},"revert":{"anyOf":[{"type":"object","properties":{"messageID":{"type":"string","pattern":"^msg.*"},"partID":{"type":"string","pattern":"^prt.*"},"snapshot":{"type":"string"},"diff":{"type":"string"}},"required":["messageID"]},{"type":"null"}]}},"required":["id","slug","projectID","workspaceID","directory","parentID","summary","title","version","permission","revert"]}},"required":["sessionID","info"]}},"required":["type","aggregate","data"]},"SyncEvent.session.deleted":{"type":"object","properties":{"type":{"type":"string","const":"session.deleted.1"},"aggregate":{"type":"string","const":"sessionID"},"data":{"type":"object","properties":{"sessionID":{"type":"string","pattern":"^ses.*"},"info":{"$ref":"#/components/schemas/Session"}},"required":["sessionID","info"]}},"required":["type","aggregate","data"]},"SyncEvent":{"type":"object","properties":{"payload":{"$ref":"#/components/schemas/SyncEvent"}},"required":["payload"]},"LogLevel":{"description":"Log level","type":"string","enum":["DEBUG","INFO","WARN","ERROR"]},"ServerConfig":{"description":"Server configuration for opencode serve and web commands","type":"object","properties":{"port":{"description":"Port to listen on","type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"hostname":{"description":"Hostname to listen on","type":"string"},"mdns":{"description":"Enable mDNS service discovery","type":"boolean"},"mdnsDomain":{"description":"Custom domain name for mDNS service (default: opencode.local)","type":"string"},"cors":{"description":"Additional domains to allow for CORS","type":"array","items":{"type":"string"}}},"additionalProperties":false},"PermissionActionConfig":{"type":"string","enum":["ask","allow","deny"]},"PermissionObjectConfig":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"$ref":"#/components/schemas/PermissionActionConfig"}},"PermissionRuleConfig":{"anyOf":[{"$ref":"#/components/schemas/PermissionActionConfig"},{"$ref":"#/components/schemas/PermissionObjectConfig"}]},"PermissionConfig":{"anyOf":[{"type":"object","properties":{"__originalKeys":{"type":"array","items":{"type":"string"}},"read":{"$ref":"#/components/schemas/PermissionRuleConfig"},"edit":{"$ref":"#/components/schemas/PermissionRuleConfig"},"glob":{"$ref":"#/components/schemas/PermissionRuleConfig"},"grep":{"$ref":"#/components/schemas/PermissionRuleConfig"},"list":{"$ref":"#/components/schemas/PermissionRuleConfig"},"bash":{"$ref":"#/components/schemas/PermissionRuleConfig"},"task":{"$ref":"#/components/schemas/PermissionRuleConfig"},"external_directory":{"$ref":"#/components/schemas/PermissionRuleConfig"},"todowrite":{"$ref":"#/components/schemas/PermissionActionConfig"},"question":{"$ref":"#/components/schemas/PermissionActionConfig"},"webfetch":{"$ref":"#/components/schemas/PermissionActionConfig"},"websearch":{"$ref":"#/components/schemas/PermissionActionConfig"},"codesearch":{"$ref":"#/components/schemas/PermissionActionConfig"},"lsp":{"$ref":"#/components/schemas/PermissionRuleConfig"},"doom_loop":{"$ref":"#/components/schemas/PermissionActionConfig"},"skill":{"$ref":"#/components/schemas/PermissionRuleConfig"}},"additionalProperties":{"$ref":"#/components/schemas/PermissionRuleConfig"}},{"$ref":"#/components/schemas/PermissionActionConfig"}]},"AgentConfig":{"type":"object","properties":{"model":{"type":"string"},"variant":{"description":"Default model variant for this agent (applies only when using the agent's configured model).","type":"string"},"temperature":{"type":"number"},"top_p":{"type":"number"},"prompt":{"type":"string"},"tools":{"description":"@deprecated Use 'permission' field instead","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"boolean"}},"disable":{"type":"boolean"},"description":{"description":"Description of when to use the agent","type":"string"},"mode":{"type":"string","enum":["subagent","primary","all"]},"hidden":{"description":"Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)","type":"boolean"},"options":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"color":{"description":"Hex color code (e.g., #FF5733) or theme color (e.g., primary)","anyOf":[{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},{"type":"string","enum":["primary","secondary","accent","success","warning","error","info"]}]},"steps":{"description":"Maximum number of agentic iterations before forcing text-only response","type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxSteps":{"description":"@deprecated Use 'steps' field instead.","type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"permission":{"$ref":"#/components/schemas/PermissionConfig"}},"additionalProperties":{}},"ProviderConfig":{"type":"object","properties":{"api":{"type":"string"},"name":{"type":"string"},"env":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"npm":{"type":"string"},"models":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"family":{"type":"string"},"release_date":{"type":"string"},"attachment":{"type":"boolean"},"reasoning":{"type":"boolean"},"temperature":{"type":"boolean"},"tool_call":{"type":"boolean"},"interleaved":{"anyOf":[{"type":"boolean","const":true},{"type":"object","properties":{"field":{"type":"string","enum":["reasoning_content","reasoning_details"]}},"required":["field"],"additionalProperties":false}]},"cost":{"type":"object","properties":{"input":{"type":"number"},"output":{"type":"number"},"cache_read":{"type":"number"},"cache_write":{"type":"number"},"context_over_200k":{"type":"object","properties":{"input":{"type":"number"},"output":{"type":"number"},"cache_read":{"type":"number"},"cache_write":{"type":"number"}},"required":["input","output"]}},"required":["input","output"]},"limit":{"type":"object","properties":{"context":{"type":"number"},"input":{"type":"number"},"output":{"type":"number"}},"required":["context","output"]},"modalities":{"type":"object","properties":{"input":{"type":"array","items":{"type":"string","enum":["text","audio","image","video","pdf"]}},"output":{"type":"array","items":{"type":"string","enum":["text","audio","image","video","pdf"]}}},"required":["input","output"]},"experimental":{"type":"boolean"},"status":{"type":"string","enum":["alpha","beta","deprecated"]},"options":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"headers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"provider":{"type":"object","properties":{"npm":{"type":"string"},"api":{"type":"string"}}},"variants":{"description":"Variant-specific configuration","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"disabled":{"description":"Disable this variant for the model","type":"boolean"}},"additionalProperties":{}}}}}},"whitelist":{"type":"array","items":{"type":"string"}},"blacklist":{"type":"array","items":{"type":"string"}},"options":{"type":"object","properties":{"apiKey":{"type":"string"},"baseURL":{"type":"string"},"enterpriseUrl":{"description":"GitHub Enterprise URL for copilot authentication","type":"string"},"setCacheKey":{"description":"Enable promptCacheKey for this provider (default false)","type":"boolean"},"timeout":{"description":"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.","anyOf":[{"description":"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.","type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},{"description":"Disable timeout for this provider entirely.","type":"boolean","const":false}]},"chunkTimeout":{"description":"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.","type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":{}}},"additionalProperties":false},"McpLocalConfig":{"type":"object","properties":{"type":{"description":"Type of MCP server connection","type":"string","const":"local"},"command":{"description":"Command and arguments to run the MCP server","type":"array","items":{"type":"string"}},"environment":{"description":"Environment variables to set when running the MCP server","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"enabled":{"description":"Enable or disable the MCP server on startup","type":"boolean"},"timeout":{"description":"Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.","type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["type","command"],"additionalProperties":false},"McpOAuthConfig":{"type":"object","properties":{"clientId":{"description":"OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.","type":"string"},"clientSecret":{"description":"OAuth client secret (if required by the authorization server)","type":"string"},"scope":{"description":"OAuth scopes to request during authorization","type":"string"}},"additionalProperties":false},"McpRemoteConfig":{"type":"object","properties":{"type":{"description":"Type of MCP server connection","type":"string","const":"remote"},"url":{"description":"URL of the remote MCP server","type":"string"},"enabled":{"description":"Enable or disable the MCP server on startup","type":"boolean"},"headers":{"description":"Headers to send with the request","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"oauth":{"description":"OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.","anyOf":[{"$ref":"#/components/schemas/McpOAuthConfig"},{"type":"boolean","const":false}]},"timeout":{"description":"Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.","type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["type","url"],"additionalProperties":false},"LayoutConfig":{"description":"@deprecated Always uses stretch layout.","type":"string","enum":["auto","stretch"]},"Config":{"type":"object","properties":{"$schema":{"description":"JSON schema reference for configuration validation","type":"string"},"logLevel":{"$ref":"#/components/schemas/LogLevel"},"server":{"$ref":"#/components/schemas/ServerConfig"},"command":{"description":"Command configuration, see https://opencode.ai/docs/commands","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"template":{"type":"string"},"description":{"type":"string"},"agent":{"type":"string"},"model":{"type":"string"},"subtask":{"type":"boolean"}},"required":["template"]}},"skills":{"description":"Additional skill folder paths","type":"object","properties":{"paths":{"description":"Additional paths to skill folders","type":"array","items":{"type":"string"}},"urls":{"description":"URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)","type":"array","items":{"type":"string"}}}},"watcher":{"type":"object","properties":{"ignore":{"type":"array","items":{"type":"string"}}}},"snapshot":{"description":"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.","type":"boolean"},"plugin":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"array","prefixItems":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}]}},"share":{"description":"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing","type":"string","enum":["manual","auto","disabled"]},"autoshare":{"description":"@deprecated Use 'share' field instead. Share newly created sessions automatically","type":"boolean"},"autoupdate":{"description":"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications","anyOf":[{"type":"boolean"},{"type":"string","const":"notify"}]},"disabled_providers":{"description":"Disable providers that are loaded automatically","type":"array","items":{"type":"string"}},"enabled_providers":{"description":"When set, ONLY these providers will be enabled. All other providers will be ignored","type":"array","items":{"type":"string"}},"model":{"description":"Model to use in the format of provider/model, eg anthropic/claude-2","type":"string"},"small_model":{"description":"Small model to use for tasks like title generation in the format of provider/model","type":"string"},"default_agent":{"description":"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.","type":"string"},"username":{"description":"Custom username to display in conversations instead of system username","type":"string"},"mode":{"description":"@deprecated Use `agent` field instead.","type":"object","properties":{"build":{"$ref":"#/components/schemas/AgentConfig"},"plan":{"$ref":"#/components/schemas/AgentConfig"}},"additionalProperties":{"$ref":"#/components/schemas/AgentConfig"}},"agent":{"description":"Agent configuration, see https://opencode.ai/docs/agents","type":"object","properties":{"plan":{"$ref":"#/components/schemas/AgentConfig"},"build":{"$ref":"#/components/schemas/AgentConfig"},"general":{"$ref":"#/components/schemas/AgentConfig"},"explore":{"$ref":"#/components/schemas/AgentConfig"},"title":{"$ref":"#/components/schemas/AgentConfig"},"summary":{"$ref":"#/components/schemas/AgentConfig"},"compaction":{"$ref":"#/components/schemas/AgentConfig"}},"additionalProperties":{"$ref":"#/components/schemas/AgentConfig"}},"provider":{"description":"Custom provider configurations and model overrides","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"$ref":"#/components/schemas/ProviderConfig"}},"mcp":{"description":"MCP (Model Context Protocol) server configurations","type":"object","propertyNames":{"type":"string"},"additionalProperties":{"anyOf":[{"anyOf":[{"$ref":"#/components/schemas/McpLocalConfig"},{"$ref":"#/components/schemas/McpRemoteConfig"}]},{"type":"object","properties":{"enabled":{"type":"boolean"}},"required":["enabled"],"additionalProperties":false}]}},"formatter":{"anyOf":[{"type":"boolean","const":false},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"disabled":{"type":"boolean"},"command":{"type":"array","items":{"type":"string"}},"environment":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"extensions":{"type":"array","items":{"type":"string"}}}}}]},"lsp":{"anyOf":[{"type":"boolean","const":false},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"anyOf":[{"type":"object","properties":{"disabled":{"type":"boolean","const":true}},"required":["disabled"]},{"type":"object","properties":{"command":{"type":"array","items":{"type":"string"}},"extensions":{"type":"array","items":{"type":"string"}},"disabled":{"type":"boolean"},"env":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"initialization":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"required":["command"]}]}}]},"instructions":{"description":"Additional instruction files or patterns to include","type":"array","items":{"type":"string"}},"layout":{"$ref":"#/components/schemas/LayoutConfig"},"permission":{"$ref":"#/components/schemas/PermissionConfig"},"tools":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"boolean"}},"enterprise":{"type":"object","properties":{"url":{"description":"Enterprise URL","type":"string"}}},"compaction":{"type":"object","properties":{"auto":{"description":"Enable automatic compaction when context is full (default: true)","type":"boolean"},"prune":{"description":"Enable pruning of old tool outputs (default: true)","type":"boolean"},"reserved":{"description":"Token buffer for compaction. Leaves enough window to avoid overflow during compaction.","type":"integer","minimum":0,"maximum":9007199254740991}}},"experimental":{"type":"object","properties":{"disable_paste_summary":{"type":"boolean"},"batch_tool":{"description":"Enable the batch tool","type":"boolean"},"openTelemetry":{"description":"Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)","type":"boolean"},"primary_tools":{"description":"Tools that should only be available to primary agents.","type":"array","items":{"type":"string"}},"continue_loop_on_deny":{"description":"Continue the agent loop when a tool call is denied","type":"boolean"},"mcp_timeout":{"description":"Timeout in milliseconds for model context protocol (MCP) requests","type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}}}},"additionalProperties":false},"BadRequestError":{"type":"object","properties":{"data":{},"errors":{"type":"array","items":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"success":{"type":"boolean","const":false}},"required":["data","errors","success"]},"OAuth":{"type":"object","properties":{"type":{"type":"string","const":"oauth"},"refresh":{"type":"string"},"access":{"type":"string"},"expires":{"type":"number"},"accountId":{"type":"string"},"enterpriseUrl":{"type":"string"}},"required":["type","refresh","access","expires"]},"ApiAuth":{"type":"object","properties":{"type":{"type":"string","const":"api"},"key":{"type":"string"},"metadata":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}}},"required":["type","key"]},"WellKnownAuth":{"type":"object","properties":{"type":{"type":"string","const":"wellknown"},"key":{"type":"string"},"token":{"type":"string"}},"required":["type","key","token"]},"Auth":{"anyOf":[{"$ref":"#/components/schemas/OAuth"},{"$ref":"#/components/schemas/ApiAuth"},{"$ref":"#/components/schemas/WellKnownAuth"}]}}}} \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json index d17e3c3..4beebdd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,7 @@ "name": "web", "version": "0.0.0", "dependencies": { - "@opencode-ai/sdk": "^1.3.17", + "@appx-org/agent-chat-ui": "file:../../agent-chat", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", @@ -34,6 +34,37 @@ "vite": "^8.0.1" } }, + "../../agent-chat": { + "name": "@appx-org/agent-chat-ui", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "dompurify": "^3.3.3", + "marked": "^18.0.0", + "openapi-fetch": "^0.17.0", + "react-virtuoso": "^4.18.7" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "jsdom": "^25.0.1", + "openapi-typescript": "^7.13.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "typescript": "~5.9.3", + "vitest": "^2.1.9" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@appx-org/agent-chat-ui": { + "resolved": "../../agent-chat", + "link": true + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -275,21 +306,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -298,9 +329,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -568,35 +599,28 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.3.17", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.17.tgz", - "integrity": "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.120.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", - "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -604,9 +628,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -621,9 +645,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -638,9 +662,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -655,9 +679,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -672,9 +696,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", - "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -689,9 +713,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -706,9 +730,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -723,9 +747,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -740,9 +764,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -757,9 +781,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -774,9 +798,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -791,9 +815,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -808,9 +832,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", - "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -818,16 +842,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -842,9 +868,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -866,9 +892,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -1136,9 +1162,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1363,9 +1389,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1506,6 +1532,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1559,9 +1586,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", + "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -2013,6 +2040,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/js-tokens": { @@ -2401,9 +2429,9 @@ } }, "node_modules/marked": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz", - "integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==", + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", + "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -2433,9 +2461,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -2542,6 +2570,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2555,9 +2584,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2568,9 +2597,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -2588,7 +2617,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2686,14 +2715,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", - "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.120.0", - "@rolldown/pluginutils": "1.0.0-rc.10" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -2702,27 +2731,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-x64": "1.0.0-rc.10", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", - "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -2752,6 +2781,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -2764,6 +2794,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2806,14 +2837,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2943,17 +2974,17 @@ } }, "node_modules/vite": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", - "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.10", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -2969,8 +3000,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -3024,6 +3055,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/web/package.json b/web/package.json index b1f5fae..a9d18d0 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,7 @@ "preview": "vite preview" }, "dependencies": { - "@opencode-ai/sdk": "^1.3.17", + "@appx-org/agent-chat-ui": "file:../../agent-chat", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 878c9e6..883e209 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -40,7 +40,6 @@ export interface Project { status: string; assignedPort: number; appRunning: boolean; - openCodeProjectId?: string; lastError?: string; createdAt: string; projectDir?: string; @@ -88,32 +87,133 @@ export function changePassword(currentPassword: string, newPassword: string) { }); } -/** Checks whether an Anthropic API key is configured. */ -export function getApiKeyStatus() { - return request<{ set: boolean }>('/settings/api-key'); +export interface AgentAuthProvider { + provider: string; + name: string; + configured: boolean; + credentialType?: 'api_key' | 'oauth'; + source?: 'stored' | 'runtime' | 'environment' | 'fallback' | 'models_json_key' | 'models_json_command'; + label?: string; + supportsApiKey: boolean; + supportsSubscription: boolean; + modelCount: number; + availableModelCount: number; +} + +export interface AgentOAuthFlowState { + id: string; + provider: string; + providerName: string; + status: 'starting' | 'prompt' | 'auth' | 'waiting' | 'complete' | 'error' | 'cancelled'; + authUrl?: string; + instructions?: string; + prompt?: { + message: string; + placeholder?: string; + allowEmpty?: boolean; + }; + progress: string[]; + error?: string; + expiresAt: string; +} + +export type AgentCustomProviderApi = 'openai-completions' | 'openai-responses' | 'anthropic-messages'; + +export interface AgentCustomProviderModel { + id: string; + name?: string; + api?: AgentCustomProviderApi; + reasoning?: boolean; + thinkingLevelMap?: Partial>; + input?: Array<'text' | 'image'>; + contextWindow?: number; + maxTokens?: number; + compat?: Record; } -/** Stores an Anthropic API key. */ -export function setApiKey(key: string) { - return request<{ status: string }>('/settings/api-key', { +export interface AgentCustomProvider { + provider: string; + name?: string; + baseUrl?: string; + api?: AgentCustomProviderApi; + apiKeyConfigured: boolean; + modelCount: number; + models: AgentCustomProviderModel[]; +} + +/** Fetches Pi provider auth status. No secret values are returned. */ +export function getAgentAuthProviders() { + return request<{ providers: AgentAuthProvider[] }>('/agent/auth/providers'); +} + +/** Stores an API key for a Pi provider in the agent runtime user's auth storage. */ +export function setAgentProviderApiKey(provider: string, key: string) { + return request<{ ok: true }>(`/agent/auth/providers/${encodeURIComponent(provider)}/api-key`, { method: 'PUT', body: JSON.stringify({ key }), }); } -/** Removes the stored Anthropic API key. */ -export function deleteApiKey() { - return request<{ status: string }>('/settings/api-key', { method: 'DELETE' }); +/** Removes a stored Pi provider credential from the agent runtime user's auth storage. */ +export function deleteAgentProviderCredential(provider: string) { + return request<{ ok: true }>(`/agent/auth/providers/${encodeURIComponent(provider)}`, { + method: 'DELETE', + }); +} + +/** Starts a Pi subscription OAuth flow for a provider such as OpenAI Codex or Anthropic. */ +export function startAgentProviderSubscription(provider: string) { + return request( + `/agent/auth/providers/${encodeURIComponent(provider)}/subscription/start`, + { method: 'POST' }, + ); } -/** OpenCode server health status. */ -export interface OpenCodeHealth { - healthy: boolean; +/** Fetches the current state for a pending subscription auth flow. */ +export function getAgentSubscriptionFlow(flowId: string) { + return request(`/agent/auth/subscription/${encodeURIComponent(flowId)}`); } -/** Checks if the OpenCode server is reachable. GET /api/opencode/health. */ -export function getOpenCodeHealth() { - return request('/opencode/health'); +/** Continues a pending subscription auth flow with prompt input or a pasted redirect URL/code. */ +export function continueAgentSubscriptionFlow(flowId: string, value: string) { + return request(`/agent/auth/subscription/${encodeURIComponent(flowId)}/continue`, { + method: 'POST', + body: JSON.stringify({ value }), + }); +} + +/** Cancels a pending subscription auth flow. */ +export function cancelAgentSubscriptionFlow(flowId: string) { + return request(`/agent/auth/subscription/${encodeURIComponent(flowId)}`, { + method: 'DELETE', + }); +} + +/** Lists Pi custom providers managed through agent-server models.json. */ +export function getAgentCustomProviders() { + return request<{ providers: AgentCustomProvider[] }>('/agent/custom/providers'); +} + +/** Creates or updates a Pi custom provider, including LiteLLM-compatible providers. */ +export function upsertAgentCustomProvider(body: { + provider: string; + name?: string; + baseUrl: string; + api: AgentCustomProviderApi; + apiKey?: string; + models: AgentCustomProviderModel[]; +}) { + return request('/agent/custom/providers', { + method: 'PUT', + body: JSON.stringify(body), + }); +} + +/** Removes a custom Pi provider from models.json. */ +export function deleteAgentCustomProvider(provider: string) { + return request<{ ok: true }>(`/agent/custom/providers/${encodeURIComponent(provider)}`, { + method: 'DELETE', + }); } /** A single egress log entry. */ diff --git a/web/src/api/opencode.ts b/web/src/api/opencode.ts deleted file mode 100644 index 50dadb0..0000000 --- a/web/src/api/opencode.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk/v2/client'; - -export type { OpencodeClient }; -export type { - Session, - Message, - UserMessage, - AssistantMessage, - Part, - TextPart, - ToolPart, - ReasoningPart, - ToolState, - Event, - EventMessagePartDelta, - EventMessageUpdated, - EventMessagePartUpdated, - EventPermissionAsked, - EventPermissionReplied, - EventQuestionAsked, - EventQuestionReplied, - EventSessionStatus, - EventSessionIdle, - EventSessionCreated, - EventSessionUpdated, - EventSessionDeleted, - EventTodoUpdated, - PermissionRequest, - QuestionRequest, - QuestionInfo, - QuestionOption, - Todo, - SessionStatus, - FileDiff, -} from '@opencode-ai/sdk/v2/client'; - -const clients = new Map(); - -/** getClient returns a cached SDK client scoped to a project directory. */ -export function getClient(directory: string): OpencodeClient { - let client = clients.get(directory); - if (!client) { - client = createOpencodeClient({ - baseUrl: `${window.location.origin}/api/opencode`, - directory, - }); - clients.set(directory, client); - } - return client; -} diff --git a/web/src/components/EgressRequestDock.tsx b/web/src/components/EgressRequestDock.tsx index 6914df7..8197021 100644 --- a/web/src/components/EgressRequestDock.tsx +++ b/web/src/components/EgressRequestDock.tsx @@ -22,9 +22,12 @@ export default function EgressRequestDock() { }, []); useEffect(() => { - poll(); - const interval = setInterval(poll, 2000); - return () => clearInterval(interval); + const initial = window.setTimeout(() => void poll(), 0); + const interval = window.setInterval(() => void poll(), 2000); + return () => { + window.clearTimeout(initial); + window.clearInterval(interval); + }; }, [poll]); const handleApprove = async (id: string) => { diff --git a/web/src/components/Markdown.tsx b/web/src/components/Markdown.tsx deleted file mode 100644 index 352e24f..0000000 --- a/web/src/components/Markdown.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useMemo, useRef, useEffect } from 'react'; -import { marked } from 'marked'; -import DOMPurify from 'dompurify'; - -interface MarkdownProps { - text: string; -} - -/** Markdown renders a markdown string as sanitized HTML with copy buttons on code blocks. */ -export default function Markdown({ text }: MarkdownProps) { - const containerRef = useRef(null); - - const html = useMemo(() => { - if (!text) return ''; - const raw = marked.parse(text, { async: false }) as string; - return DOMPurify.sanitize(raw); - }, [text]); - - // Add copy buttons to code blocks after render - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const pres = container.querySelectorAll('pre'); - pres.forEach((pre) => { - if (pre.querySelector('[data-copy-btn]')) return; - const btn = document.createElement('button'); - btn.setAttribute('data-copy-btn', ''); - btn.textContent = 'Copy'; - Object.assign(btn.style, copyBtnStyle); - btn.addEventListener('click', () => { - const code = pre.querySelector('code'); - const text = code?.textContent ?? pre.textContent ?? ''; - navigator.clipboard.writeText(text).then(() => { - btn.textContent = 'Copied!'; - setTimeout(() => { - btn.textContent = 'Copy'; - }, 2000); - }); - }); - pre.style.position = 'relative'; - pre.appendChild(btn); - }); - }, [html]); - - return ( -
- ); -} - -const copyBtnStyle: Partial = { - position: 'absolute', - top: '6px', - right: '6px', - background: 'var(--surface)', - border: '1px solid var(--border)', - color: 'var(--muted)', - borderRadius: '3px', - padding: '2px 8px', - fontSize: '10px', - cursor: 'pointer', - fontFamily: "'JetBrains Mono', monospace", -}; - -const styles: Record = { - container: { - fontSize: 13, - lineHeight: 1.6, - color: 'var(--text)', - fontFamily: "'DM Sans', sans-serif", - wordBreak: 'break-word', - overflowWrap: 'break-word', - }, -}; - -// Global markdown styles — inject once -const styleId = 'appx-markdown-styles'; -if (typeof document !== 'undefined' && !document.getElementById(styleId)) { - const style = document.createElement('style'); - style.id = styleId; - style.textContent = ` - .appx-markdown p { margin: 0 0 8px 0; } - .appx-markdown p:last-child { margin-bottom: 0; } - .appx-markdown pre { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 4px; - padding: 12px; - overflow-x: auto; - margin: 8px 0; - position: relative; - } - .appx-markdown code { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - } - .appx-markdown :not(pre) > code { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 3px; - padding: 1px 5px; - font-size: 12px; - } - .appx-markdown ul, .appx-markdown ol { margin: 4px 0; padding-left: 20px; } - .appx-markdown li { margin: 2px 0; } - .appx-markdown a { color: var(--cyan); text-decoration: none; } - .appx-markdown a:hover { text-decoration: underline; } - .appx-markdown h1, .appx-markdown h2, .appx-markdown h3 { - margin: 12px 0 6px 0; - color: var(--text); - } - .appx-markdown blockquote { - border-left: 3px solid var(--border); - margin: 8px 0; - padding: 4px 12px; - color: var(--muted); - } - .appx-markdown table { border-collapse: collapse; margin: 8px 0; } - .appx-markdown th, .appx-markdown td { - border: 1px solid var(--border); - padding: 6px 10px; - font-size: 12px; - } - .appx-markdown th { background: var(--surface); } - `; - document.head.appendChild(style); -} diff --git a/web/src/components/OpenCodeStatus.tsx b/web/src/components/OpenCodeStatus.tsx deleted file mode 100644 index 50cd3cb..0000000 --- a/web/src/components/OpenCodeStatus.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; -import { getOpenCodeHealth } from '../api/client'; - -const POLL_INTERVAL = 10000; - -/** OpenCodeStatus renders a small health indicator for the OpenCode server. - * Polls every 10 seconds. Shows a colored dot with label: green when healthy, - * red when down, gray on initial load. */ -export default function OpenCodeStatus() { - const [healthy, setHealthy] = useState(null); - const pollRef = useRef | null>(null); - - useEffect(() => { - const check = () => { - getOpenCodeHealth() - .then(res => setHealthy(res.healthy)) - .catch(() => setHealthy(false)); - }; - - check(); - pollRef.current = setInterval(check, POLL_INTERVAL); - - return () => { - if (pollRef.current) clearInterval(pollRef.current); - }; - }, []); - - const color = healthy === null ? 'var(--muted)' : healthy ? 'var(--green)' : 'var(--red)'; - - return ( - - - OPENCODE - - ); -} - -const styles: Record = { - wrapper: { display: 'flex', alignItems: 'center', gap: 5 }, - dot: { width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }, - label: { fontFamily: "'JetBrains Mono', monospace", fontSize: 10, letterSpacing: '0.07em' }, -}; diff --git a/web/src/components/PermissionDock.tsx b/web/src/components/PermissionDock.tsx deleted file mode 100644 index d184e03..0000000 --- a/web/src/components/PermissionDock.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { PermissionRequest } from '@opencode-ai/sdk/v2/client'; - -interface PermissionDockProps { - permission: PermissionRequest; - onRespond: (requestID: string, reply: 'once' | 'always' | 'reject') => void; -} - -/** PermissionDock shows a permission request with allow/deny actions. */ -export default function PermissionDock({ permission, onRespond }: PermissionDockProps) { - return ( -
-
- - Permission Required -
-
- Tool: - {permission.permission} -
- {permission.patterns.length > 0 && ( -
- {permission.patterns.map((p, i) => ( - {p} - ))} -
- )} -
- - - -
-
- ); -} - -const styles: Record = { - dock: { background: 'var(--surface)', border: '1px solid var(--yellow)', borderRadius: 6, padding: '12px 16px', margin: '0 20px 8px' }, - header: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }, - icon: { fontSize: 14, color: 'var(--yellow)' }, - title: { fontFamily: "'JetBrains Mono', monospace", fontSize: 11, letterSpacing: '0.05em', color: 'var(--yellow)', fontWeight: 500 }, - info: { display: 'flex', gap: 6, marginBottom: 6, fontSize: 12 }, - label: { color: 'var(--muted)', fontFamily: "'JetBrains Mono', monospace", fontSize: 11 }, - value: { color: 'var(--text)', fontFamily: "'JetBrains Mono', monospace", fontSize: 11 }, - patterns: { display: 'flex', flexWrap: 'wrap' as const, gap: 4, marginBottom: 10 }, - pattern: { fontFamily: "'JetBrains Mono', monospace", fontSize: 10, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 3, padding: '2px 6px', color: 'var(--text)' }, - actions: { display: 'flex', gap: 8, justifyContent: 'flex-end' }, - denyBtn: { background: 'transparent', border: '1px solid var(--red)', color: 'var(--red)', borderRadius: 4, padding: '5px 14px', fontSize: 11, cursor: 'pointer' }, - alwaysBtn: { background: 'transparent', border: '1px solid var(--green)', color: 'var(--green)', borderRadius: 4, padding: '5px 14px', fontSize: 11, cursor: 'pointer' }, - allowBtn: { background: 'var(--blue)', border: 'none', color: '#fff', borderRadius: 4, padding: '5px 14px', fontSize: 11, fontWeight: 500, cursor: 'pointer' }, -}; diff --git a/web/src/components/QuestionDock.tsx b/web/src/components/QuestionDock.tsx deleted file mode 100644 index be02e83..0000000 --- a/web/src/components/QuestionDock.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useState } from 'react'; -import type { QuestionRequest, QuestionAnswer } from '@opencode-ai/sdk/v2/client'; - -interface QuestionDockProps { - question: QuestionRequest; - onAnswer: (requestID: string, answers: QuestionAnswer[]) => void; - onReject: (requestID: string) => void; -} - -/** QuestionDock shows an agent question with radio/text options and submit. */ -export default function QuestionDock({ question, onAnswer, onReject }: QuestionDockProps) { - const [answers, setAnswers] = useState(question.questions.map(() => [])); - - const handleSelect = (qIdx: number, label: string, multiple: boolean) => { - setAnswers((prev) => { - const next = [...prev]; - if (multiple) { - const current = next[qIdx]; - next[qIdx] = current.includes(label) ? current.filter((l) => l !== label) : [...current, label]; - } else { - next[qIdx] = [label]; - } - return next; - }); - }; - - const handleSubmit = () => { onAnswer(question.id, answers); }; - const hasAnswer = answers.some((a) => a.length > 0); - - return ( -
- {question.questions.map((q, qIdx) => ( -
- {q.header &&
{q.header}
} -
{q.question}
-
- {q.options.map((opt) => ( - - ))} -
-
- ))} -
- - -
-
- ); -} - -const styles: Record = { - dock: { background: 'var(--surface)', border: '1px solid var(--cyan)', borderRadius: 6, padding: '12px 16px', margin: '0 20px 8px' }, - questionBlock: { marginBottom: 10 }, - header: { fontFamily: "'JetBrains Mono', monospace", fontSize: 10, letterSpacing: '0.05em', color: 'var(--cyan)', marginBottom: 4 }, - questionText: { fontSize: 13, color: 'var(--text)', marginBottom: 8 }, - options: { display: 'flex', flexDirection: 'column' as const, gap: 4 }, - option: { display: 'flex', flexDirection: 'column' as const, gap: 2, padding: '8px 12px', background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 4, cursor: 'pointer', textAlign: 'left' as const }, - optionSelected: { display: 'flex', flexDirection: 'column' as const, gap: 2, padding: '8px 12px', background: 'var(--cyan-dim)', border: '1px solid var(--cyan)', borderRadius: 4, cursor: 'pointer', textAlign: 'left' as const }, - optionLabel: { fontSize: 12, color: 'var(--text)', fontWeight: 500 }, - optionDesc: { fontSize: 11, color: 'var(--muted)' }, - actions: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }, - rejectBtn: { background: 'transparent', border: '1px solid var(--border)', color: 'var(--muted)', borderRadius: 4, padding: '5px 14px', fontSize: 11, cursor: 'pointer' }, - submitBtn: { background: 'var(--blue)', border: 'none', color: '#fff', borderRadius: 4, padding: '5px 14px', fontSize: 11, fontWeight: 500, cursor: 'pointer' }, -}; diff --git a/web/src/components/StatusBar.tsx b/web/src/components/StatusBar.tsx deleted file mode 100644 index 63f270e..0000000 --- a/web/src/components/StatusBar.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { ConnectionStatus } from '../lib/agent-core/connection'; - -interface StatusBarProps { - agentStatus: 'idle' | 'running' | 'error'; - connectionStatus: ConnectionStatus; -} - -/** StatusBar shows agent status and SSE connection health. */ -export default function StatusBar({ agentStatus, connectionStatus }: StatusBarProps) { - const agentColor = agentStatus === 'running' ? 'var(--yellow)' : agentStatus === 'error' ? 'var(--red)' : 'var(--green)'; - const connColor = connectionStatus === 'connected' ? 'var(--green)' : connectionStatus === 'connecting' ? 'var(--yellow)' : 'var(--red)'; - - return ( -
-
- - {agentStatus === 'running' ? 'Agent running' : agentStatus === 'error' ? 'Agent error' : 'Agent idle'} -
-
- - {connectionStatus === 'connected' ? 'Connected' : connectionStatus === 'connecting' ? 'Reconnecting...' : 'Disconnected'} -
-
- ); -} - -const styles: Record = { - bar: { display: 'flex', gap: 16, padding: '6px 20px', borderTop: '1px solid var(--border)', background: 'var(--bg)' }, - item: { display: 'flex', alignItems: 'center', gap: 6 }, - dot: { width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }, - label: { fontFamily: "'JetBrains Mono', monospace", fontSize: 10, color: 'var(--muted)' }, -}; diff --git a/web/src/components/Terminal.tsx b/web/src/components/Terminal.tsx index 0a5c09a..4691453 100644 --- a/web/src/components/Terminal.tsx +++ b/web/src/components/Terminal.tsx @@ -16,8 +16,8 @@ interface TerminalProps { } /** Terminal renders an xterm.js terminal connected to a local PTY via appx's - * /api/shell endpoints (creack/pty). No OpenCode dependency — works even when - * OpenCode is down. Handles auto-reconnect with exponential backoff, resize, + * /api/shell endpoints (creack/pty). No agent-runtime dependency. Handles + * auto-reconnect with exponential backoff, resize, * ring buffer replay on reconnect, and mobile copy/paste. */ export default function Terminal({ cwd }: TerminalProps) { const containerRef = useRef(null); diff --git a/web/src/components/ToolCallCard.tsx b/web/src/components/ToolCallCard.tsx deleted file mode 100644 index 95595b5..0000000 --- a/web/src/components/ToolCallCard.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useState } from 'react'; -import type { ToolPart } from '@opencode-ai/sdk/v2/client'; - -interface ToolCallCardProps { - part: ToolPart; -} - -/** ToolCallCard renders a collapsible card for a tool call with status indicator. */ -export default function ToolCallCard({ part }: ToolCallCardProps) { - const { tool, state } = part; - const status = state.status; - const isRunning = status === 'running'; - const isError = status === 'error'; - const isCompleted = status === 'completed'; - - const [open, setOpen] = useState(isRunning || isError); - - const title = - (status === 'completed' || status === 'running') && state.title - ? state.title - : tool; - - const statusColor = isError - ? 'var(--red)' - : isRunning - ? 'var(--yellow)' - : isCompleted - ? 'var(--green)' - : 'var(--muted)'; - - const statusLabel = isError - ? 'error' - : isRunning - ? 'running' - : isCompleted - ? 'done' - : 'pending'; - - return ( -
- - {open && ( -
- {isError && ( -
-              {(state as { error: string }).error}
-            
- )} - {isCompleted && ( -
-              {(state as { output: string }).output || '(no output)'}
-            
- )} - {isRunning && ( - Running... - )} - {status === 'pending' && ( - Pending... - )} -
- )} -
- ); -} - -const styles: Record = { - card: { - border: '1px solid var(--border)', - borderRadius: 4, - overflow: 'hidden', - margin: '4px 0', - }, - header: { - display: 'flex', - alignItems: 'center', - gap: 8, - width: '100%', - padding: '8px 12px', - background: 'var(--surface)', - border: 'none', - cursor: 'pointer', - textAlign: 'left' as const, - }, - toolName: { - flex: 1, - fontFamily: "'JetBrains Mono', monospace", - fontSize: 12, - color: 'var(--text)', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap' as const, - }, - statusBadge: { - fontFamily: "'JetBrains Mono', monospace", - fontSize: 10, - letterSpacing: '0.05em', - display: 'flex', - alignItems: 'center', - gap: 4, - }, - spinner: { - display: 'inline-block', - animation: 'spin 1s linear infinite', - }, - toggle: { - fontSize: 10, - color: 'var(--muted)', - }, - body: { - padding: '8px 12px', - borderTop: '1px solid var(--border)', - background: 'var(--bg)', - }, - output: { - fontFamily: "'JetBrains Mono', monospace", - fontSize: 11, - color: 'var(--text)', - margin: 0, - whiteSpace: 'pre-wrap' as const, - wordBreak: 'break-word' as const, - maxHeight: 300, - overflowY: 'auto' as const, - lineHeight: 1.4, - }, - errorOutput: { - fontFamily: "'JetBrains Mono', monospace", - fontSize: 11, - color: 'var(--red)', - margin: 0, - whiteSpace: 'pre-wrap' as const, - wordBreak: 'break-word' as const, - maxHeight: 200, - overflowY: 'auto' as const, - }, - runningText: { - fontFamily: "'JetBrains Mono', monospace", - fontSize: 11, - color: 'var(--muted)', - }, -}; diff --git a/web/src/components/agent/ChatPanel.tsx b/web/src/components/agent/ChatPanel.tsx deleted file mode 100644 index 669992b..0000000 --- a/web/src/components/agent/ChatPanel.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import { useState, useRef, useEffect, useMemo } from 'react'; -import type { - Message, - UserMessage, - AssistantMessage, - Part, - TextPart, - ToolPart, - ReasoningPart, -} from '@opencode-ai/sdk/v2/client'; -import { useSession } from '../../lib/agent-react/useSession'; -import { usePermissions } from '../../lib/agent-react/usePermissions'; -import { getClient } from '../../api/opencode'; -import Markdown from '../Markdown'; -import ToolCallCard from '../ToolCallCard'; -import PermissionDock from '../PermissionDock'; -import QuestionDock from '../QuestionDock'; -import EgressRequestDock from '../EgressRequestDock'; -import StatusBar from '../StatusBar'; - -interface Turn { - user: UserMessage; - assistants: AssistantMessage[]; -} - -function groupIntoTurns(messages: Message[]): Turn[] { - const users = messages.filter((m): m is UserMessage => m.role === 'user'); - return users.map((user) => ({ - user, - assistants: messages.filter( - (m): m is AssistantMessage => - m.role === 'assistant' && m.parentID === user.id, - ), - })); -} - -function renderPart(part: Part) { - switch (part.type) { - case 'text': - return ; - case 'tool': - return ; - case 'reasoning': - return ( -
- Thinking... -
-            {(part as ReasoningPart).text}
-          
-
- ); - default: - return null; - } -} - -/** ChatPanel renders the full agent conversation for a session. Uses the - * headless core hooks for SSE streaming, state management, and actions. */ -export default function ChatPanel({ - sessionId, - projectDir, -}: { - sessionId: string; - projectDir: string; -}) { - const { state, connectionStatus, sendPrompt, abort } = useSession( - sessionId, - projectDir, - ); - const client = useMemo( - () => (projectDir ? getClient(projectDir) : null), - [projectDir], - ); - const { respondPermission, answerQuestion, rejectQuestion } = - usePermissions(client); - - const [input, setInput] = useState(''); - const [sending, setSending] = useState(false); - const bottomRef = useRef(null); - const scrollRef = useRef(null); - const pinnedRef = useRef(true); - - const turns = useMemo(() => groupIntoTurns(state.messages), [state.messages]); - const isRunning = state.status === 'running'; - - // Auto-scroll to bottom only when the user is already pinned there. - useEffect(() => { - if (pinnedRef.current) { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); - } - }, [state.messages, state.parts]); - - // Re-pin when the user sends a new message so the response scrolls into view. - // Also re-pin when a previously-running agent becomes idle (turn complete). - const prevRunningRef = useRef(false); - useEffect(() => { - if (!prevRunningRef.current && isRunning) { - pinnedRef.current = true; - } - prevRunningRef.current = isRunning; - }, [isRunning]); - - const handleSend = async () => { - const text = input.trim(); - if (!text || sending || isRunning) return; - setInput(''); - setSending(true); - try { - await sendPrompt(text); - } catch (e) { - console.error('Failed to send prompt:', e); - } finally { - setSending(false); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }; - - return ( -
- {/* Messages */} -
{ - const el = scrollRef.current; - if (!el) return; - // Consider "pinned" when within 80px of the bottom. - pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80; - }} - > - {turns.length === 0 && ( -
- Send a prompt to start -
- )} - {turns.map((turn) => ( -
- {/* User message */} -
- YOU - {(state.parts[turn.user.id] ?? []).map(renderPart)} - {!(state.parts[turn.user.id]?.length) && ( - (prompt) - )} -
- {/* Assistant messages */} - {turn.assistants.map((asst) => ( -
- AGENT - {(state.parts[asst.id] ?? []).map(renderPart)} - {asst.error && ( -
- {JSON.stringify(asst.error)} -
- )} -
- ))} -
- ))} -
-
- - {/* Docks */} - {state.pendingPermissions.map((perm) => ( - - ))} - {state.pendingQuestions.map((q) => ( - - ))} - - - {/* Error banner */} - {state.error &&
{state.error}
} - - {/* Input */} -
-