diff --git a/README.md b/README.md index d2e0df3..1f10691 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@
Spoon is a self-hostable fork maintenance cockpit built around managed forks, - durable maintenance threads, and OpenCode-powered workspaces. + durable maintenance threads, and a persistent per-user dev box that agents, + terminals, and project commands all run inside.
@@ -61,14 +62,38 @@ in the data model, but GitHub is the active automation surface today.
Spoon keeps raw GitHub drift visible while also tracking ignored upstream
changes so irrelevant commits do not keep a fork permanently actionable.
-- **OpenCode workspaces**
- Agent work happens in an isolated workspace with a file tree, browser editor,
- diff viewer, command panel, logs, artifacts, and draft PR actions.
+- **One persistent per-user box**
+ Every user owns a single long-running Fedora container `spoon-box-{username}`
+ with a persistent home. Every thread's agent turn, terminal session, and
+ project command `docker exec`s into that same box — there is no per-job
+ throwaway container.
+
+- **Three agent runtimes**
+ Codex, OpenCode, and Claude Code all run inside the box behind one adapter
+ interface, selected per thread and validated at queue time against the AI
+ provider profile.
+
+- **Workspaces**
+ Agent work happens in a workspace with a file tree, browser editor, diff
+ viewer, command panel, logs, artifacts, an interactive terminal, and draft PR
+ actions.
+
+- **My Machine**
+ A `/machine` surface to start/stop/restart the box, open a `~`-rooted
+ terminal, and browse the persistent home.
+
+- **GitHub webhooks**
+ A signature-verified webhook keeps drift fresh on `push` and flips connection
+ status on `installation` changes; the hourly cron is a fallback.
+
+- **Notifications**
+ An in-app bell plus preference-gated transactional email for maintenance
+ threads, agent turns, needed input, sync failures, and connection re-auth.
- **User-owned providers and secrets**
- AI provider profiles, Codex/OpenCode auth, and per-Spoon project secrets are
- encrypted. Secrets are redacted from logs and refused from commits when
- materialized into env files.
+ AI provider profiles, Codex/OpenCode/Anthropic auth, and per-Spoon project
+ secrets are encrypted. Secrets are written `0600`, redacted from logs, and
+ refused from commits when materialized into env files.
- **Draft PR handoff**
Code changes become branches and draft pull requests. Spoon does not
@@ -138,12 +163,23 @@ custom work exists.
OpenCode workspaces
+The box and workspaces
Spoon's optional agent worker is designed to run outside Convex actions. The
-worker claims queued jobs, clones the current GitHub fork, creates a branch,
-starts an isolated workspace, and exposes workspace operations to the Next app
-through server-only API proxies.
+worker claims queued jobs, clones the current GitHub fork into the owner's
+persistent home, creates a branch, and exposes workspace operations to the Next
+app through server-only API proxies.
+
+Everything runs inside **one long-running container per user**,
+`spoon-box-{username}`. The box is a Fedora image (`docker/agent-job.Dockerfile`)
+started with `sleep infinity` and a `--memory 4g` / `--cpus 2` cap. Each
+thread's agent turn, terminal, and project commands `docker exec` into that same
+box; the box's `/home/{username}` is a bind-mounted persistent home so dotfiles,
+installed tools, shell history, and thread checkouts under `~/Code/{spoon}/{branch}`
+survive across sessions. The box is reference-counted by the worker and reaped
+after `SPOON_AGENT_BOX_IDLE_MS` idle (default 30m). There is no per-job
+`docker run --rm` container and no separate `opencode serve` container — that
+path was removed.
Workspace capabilities:
@@ -154,16 +190,18 @@ Workspace capabilities:
- inspect diffs
- send thread messages to the agent
- run configured commands
+- use an interactive terminal (xterm.js → box PTY; see
+ [docs/agent-terminal.md](docs/agent-terminal.md))
- store logs and artifacts
- push a branch
- open a draft PR
The browser never receives worker tokens and never talks directly to the worker
-or job container.
+or the box.
-Worker cleanup is available in `Settings -> Worker`. It can delete old terminal
-workspace records and ask the active worker to remove orphaned job containers
-and inactive work directories.
+Worker cleanup is available in `Settings -> Worker`. It can delete stale
+workspace records and ask the active worker to remove orphaned containers and
+inactive work directories (persistent per-user homes are preserved).
Local worker development:
@@ -174,20 +212,53 @@ bun dev:next:worker
bun dev:next:worker:staging
```
-Local host-run worker commands still load env through Infisical, then
-`scripts/dev-agent-worker` selects Podman when available, falls back to Docker,
-and publishes the OpenCode server on a localhost port so the host worker can
-reach the job container. Override with:
+Local host-run worker commands load env through Infisical, then
+`scripts/dev-agent-worker` selects Podman when available and falls back to
+Docker. Override the container CLI with:
```env
SPOON_AGENT_CONTAINER_RUNTIME=podman
-SPOON_AGENT_CONTAINER_ACCESS=host_port
```
Production agent runtime images
+Agent runtimes
+
+Three agent CLIs run inside the box behind one `AgentRuntime` adapter interface
+(`apps/agent-worker/src/runtime/*-adapter.ts`), selected by `job.runtime`:
+
+| Runtime | CLI | Selected for |
+| ---------- | ----------------------------------- | -------------------------------------------------- |
+| `codex` | `@openai/codex` | OpenAI providers and Codex ChatGPT-login snapshots |
+| `opencode` | `opencode-ai` | OpenAI-compatible API-key providers (default) |
+| `claude` | `@anthropic-ai/claude-code@2.1.207` | Anthropic providers |
+
+The runtime is validated at queue time (`runtimeSupport.ts` +
+`agentJobs.insertJob`): a job is rejected unless the resolved runtime is one the
+AI provider profile supports. `runtimesForProfile` maps profiles to runtimes —
+ChatGPT-login snapshots → `codex` only; `openai` API keys → `opencode`/`codex`;
+Anthropic API keys → `claude`/`opencode`; Anthropic `anthropic_oauth_json`
+credential snapshots → `claude` only; every other OpenAI-compatible API key →
+`opencode`.
+
+Auth is materialized into the box before a turn:
+
+- **Codex ChatGPT-login** profiles get the encrypted `auth.json` written to
+ `CODEX_HOME/.codex/auth.json`.
+- **Anthropic API-key** profiles authenticate via `ANTHROPIC_API_KEY` in the
+ environment (no file needed).
+- **Anthropic `anthropic_oauth_json`** credential-snapshot profiles get the
+ encrypted OAuth blob written to `~/.claude/.credentials.json`.
+- **API-key** profiles run through OpenCode.
+
+All materialized auth files are written `0600`. Treat those saved auth files
+like a password and only use them on trusted workers.
+
+Production runtime images
Gitea CI builds and pushes three production images:
@@ -197,32 +268,36 @@ git.gbrown.org/gib/spoon-agent-worker:latest
git.gbrown.org/gib/spoon-agent-job:latest
```
-The worker image is the long-running service that polls Convex. The job image is
-the isolated workbench that the worker launches for each agent job. For the MVP,
-production should use the repo-provided JS/TS workbench image:
+The worker image is the long-running service that polls Convex. The
+`spoon-agent-job` image is the **box** image — the per-user Fedora dev box the
+worker `exec`s into (it is not launched fresh per job). Point the worker at it
+with:
```env
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
```
-The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git,
-ripgrep, Python, build tools, OpenCode, the Codex CLI, and the Claude Code CLI.
-It is not the forked project's production runtime; it is the agent execution
-environment.
+The box image is Fedora 41 with Node, Bun, pnpm and yarn, npm, git, ripgrep,
+Python, build tools, plus interactive tooling (neovim, tmux, fzf, fd, bat, eza,
+zoxide, gh, gum, oh-my-posh) and the pinned agent CLIs OpenCode, Codex, and
+Claude Code. It is not the forked project's production runtime; it is the agent
+execution environment.
Production worker runtime requirements:
- `spoon-agent-worker` must run as a separate service.
-- The worker needs `/var/run/docker.sock` mounted so it can launch job
- containers.
-- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
- `SPOON_AGENT_CONTAINER_ACCESS=network`.
-- The production Docker host must be logged into `git.gbrown.org` so worker jobs
- can pull the private `spoon-agent-job` image.
+- The worker needs `/var/run/docker.sock` mounted so it can create and `exec`
+ into per-user boxes on the host daemon.
+- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker`.
+- `SPOON_AGENT_HOST_WORKDIR` must equal the absolute host path backing
+ `SPOON_AGENT_WORKDIR` (identical inside and outside the worker container) so
+ the host daemon can bind-mount per-user homes under `${WORKDIR}/homes/{username}`.
+- The production Docker host must be logged into `git.gbrown.org` so the worker
+ can pull the private `spoon-agent-job` box image.
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
- `spoon-next` needs `SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921` and
- `SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
- file, diff, message, command, and draft PR actions.
+ `SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace and
+ box file, diff, message, command, terminal, and draft PR actions.
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
If the private key is stored in a single-line dotenv value, encode newlines as
literal `\n` characters so the worker can restore the PEM before using it.
@@ -241,26 +316,57 @@ curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
Deployment readiness checklist:
1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`,
- GitHub App env, and Convex Auth signing keys.
+ GitHub App env (including `GITHUB_APP_WEBHOOK_SECRET`), and Convex Auth
+ signing keys.
2. Compose env has `SPOON_AGENT_WORKER_URL`,
`SPOON_AGENT_WORKER_INTERNAL_TOKEN`, `SPOON_AGENT_JOB_IMAGE`, and the GitHub
- App private key.
+ App private key; the `spoon-next` image is built with
+ `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL`.
3. The production Docker host can pull private images from `git.gbrown.org`.
-4. `Settings -> Worker` reports the expected job image, runtime, network, and
- active workspace count.
+4. `Settings -> Worker` reports the expected box image, runtime, network, and
+ active box count.
5. The first test thread uses a configured API-key provider or a trusted Codex
login profile.
-6. If a worker restart leaves stale workspace state, use the workspace recovery
- panel or `Settings -> Worker` cleanup.
+6. If a worker restart leaves stale state, use the workspace recovery panel or
+ `Settings -> Worker` cleanup.
-API-key based AI provider profiles run through OpenCode. Codex ChatGPT login
-profiles run through the Codex CLI: Spoon writes the encrypted `auth.json` into
-the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution.
-Anthropic profiles run through the Claude Code CLI: API-key profiles authenticate
-via `ANTHROPIC_API_KEY`, while `anthropic_oauth_json` credential-snapshot profiles
-have their encrypted OAuth blob written into the isolated job workspace as
-`~/.claude/.credentials.json` before execution. Treat those saved auth files like
-a password and only use them on trusted workers.
+GitHub webhooks
+
+Spoon exposes a signature-verified webhook as a Convex `httpAction` at
+`POST /webhooks/github` (`packages/backend/convex/githubWebhooks.ts`,
+`http.ts`). Configure the GitHub App webhook URL as
+`Notifications
+
+Spoon delivers an in-app bell plus preference-gated transactional email
+(`packages/backend/convex/notifications.ts`). Five kinds are emitted:
+`maintenance_thread`, `agent_turn_finished`, `agent_needs_input`, `sync_failed`,
+and `connection_needs_reauth`.
+
+- Every event inserts an in-app notification (bell badge via `unreadCount`,
+ list via `listMine`, `markRead` / `markAllRead`).
+- Email is sent through UseSend (`USESEND_*`) only when the user has an email
+ and the matching per-kind preference is not disabled. Unset preferences
+ default to enabled.
+- Per-kind email toggles live in `Settings -> Notifications`. Web push is out of
+ scope.
Core tables
-| Table | Purpose |
-| ------------------------ | --------------------------------------------------------- |
-| `spoons` | Managed fork records |
-| `threads` | Durable maintenance and work conversations |
-| `threadMessages` | Messages inside threads |
-| `syncRuns` | Upstream checks, sync attempts, and maintenance decisions |
-| `ignoredUpstreamChanges` | Intentional ignore records that affect effective drift |
-| `gitConnections` | Git provider connection metadata |
-| `spoonRepositoryStates` | Latest cached upstream/fork state |
-| `spoonCommits` | Cached upstream and fork-only commits |
-| `spoonPullRequests` | Cached fork/upstream pull requests |
-| `spoonSecrets` | Encrypted per-Spoon environment variables |
-| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
-| `aiProviderProfiles` | Encrypted provider/auth profiles used by OpenCode |
-| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
-| `agentJobEvents` | Append-only worker event log |
-| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
-| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
+| Table | Purpose |
+| ------------------------- | ----------------------------------------------------------- |
+| `spoons` | Managed fork records |
+| `threads` | Durable maintenance and work conversations |
+| `threadMessages` | Messages inside threads |
+| `syncRuns` | Upstream checks, sync attempts, and maintenance decisions |
+| `ignoredUpstreamChanges` | Intentional ignore records that affect effective drift |
+| `gitConnections` | Git provider connection metadata |
+| `spoonRepositoryStates` | Latest cached upstream/fork state |
+| `spoonCommits` | Cached upstream and fork-only commits |
+| `spoonPullRequests` | Cached fork/upstream pull requests |
+| `spoonSecrets` | Encrypted per-Spoon environment variables |
+| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
+| `aiProviderProfiles` | Encrypted provider/auth profiles (Codex/OpenCode/Anthropic) |
+| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
+| `agentJobEvents` | Append-only worker event log |
+| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
+| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
+| `userDotfiles` | Encrypted per-user dotfiles overlay + repo/setup config |
+| `userEnvironment` | Per-user box/home environment config |
+| `notifications` | In-app notification rows (bell) |
+| `notificationPreferences` | Per-user email notification toggles |
Important routes
-| Route | Purpose |
-| --------------------------------- | --------------------------------------- |
-| `/` | Public product landing page |
-| `/dashboard` | Maintenance overview |
-| `/spoons` | Managed fork list |
-| `/spoons/new` | Manual/GitHub Spoon creation |
-| `/spoons/[spoonId]` | Spoon detail dashboard |
-| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
-| `/threads` | Global thread queue |
-| `/threads/[threadId]` | Thread detail |
-| `/settings/profile` | User profile settings |
-| `/settings/integrations` | GitHub and service integration settings |
-| `/settings/ai-providers` | AI/OpenCode provider profiles |
+| Route | Purpose |
+| --------------------------------- | ----------------------------------------------- |
+| `/` | Public product landing page |
+| `/dashboard` | Maintenance overview |
+| `/spoons` | Managed fork list |
+| `/spoons/new` | Manual/GitHub Spoon creation |
+| `/spoons/[spoonId]` | Spoon detail dashboard |
+| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
+| `/threads` | Global thread queue |
+| `/threads/[threadId]` | Thread detail |
+| `/machine` | My Machine: box status, terminal, home |
+| `/settings/profile` | User profile settings |
+| `/settings/integrations` | GitHub and service integration settings |
+| `/settings/ai-providers` | AI provider profiles (Codex/OpenCode/Anthropic) |
+| `/settings/dotfiles` | Per-user dotfiles overlay + repo |
+| `/settings/notifications` | Email notification preferences |
-Legacy `/updates` and `/agents` routes redirect into `/threads`.
+The Convex `httpAction` `POST /webhooks/github` (mounted at
+`Public Next variables
-| Variable | Used for |
-| --------------------------------- | ------------------------------------------- |
-| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
-| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
-| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
-| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
-| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
-| `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL |
-| `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization |
-| `NEXT_PUBLIC_SENTRY_PROJECT_NAME` | Sentry project name |
+| Variable | Used for |
+| --------------------------------------- | ------------------------------------------------------------------- |
+| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
+| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
+| `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` | Browser-facing worker WS base for the terminal (**build-time** var) |
+| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
+| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
+| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
+| `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL |
+| `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization |
+| `NEXT_PUBLIC_SENTRY_PROJECT_NAME` | Sentry project name |