Gabriel Brown b038a31520 fix(box): bump job image base to Fedora 44 for Neovim 0.12
fedora:41 ships Neovim 0.10.4, below LazyVim's >= 0.11 requirement, so
dotfiles-managed editors failed inside the box. F44 ships 0.12.4.
2026-07-12 22:26:45 -04:00
2026-06-21 17:52:02 -05:00
2026-06-23 22:27:23 -04:00
2026-06-21 17:52:02 -05:00
2026-06-21 17:52:02 -05:00
2026-06-23 22:27:23 -04:00
2026-06-24 08:39:31 -04:00
2026-06-24 22:40:26 -04:00

Spoon logo

Spoon

Fork freely & keep them all intimately close to upstream.

Spoon is a self-hostable fork maintenance cockpit built around managed forks, durable maintenance threads, and a persistent per-user dev box that agents, terminals, and project commands all run inside.

What this is · Product model · Architecture · Environment


What This Is

Spoon is a private, actively evolving project for making forks less lonely to maintain.

Forking a project is easy. Keeping that fork close to upstream after you add custom changes is the hard part. Spoon treats a fork as an ongoing relationship: it watches upstream, understands fork-only commits, automatically syncs clean drift when it can, and opens a durable Thread when a decision needs context or code.

The application is currently GitHub-first. Future provider-neutral fields exist in the data model, but GitHub is the active automation surface today.

Highlights

  • Managed forks, called Spoons
    Track upstream metadata, fork metadata, clone URLs, extra remotes, sync cadence, production-ref strategy, fork-only commits, and pull requests.

  • Thread-first maintenance
    Upstream updates, conflict review, ignore decisions, user-requested work, worker output, and draft PR handoff all live inside Threads.

  • Clean drift auto-sync
    If upstream moves and the fork has no custom commits, Spoon can fast-forward the fork without creating busywork.

  • Custom forks get context
    If the fork has custom commits, Spoon creates a maintenance thread rather than pretending the update is trivial.

  • Effective drift
    Spoon keeps raw GitHub drift visible while also tracking ignored upstream changes so irrelevant commits do not keep a fork permanently actionable.

  • 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 execs 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/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 auto-merge custom forks behind the user's back.

Product Model

Spoons

A Spoon is a managed fork. It records the upstream project, the fork repository, default branches, sync policy, extra remotes, current drift, cached commits, cached pull requests, secrets, and agent settings.

Spoons are the durable project-level objects. They answer:

  • What did I fork?
  • Where does my fork live?
  • How far has it drifted?
  • Which commits are mine?
  • Which upstream changes matter?
  • What threads or PRs are open?
Threads

A Thread is the durable place where Spoon talks about maintenance work. Threads can be created by a user or by the system.

Common thread sources:

  • user_request: user asks Spoon to change a fork.
  • upstream_update: upstream moved and the fork needs review.
  • merge_conflict: a sync conflict needs context or code.
  • manual_review: user explicitly asks for a review.
  • system: internal maintenance coordination.

Threads hold messages, status, outcomes, related sync runs, related jobs, workspace links, draft PR links, and ignored upstream decisions.

Opening a thread opens its workspace when a run exists. The workspace is the primary surface for that thread: agent messages, tool activity, file edits, manual edits, diffs, commands, and draft PR actions all happen there. Legacy job URLs under /spoons/[spoonId]/agent/[jobId] are kept for compatibility, but normal navigation targets /threads/[threadId].

Maintenance decisions

Spoon's maintenance policy is intentionally conservative:

Situation Default action
No fork-only commits and upstream is ahead Auto-sync
Fork-only commits and upstream is ahead Create a maintenance thread
Merge conflicts Open or continue a workspace thread
Irrelevant upstream changes Record an intentional ignore decision
Agent/code changes Open a draft PR

The goal is to keep forks close without hiding risk or skipping review when custom work exists.

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 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:

  • browse repository files
  • edit files in a browser editor
  • use optional Vim keybindings
  • resize the agent thread panel on desktop
  • inspect diffs
  • send thread messages to the agent
  • run configured commands
  • use an interactive terminal (xterm.js → box PTY; see 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 the box.

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:

scripts/build-agent-images
bun smoke:agent-container
bun dev:next:worker
bun dev:next:worker:staging

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:

SPOON_AGENT_CONTAINER_RUNTIME=podman
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:

git.gbrown.org/gib/spoon-next:latest
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 spoon-agent-job image is the box image — the per-user Fedora dev box the worker execs into (it is not launched fresh per job). Point the worker at it with:

SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"

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 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 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.

Useful production checks:

docker login git.gbrown.org
docker pull git.gbrown.org/gib/spoon-agent-worker:latest
docker pull git.gbrown.org/gib/spoon-agent-job:latest
docker logs --tail=200 spoon-agent-worker
curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
  http://spoon-agent-worker:3921/health

Deployment readiness checklist:

  1. Production Convex env has SPOON_WORKER_TOKEN, SPOON_ENCRYPTION_KEY, 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; 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 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 state, use the workspace recovery panel or Settings -> Worker cleanup.
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 <CONVEX_SITE_URL>/webhooks/github and set GITHUB_APP_WEBHOOK_SECRET; the endpoint fails closed (HTTP 503) when no secret is configured and rejects any payload whose x-hub-signature-256 does not verify.

  • push → a targeted drift refresh for every Spoon tracking that repository.
  • installation / installation_repositories → the matching connection's status flips to revoked (deleted/suspended), active (created/unsuspended), or otherwise needs_reauth, surfaced in Settings -> Integrations.

The hourly refreshDueSpoons cron remains a fallback, so drift still refreshes even if a webhook is missed.

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.

Architecture

Workspace layout
.
├── apps
│   ├── next              # Next.js 16 web app and primary Spoon UI
│   ├── agent-worker      # Optional OpenCode workspace / draft PR worker
│   └── expo              # Expo companion app scaffold
├── packages
│   ├── backend           # Convex backend package
│   │   └── convex        # Schema, functions, auth, HTTP routes
│   └── ui                # Shared shadcn-based UI components
├── tools                 # Shared lint, format, Tailwind, TS, Vitest config
├── docker                # Compose files and worker/job Dockerfiles
└── scripts               # Env, Convex, codegen, database, and CI helpers
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 (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
/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

The Convex httpAction POST /webhooks/github (mounted at <CONVEX_SITE_URL>/webhooks/github) handles GitHub App webhooks. Legacy /updates and /agents routes redirect into /threads.

Mobile App

Current Expo scope

apps/expo is the mobile Spoon client. It is designed to mirror the core web product without exposing worker internals or trying to turn a phone into the primary code-editing surface.

The mobile app currently supports:

  • password, GitHub, and Authentik sign-in
  • Dashboard, Spoons, Threads, Workspace Review, and Settings tabs/screens
  • manual Spoon creation and GitHub-assisted repository tracking
  • Spoon detail views for overview, upstream commits, fork-only commits, PRs, threads, settings, clone URLs, and additional remotes
  • Spoon maintenance settings, agent settings, encrypted secrets, and bulk .env paste import
  • thread list/detail, message composer, resolve/cancel actions, and workspace review links
  • GitHub integration status and repository listing
  • AI provider profile management, including Codex auth JSON and API-key providers
  • read-only workspace review for job status, messages, diffs, events, artifacts, and draft PR links

The mobile app intentionally does not currently support:

  • live workspace file browsing/editing
  • mobile command execution
  • direct mobile calls to the agent worker HTTP API
  • mobile access to worker/container tokens
  • long-running app preview stacks
  • production app-store/EAS release flow

Mobile workspace editing is deferred until worker authorization and mobile editor UX are designed explicitly. For now, the phone is a strong review and control surface; the browser remains the code workspace.

Expo validation

Useful mobile checks:

bun --filter @spoon/expo lint
bun --filter @spoon/expo typecheck
bun --filter @spoon/expo test:unit
bun --filter @spoon/expo test:component

The Expo unit tests cover pure utilities such as .env parsing and formatting. The component tests use a lightweight React Native mock layer to exercise shared mobile controls, higher-value forms, and route smoke renders without booting a native simulator.

Environment Reference

This project is currently private, so this section is a reference for what the application expects rather than public setup documentation.

Local Infisical account selection

Local dev and staging commands export secrets through Infisical. Spoon runs scripts/infisical-account ensure from scripts/export-env before exporting so machines logged into multiple Infisical accounts do not accidentally use the wrong organization.

If your machine has only one local Infisical account, no extra setup is needed. If it has multiple accounts, create this ignored local file:

mkdir -p .local
printf "INFISICAL_EMAIL=me@gbrown.org\n" > .local/infisical.env

Log into each needed account once with infisical login. You can inspect local profiles without printing tokens:

jq '.loggedInUsers[] | {email, domain}' ~/.infisical/infisical-config.json

.local/infisical.env supports only INFISICAL_EMAIL=... and must not be committed. CI is unchanged; it uses injected environment files/secrets and must not call Infisical.

Public Next variables
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
Auth and email
Variable Used for
SITE_URL Convex Auth site URL
JWT_PRIVATE_KEY Convex Auth signing key
JWKS Convex Auth JWKS
AUTH_AUTHENTIK_ID Authentik OAuth client ID
AUTH_AUTHENTIK_SECRET Authentik OAuth client secret
AUTH_AUTHENTIK_ISSUER Authentik issuer URL
AUTH_GITHUB_ID GitHub OAuth client ID
AUTH_GITHUB_SECRET GitHub OAuth client secret
USESEND_API_KEY UseSend API key
USESEND_URL UseSend API URL
USESEND_FROM_EMAIL Transactional email sender
GitHub App
Variable Used for
GITHUB_APP_ID GitHub App ID
GITHUB_APP_CLIENT_ID GitHub App OAuth client ID
GITHUB_APP_CLIENT_SECRET GitHub App OAuth client secret
GITHUB_APP_PRIVATE_KEY GitHub App PEM private key
GITHUB_APP_WEBHOOK_SECRET GitHub webhook verification secret
GITHUB_APP_SLUG GitHub App slug
GITHUB_APP_INSTALLATION_ID Default/local installation ID
GITHUB_APP_OWNER Default/local installation owner
Convex, storage, and runtime
Variable Used for
CONVEX_SELF_HOSTED_URL Self-hosted Convex API URL
CONVEX_SELF_HOSTED_ADMIN_KEY Admin key for deploying/syncing Convex
CONVEX_CLOUD_ORIGIN Convex backend origin
CONVEX_SITE_ORIGIN Convex site-function origin
CONVEX_SITE_URL Site URL seen by Convex Auth
POSTGRES_URL Convex storage database URL
SPOON_ENCRYPTION_KEY Encryption key for stored secrets/provider auth
SPOON_WORKER_TOKEN Worker token for Convex worker mutations
SPOON_AGENT_WORKER_URL Internal worker HTTP URL used by Next
SPOON_AGENT_WORKER_HTTP_PORT Worker HTTP port
SPOON_AGENT_WORKER_INTERNAL_TOKEN Server-only token for Next-to-worker proxy
SPOON_AGENT_TERMINAL_SECRET HMAC secret for terminal/box tokens (falls back to the internal/worker token)
SPOON_AGENT_JOB_IMAGE Per-user box (Fedora) image the worker execs into
SPOON_AGENT_RUNTIME Runtime mode, currently Docker/Podman-oriented
SPOON_AGENT_CONTAINER_RUNTIME Container CLI used by worker, docker/podman
SPOON_AGENT_BOX_IDLE_MS Idle time before a per-user box is reaped (default 1800000)
SPOON_AGENT_MAX_CONCURRENT_JOBS Worker concurrency limit
SPOON_AGENT_JOB_TIMEOUT_MS Agent turn timeout
SPOON_AGENT_WORKDIR Worker work directory; per-user homes live under homes/{username}
SPOON_AGENT_HOST_WORKDIR Host path matching SPOON_AGENT_WORKDIR when the worker runs in Docker and controls the host Docker socket
SPOON_AGENT_NETWORK Optional box container network
Deployment and observability
Variable Used for
NODE_ENV Runtime environment
SENTRY_AUTH_TOKEN Sentry source map/upload auth
REDACT_LOGS_TO_CLIENT Convex log redaction setting
DISABLE_BEACON Self-hosted Convex beacon setting
DO_NOT_REQUIRE_SSL Self-hosted Convex SSL behavior
CI_ENV_FILE CI-provided env file path

Current Status

Implemented
  • Thread-first Next.js product shell
  • GitHub App connection and fork creation foundation
  • GitHub drift refresh, commit cache, PR cache, and sync-run history
  • Effective drift and ignored upstream change records
  • Global Threads page and Spoon-scoped Threads tab
  • Persistent per-user box the worker execs into for agent turns, terminal, and commands
  • Three agent runtimes (Codex, OpenCode, Claude Code) behind one adapter, with queue-time runtime validation
  • My Machine surface (box status/start/stop/restart, terminal, home browser)
  • Interactive workspace terminal (xterm.js → box PTY)
  • Per-user dotfiles overlay + optional dotfiles repo
  • Signature-verified GitHub webhook with hourly cron fallback
  • In-app + email notifications with per-user preferences
  • Monaco editor with optional Vim mode
  • Diff viewer, command panel, worker logs, and artifacts
  • Encrypted Spoon secrets and bulk .env import
  • Encrypted AI provider profiles, including Codex/Anthropic auth JSON and API-key provider support
  • Authentik, GitHub, and password auth through Convex Auth
  • Self-hosted Convex/Postgres deployment model
Intentionally not done yet
  • Autonomous merging for custom/diverged forks
  • Non-GitHub provider automation
  • Pushing agent branches to additional remotes
  • Long-running preview stacks for arbitrary forked projects
  • Direct browser access to worker containers
  • Public self-hosting setup documentation
  • Production mobile release flow

Notes

Spoon is built for a very specific maintenance problem: "I want to fork this project, but I do not want to permanently become its maintenance team."

The current product direction is to make that maintenance visible, threaded, reviewable, and increasingly automated where it is safe. Clean forks can stay close automatically. Custom forks get context, workspace help, and draft PRs.

S
Description
a fork management dashboard
Readme
6.7 MiB
Languages
TypeScript 96.7%
Shell 2%
JavaScript 0.5%
CSS 0.4%
Dockerfile 0.3%