initial agent-mgr: app builder platform MVP

Go API server + Preact UI + Claude Code adapter.
- App-centric model (ideas, not repos)
- AgentProvider interface for multi-agent support
- K8s pod lifecycle for sandboxed agent sessions
- Gitea integration (create repos, push branches)
- WebSocket streaming for live session output
- Woodpecker CI/CD pipelines (kaniko build + kubectl deploy)
This commit is contained in:
Steven Hooker
2026-02-18 15:56:32 +01:00
commit e5b07cc1d8
39 changed files with 3120 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
bin/
ui/node_modules/
ui/dist/index.js
*.secret.tfvars
.env

38
.woodpecker/build.yaml Normal file
View File

@@ -0,0 +1,38 @@
variables:
- &kaniko_settings
registry: git.asp.now
username:
from_secret: gitea_user
password:
from_secret: gitea_token
when:
- event: [push, tag]
branch: main
steps:
- name: build-agent-mgr
image: woodpeckerci/plugin-kaniko
settings:
<<: *kaniko_settings
repo: git.asp.now/platform/agent-mgr
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
dockerfile: docker/Dockerfile
context: .
cache: true
cache_repo: git.asp.now/platform/agent-mgr/cache
- name: build-claude-runner
image: woodpeckerci/plugin-kaniko
settings:
<<: *kaniko_settings
repo: git.asp.now/platform/claude-code-runner
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
dockerfile: docker/Dockerfile.claude-runner
context: docker/
cache: true
cache_repo: git.asp.now/platform/claude-code-runner/cache

16
.woodpecker/deploy.yaml Normal file
View File

@@ -0,0 +1,16 @@
depends_on:
- build
when:
- event: [push, tag]
branch: main
steps:
- name: deploy
image: bitnami/kubectl:latest
commands:
- kubectl -n agent-mgr set image deployment/agent-mgr agent-mgr=git.asp.now/platform/agent-mgr:${CI_COMMIT_SHA:0:8}
- kubectl -n agent-mgr rollout status deployment/agent-mgr --timeout=120s
backend_options:
kubernetes:
serviceAccountName: woodpecker-deployer

32
Makefile Normal file
View File

@@ -0,0 +1,32 @@
.PHONY: build build-ui build-go run clean docker docker-runner
# Build UI, then Go binary
build: build-ui build-go
build-ui:
cd ui && npm ci --ignore-scripts && npm run build
build-go:
go build -o bin/agent-mgr ./cmd/agent-mgr
run: build
./bin/agent-mgr
clean:
rm -rf bin/ ui/dist/index.js ui/node_modules
# Docker images
docker:
docker build -f docker/Dockerfile -t agent-mgr .
docker-runner:
docker build -f docker/Dockerfile.claude-runner -t claude-code-runner docker/
# Development: run with local kubeconfig
dev: build-ui
KUBECONFIG=~/.kube/n2d-1-config \
DATABASE_URL=postgres://localhost:5432/agent_mgr_db?sslmode=disable \
GITEA_URL=https://git.asp.now \
GITEA_TOKEN= \
GITEA_BOT_USER=agent-mgr-bot \
go run ./cmd/agent-mgr

129
cmd/agent-mgr/main.go Normal file
View File

@@ -0,0 +1,129 @@
package main
import (
"context"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/agentsphere/agent-mgr/internal/api"
"github.com/agentsphere/agent-mgr/internal/app"
"github.com/agentsphere/agent-mgr/internal/events"
"github.com/agentsphere/agent-mgr/internal/gitea"
"github.com/agentsphere/agent-mgr/internal/provider"
"github.com/agentsphere/agent-mgr/internal/provider/claudecode"
"github.com/agentsphere/agent-mgr/internal/store"
"github.com/agentsphere/agent-mgr/ui"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
// Config from env
listenAddr := envOr("LISTEN_ADDR", ":8080")
databaseURL := envOr("DATABASE_URL", "postgres://localhost:5432/agent_mgr_db?sslmode=disable")
giteaURL := envOr("GITEA_URL", "https://git.asp.now")
giteaToken := envOr("GITEA_TOKEN", "")
giteaBotUser := envOr("GITEA_BOT_USER", "agent-mgr-bot")
valkeyAddr := envOr("VALKEY_ADDR", "valkey.valkey.svc.cluster.local:6379")
valkeyPassword := envOr("VALKEY_PASSWORD", "")
// Postgres
db, err := store.New(ctx, databaseURL)
if err != nil {
log.Error("failed to connect to postgres", "err", err)
os.Exit(1)
}
defer db.Close()
if err := db.Migrate(ctx); err != nil {
log.Error("failed to run migrations", "err", err)
os.Exit(1)
}
// Kubernetes client
restConfig, err := kubeConfig()
if err != nil {
log.Error("failed to get kube config", "err", err)
os.Exit(1)
}
k8s, err := kubernetes.NewForConfig(restConfig)
if err != nil {
log.Error("failed to create kube client", "err", err)
os.Exit(1)
}
// Providers
registry := provider.NewRegistry()
registry.Register(claudecode.New(k8s, restConfig))
// Gitea
giteaClient := gitea.New(giteaURL, giteaToken, giteaBotUser)
// Events
bus := events.NewBus(valkeyAddr, valkeyPassword)
defer bus.Close()
// App service
appSvc := app.NewService(db, giteaClient, registry, log)
// Static UI files
var staticFS fs.FS
uiFS := ui.StaticFS()
if uiFS != nil {
staticFS = uiFS
}
// HTTP server
srv := api.NewServer(db, appSvc, registry, bus, log)
httpSrv := &http.Server{
Addr: listenAddr,
Handler: srv.Handler(staticFS),
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Info("starting server", "addr", listenAddr)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("server error", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
log.Info("shutting down...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
httpSrv.Shutdown(shutdownCtx)
}
func kubeConfig() (*rest.Config, error) {
// In-cluster first
cfg, err := rest.InClusterConfig()
if err == nil {
return cfg, nil
}
// Fallback to kubeconfig
kubeconfigPath := envOr("KUBECONFIG", os.Getenv("HOME")+"/.kube/config")
return clientcmd.BuildConfigFromFlags("", kubeconfigPath)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

23
docker/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# Stage 1: Build UI
FROM node:22-slim AS ui-builder
WORKDIR /ui
COPY ui/package.json ui/package-lock.json* ./
RUN npm ci --ignore-scripts
COPY ui/ .
RUN npm run build
# Stage 2: Build Go binary
FROM golang:1.23-alpine AS go-builder
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=ui-builder /ui/dist ./ui/dist
RUN CGO_ENABLED=0 go build -o /agent-mgr ./cmd/agent-mgr
# Stage 3: Runtime
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=go-builder /agent-mgr /agent-mgr
EXPOSE 8080
ENTRYPOINT ["/agent-mgr"]

View File

@@ -0,0 +1,17 @@
FROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
RUN useradd -m -s /bin/bash agent
USER agent
WORKDIR /workspace
COPY --chown=agent:agent entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

16
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
set -euo pipefail
cd /workspace
# Run claude interactively, streaming JSON output
claude --output-format stream-json "$@"
EXIT_CODE=$?
# After claude exits, push whatever it did
if [ -n "$(git status --porcelain)" ]; then
git add -A
git commit -m "agent session ${SESSION_ID:-unknown}"
git push origin "agent/${SESSION_ID:-unknown}"
fi
exit $EXIT_CODE

62
go.mod Normal file
View File

@@ -0,0 +1,62 @@
module github.com/agentsphere/agent-mgr
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.8.0
github.com/redis/go-redis/v9 v9.18.0
k8s.io/api v0.35.1
k8s.io/apimachinery v0.35.1
k8s.io/client-go v0.35.1
nhooyr.io/websocket v1.8.17
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)

166
go.sum Normal file
View File

@@ -0,0 +1,166 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q=
k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM=
k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU=
k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM=
k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

105
internal/api/apps.go Normal file
View File

@@ -0,0 +1,105 @@
package api
import (
"encoding/json"
"net/http"
"github.com/agentsphere/agent-mgr/internal/store"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type createAppRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Provider string `json:"provider,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
}
func (s *Server) createApp(w http.ResponseWriter, r *http.Request) {
var req createAppRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Name == "" && req.Description == "" {
http.Error(w, `{"error":"name or description required"}`, http.StatusBadRequest)
return
}
if req.Name == "" {
// Auto-generate name from first few words of description
name := req.Description
if len(name) > 40 {
name = name[:40]
}
req.Name = name
}
app, sess, err := s.app.CreateApp(r.Context(), req.Name, req.Description, req.Provider, req.Config)
if err != nil {
s.log.Error("create app failed", "err", err)
http.Error(w, `{"error":"failed to create app"}`, http.StatusInternalServerError)
return
}
resp := map[string]any{"app": app}
if sess != nil {
resp["session"] = sess
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
func (s *Server) listApps(w http.ResponseWriter, r *http.Request) {
apps, err := s.store.ListApps(r.Context())
if err != nil {
s.log.Error("list apps failed", "err", err)
http.Error(w, `{"error":"failed to list apps"}`, http.StatusInternalServerError)
return
}
if apps == nil {
apps = []store.App{}
}
json.NewEncoder(w).Encode(map[string]any{"apps": apps})
}
func (s *Server) getApp(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "appID"))
if err != nil {
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
return
}
app, err := s.store.GetApp(r.Context(), id)
if err != nil {
http.Error(w, `{"error":"failed to get app"}`, http.StatusInternalServerError)
return
}
if app == nil {
http.Error(w, `{"error":"app not found"}`, http.StatusNotFound)
return
}
sessions, _ := s.store.ListSessionsByApp(r.Context(), id)
if sessions == nil {
sessions = []store.Session{}
}
json.NewEncoder(w).Encode(map[string]any{"app": app, "sessions": sessions})
}
func (s *Server) deleteApp(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "appID"))
if err != nil {
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
return
}
if err := s.store.DeleteApp(r.Context(), id); err != nil {
http.Error(w, `{"error":"failed to delete app"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

77
internal/api/router.go Normal file
View File

@@ -0,0 +1,77 @@
package api
import (
"io/fs"
"log/slog"
"net/http"
"github.com/agentsphere/agent-mgr/internal/app"
"github.com/agentsphere/agent-mgr/internal/events"
"github.com/agentsphere/agent-mgr/internal/provider"
"github.com/agentsphere/agent-mgr/internal/store"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type Server struct {
store *store.Store
app *app.Service
registry *provider.Registry
events *events.Bus
log *slog.Logger
}
func NewServer(st *store.Store, appSvc *app.Service, reg *provider.Registry, bus *events.Bus, log *slog.Logger) *Server {
return &Server{store: st, app: appSvc, registry: reg, events: bus, log: log}
}
func (s *Server) Handler(staticFS fs.FS) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(middleware.Compress(5))
// API routes
r.Route("/api/v1", func(r chi.Router) {
r.Use(middleware.SetHeader("Content-Type", "application/json"))
r.Post("/apps", s.createApp)
r.Get("/apps", s.listApps)
r.Get("/apps/{appID}", s.getApp)
r.Delete("/apps/{appID}", s.deleteApp)
r.Post("/apps/{appID}/sessions", s.createSession)
r.Get("/apps/{appID}/sessions", s.listSessions)
r.Get("/sessions/{sessionID}", s.getSession)
r.Post("/sessions/{sessionID}/stop", s.stopSession)
r.Post("/sessions/{sessionID}/message", s.sendMessage)
r.Get("/sessions/{sessionID}/ws", s.streamSession)
r.Get("/providers", s.listProviders)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
})
})
// Serve SPA for all other routes
if staticFS != nil {
fileServer := http.FileServer(http.FS(staticFS))
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
// Try to serve the file; if not found, serve index.html (SPA routing)
path := r.URL.Path
f, err := staticFS.Open(path[1:]) // strip leading /
if err != nil {
// Serve index.html for SPA client-side routing
r.URL.Path = "/"
} else {
f.Close()
}
fileServer.ServeHTTP(w, r)
})
}
return r
}

136
internal/api/sessions.go Normal file
View File

@@ -0,0 +1,136 @@
package api
import (
"encoding/json"
"net/http"
"github.com/agentsphere/agent-mgr/internal/store"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type createSessionRequest struct {
Prompt string `json:"prompt"`
Provider string `json:"provider,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
}
type sendMessageRequest struct {
Message string `json:"message"`
}
func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
appID, err := uuid.Parse(chi.URLParam(r, "appID"))
if err != nil {
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
return
}
var req createSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Prompt == "" {
http.Error(w, `{"error":"prompt required"}`, http.StatusBadRequest)
return
}
app, err := s.store.GetApp(r.Context(), appID)
if err != nil || app == nil {
http.Error(w, `{"error":"app not found"}`, http.StatusNotFound)
return
}
sess, err := s.app.StartSession(r.Context(), app, req.Prompt, req.Provider, req.Config)
if err != nil {
s.log.Error("create session failed", "err", err)
http.Error(w, `{"error":"failed to create session"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{"session": sess})
}
func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) {
appID, err := uuid.Parse(chi.URLParam(r, "appID"))
if err != nil {
http.Error(w, `{"error":"invalid app id"}`, http.StatusBadRequest)
return
}
sessions, err := s.store.ListSessionsByApp(r.Context(), appID)
if err != nil {
http.Error(w, `{"error":"failed to list sessions"}`, http.StatusInternalServerError)
return
}
if sessions == nil {
sessions = []store.Session{}
}
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
}
func (s *Server) getSession(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "sessionID"))
if err != nil {
http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest)
return
}
sess, err := s.store.GetSession(r.Context(), id)
if err != nil || sess == nil {
http.Error(w, `{"error":"session not found"}`, http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]any{"session": sess})
}
func (s *Server) stopSession(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "sessionID"))
if err != nil {
http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest)
return
}
if err := s.app.StopSession(r.Context(), id); err != nil {
s.log.Error("stop session failed", "err", err)
http.Error(w, `{"error":"failed to stop session"}`, http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]any{"status": "stopped"})
}
func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "sessionID"))
if err != nil {
http.Error(w, `{"error":"invalid session id"}`, http.StatusBadRequest)
return
}
var req sendMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Message == "" {
http.Error(w, `{"error":"message required"}`, http.StatusBadRequest)
return
}
if err := s.app.SendMessage(r.Context(), id, req.Message); err != nil {
s.log.Error("send message failed", "err", err)
http.Error(w, `{"error":"failed to send message"}`, http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]any{"status": "sent"})
}
func (s *Server) listProviders(w http.ResponseWriter, r *http.Request) {
infos := s.registry.List()
json.NewEncoder(w).Encode(map[string]any{"providers": infos})
}

74
internal/api/ws.go Normal file
View File

@@ -0,0 +1,74 @@
package api
import (
"bufio"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"nhooyr.io/websocket"
"github.com/agentsphere/agent-mgr/internal/provider"
"github.com/agentsphere/agent-mgr/internal/provider/claudecode"
)
func (s *Server) streamSession(w http.ResponseWriter, r *http.Request) {
sessID, err := uuid.Parse(chi.URLParam(r, "sessionID"))
if err != nil {
http.Error(w, "invalid session id", http.StatusBadRequest)
return
}
sess, err := s.store.GetSession(r.Context(), sessID)
if err != nil || sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
p, err := s.registry.Get(sess.Provider)
if err != nil {
http.Error(w, "provider not found", http.StatusInternalServerError)
return
}
handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true, // Traefik handles TLS/origin
})
if err != nil {
s.log.Error("websocket accept failed", "err", err)
return
}
defer conn.CloseNow()
ctx := conn.CloseRead(r.Context())
stream, err := p.StreamOutput(ctx, handle)
if err != nil {
s.log.Error("stream output failed", "err", err, "pod", sess.PodName)
conn.Close(websocket.StatusInternalError, "failed to stream logs")
return
}
defer stream.Close()
tracker := claudecode.NewProgressTracker()
scanner := bufio.NewScanner(stream)
scanner.Buffer(make([]byte, 64*1024), 64*1024)
for scanner.Scan() {
line := scanner.Text()
tracker.ProcessLine(line)
if err := conn.Write(ctx, websocket.MessageText, []byte(line)); err != nil {
break
}
}
if err := scanner.Err(); err != nil && err != io.EOF {
s.log.Warn("stream scanner error", "err", err)
}
conn.Close(websocket.StatusNormalClosure, "stream ended")
}

170
internal/app/service.go Normal file
View File

@@ -0,0 +1,170 @@
package app
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strings"
"github.com/agentsphere/agent-mgr/internal/gitea"
"github.com/agentsphere/agent-mgr/internal/provider"
"github.com/agentsphere/agent-mgr/internal/store"
"github.com/google/uuid"
)
type Service struct {
store *store.Store
gitea *gitea.Client
registry *provider.Registry
log *slog.Logger
}
func NewService(s *store.Store, g *gitea.Client, r *provider.Registry, log *slog.Logger) *Service {
return &Service{store: s, gitea: g, registry: r, log: log}
}
// CreateApp creates an app record, a Gitea repo, and starts the first build session.
func (svc *Service) CreateApp(ctx context.Context, name, description, providerName string, providerConfig json.RawMessage) (*store.App, *store.Session, error) {
repoName := slugify(name)
app, err := svc.store.CreateApp(ctx, name, description)
if err != nil {
return nil, nil, fmt.Errorf("create app: %w", err)
}
repo, err := svc.gitea.CreateRepo(ctx, repoName, description)
if err != nil {
_ = svc.store.DeleteApp(ctx, app.ID)
return nil, nil, fmt.Errorf("create gitea repo: %w", err)
}
if err := svc.store.UpdateAppRepo(ctx, app.ID, repo.FullName[:strings.Index(repo.FullName, "/")], repo.Name); err != nil {
return nil, nil, fmt.Errorf("update app repo: %w", err)
}
app.RepoOwner = repo.FullName[:strings.Index(repo.FullName, "/")]
app.RepoName = repo.Name
sess, err := svc.StartSession(ctx, app, description, providerName, providerConfig)
if err != nil {
return app, nil, fmt.Errorf("start initial session: %w", err)
}
return app, sess, nil
}
// StartSession creates a new session on an existing app and spawns the agent pod.
func (svc *Service) StartSession(ctx context.Context, app *store.App, prompt, providerName string, providerConfig json.RawMessage) (*store.Session, error) {
if providerName == "" {
providerName = "claude-code"
}
p, err := svc.registry.Get(providerName)
if err != nil {
return nil, err
}
sessID := uuid.New()
branch := fmt.Sprintf("agent/%s", sessID.String()[:8])
configBytes := providerConfig
if configBytes == nil {
configBytes = json.RawMessage(`{}`)
}
sess, err := svc.store.CreateSession(ctx, app.ID, providerName, prompt, branch, configBytes)
if err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
// Override the auto-generated ID with our pre-generated one for branch consistency
// (the DB generates its own UUID, but the branch name uses sessID)
// We'll use the DB-generated ID going forward
branch = fmt.Sprintf("agent/%s", sess.ID.String()[:8])
_ = svc.store.UpdateSessionStatus(ctx, sess.ID, "pending")
sess.Branch = branch
cloneURL := svc.gitea.AuthCloneURL(app.RepoOwner, app.RepoName)
systemPrompt := fmt.Sprintf("You are building an app called %q. The user's idea: %s\nCreate a complete, working application. When done, ensure all files are committed.", app.Name, app.Description)
fullPrompt := prompt
if prompt == app.Description {
fullPrompt = systemPrompt
}
handle, err := p.CreateSession(ctx, provider.SessionConfig{
SessionID: sess.ID,
AppName: app.Name,
RepoClone: cloneURL,
Branch: branch,
Prompt: fullPrompt,
Provider: configBytes,
})
if err != nil {
_ = svc.store.UpdateSessionStatus(ctx, sess.ID, "failed")
return nil, fmt.Errorf("create agent session: %w", err)
}
_ = svc.store.UpdateSessionPod(ctx, sess.ID, handle.PodName)
_ = svc.store.UpdateSessionStatus(ctx, sess.ID, "running")
_ = svc.store.UpdateAppStatus(ctx, app.ID, "building")
sess.PodName = handle.PodName
sess.Status = "running"
svc.log.Info("session started", "app", app.Name, "session", sess.ID, "pod", handle.PodName)
return sess, nil
}
func (svc *Service) StopSession(ctx context.Context, sessID uuid.UUID) error {
sess, err := svc.store.GetSession(ctx, sessID)
if err != nil || sess == nil {
return fmt.Errorf("session not found: %w", err)
}
p, err := svc.registry.Get(sess.Provider)
if err != nil {
return err
}
handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName}
if err := p.StopSession(ctx, handle); err != nil {
svc.log.Warn("failed to stop pod", "pod", sess.PodName, "err", err)
}
_ = svc.store.UpdateSessionStatus(ctx, sessID, "stopped")
return nil
}
func (svc *Service) SendMessage(ctx context.Context, sessID uuid.UUID, msg string) error {
sess, err := svc.store.GetSession(ctx, sessID)
if err != nil || sess == nil {
return fmt.Errorf("session not found")
}
if sess.Status != "running" {
return fmt.Errorf("session not running (status: %s)", sess.Status)
}
p, err := svc.registry.Get(sess.Provider)
if err != nil {
return err
}
handle := &provider.SessionHandle{SessionID: sess.ID, PodName: sess.PodName}
return p.SendMessage(ctx, handle, msg)
}
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9-]+`)
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = nonAlphaNum.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > 50 {
s = s[:50]
}
if s == "" {
s = "app"
}
return s
}

71
internal/events/valkey.go Normal file
View File

@@ -0,0 +1,71 @@
package events
import (
"context"
"encoding/json"
"fmt"
"github.com/redis/go-redis/v9"
)
const channel = "agent-mgr:events"
type Bus struct {
rdb *redis.Client
}
type Event struct {
Type string `json:"type"` // "session.started", "session.completed", "session.failed", "session.stopped", "app.status"
SessionID string `json:"session_id,omitempty"`
AppID string `json:"app_id,omitempty"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
}
func NewBus(addr, password string) *Bus {
return &Bus{
rdb: redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
}),
}
}
func (b *Bus) Publish(ctx context.Context, evt Event) error {
data, err := json.Marshal(evt)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
return b.rdb.Publish(ctx, channel, data).Err()
}
func (b *Bus) Subscribe(ctx context.Context) (<-chan Event, error) {
sub := b.rdb.Subscribe(ctx, channel)
ch := make(chan Event, 64)
go func() {
defer close(ch)
defer sub.Close()
for {
msg, err := sub.ReceiveMessage(ctx)
if err != nil {
return
}
var evt Event
if err := json.Unmarshal([]byte(msg.Payload), &evt); err != nil {
continue
}
select {
case ch <- evt:
case <-ctx.Done():
return
}
}
}()
return ch, nil
}
func (b *Bus) Close() error {
return b.rdb.Close()
}

142
internal/gitea/client.go Normal file
View File

@@ -0,0 +1,142 @@
package gitea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type Client struct {
baseURL string
token string
botUser string
http *http.Client
}
func New(baseURL, token, botUser string) *Client {
return &Client{
baseURL: baseURL,
token: token,
botUser: botUser,
http: &http.Client{},
}
}
type Repo struct {
ID int64 `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
CloneURL string `json:"clone_url"`
HTMLURL string `json:"html_url"`
Empty bool `json:"empty"`
}
type Branch struct {
Name string `json:"name"`
}
func (c *Client) CreateRepo(ctx context.Context, name, description string) (*Repo, error) {
body, _ := json.Marshal(map[string]any{
"name": name,
"description": description,
"auto_init": true,
"default_branch": "main",
"private": false,
})
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/user/repos", bytes.NewReader(body))
if err != nil {
return nil, err
}
c.setHeaders(req)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("create repo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("create repo: status %d: %s", resp.StatusCode, b)
}
var repo Repo
if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
return nil, fmt.Errorf("decode repo: %w", err)
}
return &repo, nil
}
func (c *Client) ListRepos(ctx context.Context) ([]Repo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/user/repos?limit=50", nil)
if err != nil {
return nil, err
}
c.setHeaders(req)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("list repos: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("list repos: status %d: %s", resp.StatusCode, b)
}
var repos []Repo
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
return nil, fmt.Errorf("decode repos: %w", err)
}
return repos, nil
}
func (c *Client) ListBranches(ctx context.Context, owner, repo string) ([]Branch, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches", c.baseURL, owner, repo)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
c.setHeaders(req)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("list branches: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("list branches: status %d: %s", resp.StatusCode, b)
}
var branches []Branch
if err := json.NewDecoder(resp.Body).Decode(&branches); err != nil {
return nil, fmt.Errorf("decode branches: %w", err)
}
return branches, nil
}
// AuthCloneURL returns a clone URL with embedded bot credentials.
func (c *Client) AuthCloneURL(owner, repo string) string {
// Strip protocol from base URL
base := c.baseURL
if len(base) > 8 && base[:8] == "https://" {
base = base[8:]
} else if len(base) > 7 && base[:7] == "http://" {
base = base[7:]
}
return fmt.Sprintf("https://%s:%s@%s/%s/%s.git", c.botUser, c.token, base, owner, repo)
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "token "+c.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
}

View File

@@ -0,0 +1,127 @@
package claudecode
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"github.com/agentsphere/agent-mgr/internal/provider"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
)
type Config struct {
Model string `json:"model,omitempty"`
MaxTurns int `json:"max_turns,omitempty"`
}
type Provider struct {
client kubernetes.Interface
restConfig *rest.Config
}
func New(client kubernetes.Interface, restConfig *rest.Config) *Provider {
return &Provider{client: client, restConfig: restConfig}
}
func (p *Provider) Info() provider.Info {
return provider.Info{
Name: "claude-code",
DisplayName: "Claude Code",
Description: "Anthropic Claude Code CLI agent — builds full applications from natural language",
Capabilities: []provider.Capability{
{Name: "create-app", Description: "Create new applications from scratch"},
{Name: "edit-code", Description: "Modify existing codebases"},
{Name: "interactive", Description: "Supports follow-up messages during a session"},
},
ConfigSchema: json.RawMessage(`{
"type": "object",
"properties": {
"model": {"type": "string", "description": "Claude model to use", "default": ""},
"max_turns": {"type": "integer", "description": "Maximum agentic turns", "default": 0}
}
}`),
}
}
func (p *Provider) CreateSession(ctx context.Context, cfg provider.SessionConfig) (*provider.SessionHandle, error) {
var opts *Config
if len(cfg.Provider) > 0 {
opts = &Config{}
if err := json.Unmarshal(cfg.Provider, opts); err != nil {
return nil, fmt.Errorf("parse claude-code config: %w", err)
}
}
pod := buildPod(cfg, opts)
created, err := p.client.CoreV1().Pods(Namespace).Create(ctx, pod, metav1.CreateOptions{})
if err != nil {
return nil, fmt.Errorf("create pod: %w", err)
}
return &provider.SessionHandle{
SessionID: cfg.SessionID,
PodName: created.Name,
}, nil
}
func (p *Provider) StopSession(ctx context.Context, handle *provider.SessionHandle) error {
return p.client.CoreV1().Pods(Namespace).Delete(ctx, handle.PodName, metav1.DeleteOptions{})
}
func (p *Provider) SendMessage(ctx context.Context, handle *provider.SessionHandle, msg string) error {
req := p.client.CoreV1().RESTClient().Post().
Resource("pods").
Name(handle.PodName).
Namespace(Namespace).
SubResource("attach").
VersionedParams(&corev1.PodAttachOptions{
Container: "claude",
Stdin: true,
Stdout: false,
Stderr: false,
}, scheme.ParameterCodec)
exec, err := remotecommand.NewSPDYExecutor(p.restConfig, "POST", req.URL())
if err != nil {
return fmt.Errorf("create attach executor: %w", err)
}
return exec.StreamWithContext(ctx, remotecommand.StreamOptions{
Stdin: bytes.NewReader([]byte(msg + "\n")),
})
}
func (p *Provider) StreamOutput(ctx context.Context, handle *provider.SessionHandle) (io.ReadCloser, error) {
req := p.client.CoreV1().Pods(Namespace).GetLogs(handle.PodName, &corev1.PodLogOptions{
Container: "claude",
Follow: true,
})
return req.Stream(ctx)
}
func (p *Provider) GetStatus(ctx context.Context, handle *provider.SessionHandle) (provider.Status, error) {
pod, err := p.client.CoreV1().Pods(Namespace).Get(ctx, handle.PodName, metav1.GetOptions{})
if err != nil {
return provider.StatusFailed, fmt.Errorf("get pod: %w", err)
}
switch pod.Status.Phase {
case corev1.PodPending:
return provider.StatusPending, nil
case corev1.PodRunning:
return provider.StatusRunning, nil
case corev1.PodSucceeded:
return provider.StatusCompleted, nil
case corev1.PodFailed:
return provider.StatusFailed, nil
default:
return provider.StatusFailed, nil
}
}

View File

@@ -0,0 +1,122 @@
package claudecode
import (
"fmt"
"github.com/agentsphere/agent-mgr/internal/provider"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
Namespace = "agent-mgr"
RunnerImage = "git.asp.now/platform/claude-code-runner:latest"
GitImage = "alpine/git:latest"
)
func buildPod(cfg provider.SessionConfig, opts *Config) *corev1.Pod {
podName := fmt.Sprintf("agent-%s", cfg.SessionID.String()[:8])
branch := cfg.Branch
cloneScript := fmt.Sprintf(`
set -eu
git clone %s /workspace
cd /workspace
git checkout -b %s
git config user.name "agent-mgr-bot"
git config user.email "bot@asp.now"
`, cfg.RepoClone, branch)
claudeArgs := []string{"--output-format", "stream-json", "--permission-mode", "auto-accept-only"}
if opts != nil {
if opts.Model != "" {
claudeArgs = append(claudeArgs, "--model", opts.Model)
}
if opts.MaxTurns > 0 {
claudeArgs = append(claudeArgs, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
}
}
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: podName,
Namespace: Namespace,
Labels: map[string]string{
"app": "agent-session",
"session-id": cfg.SessionID.String(),
},
},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
ServiceAccountName: "agent-runner",
InitContainers: []corev1.Container{
{
Name: "git-clone",
Image: GitImage,
Command: []string{"sh", "-c", cloneScript},
VolumeMounts: []corev1.VolumeMount{
{Name: "workspace", MountPath: "/workspace"},
},
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("50m"),
corev1.ResourceMemory: resource.MustParse("64Mi"),
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("200m"),
corev1.ResourceMemory: resource.MustParse("128Mi"),
},
},
},
},
Containers: []corev1.Container{
{
Name: "claude",
Image: RunnerImage,
Args: claudeArgs,
Stdin: true,
TTY: false,
WorkingDir: "/workspace",
Env: []corev1.EnvVar{
{
Name: "ANTHROPIC_API_KEY",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: "agent-mgr-secrets"},
Key: "anthropic-api-key",
},
},
},
{Name: "SESSION_ID", Value: cfg.SessionID.String()},
},
VolumeMounts: []corev1.VolumeMount{
{Name: "workspace", MountPath: "/workspace"},
},
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("200m"),
corev1.ResourceMemory: resource.MustParse("256Mi"),
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("500m"),
corev1.ResourceMemory: resource.MustParse("512Mi"),
},
},
},
},
Volumes: []corev1.Volume{
{
Name: "workspace",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{
SizeLimit: resourcePtr(resource.MustParse("1Gi")),
},
},
},
},
},
}
}
func resourcePtr(q resource.Quantity) *resource.Quantity { return &q }

View File

@@ -0,0 +1,75 @@
package claudecode
import "strings"
type Milestone struct {
Label string `json:"label"`
Status string `json:"status"` // "done", "active", "pending"
}
type ProgressTracker struct {
filesSeen map[string]bool
milestones []Milestone
}
func NewProgressTracker() *ProgressTracker {
return &ProgressTracker{
filesSeen: make(map[string]bool),
milestones: []Milestone{
{Label: "Setting up project", Status: "active"},
{Label: "Creating data models", Status: "pending"},
{Label: "Building UI components", Status: "pending"},
{Label: "Adding styling and polish", Status: "pending"},
{Label: "Final checks", Status: "pending"},
},
}
}
func (pt *ProgressTracker) Milestones() []Milestone {
cp := make([]Milestone, len(pt.milestones))
copy(cp, pt.milestones)
return cp
}
func (pt *ProgressTracker) ProcessLine(line string) {
lower := strings.ToLower(line)
if strings.Contains(lower, "\"tool\":\"write\"") || strings.Contains(lower, "\"tool\":\"create\"") {
pt.advanceTo(0)
}
if containsAny(lower, "model", "schema", "database", "migration", "struct", "type ") {
pt.advanceTo(1)
}
if containsAny(lower, "component", "page", "route", "template", "html", "tsx", "jsx", "vue") {
pt.advanceTo(2)
}
if containsAny(lower, "css", "style", "tailwind", "theme", "color", "font", "layout") {
pt.advanceTo(3)
}
if containsAny(lower, "test", "readme", "done", "complete", "finish", "final") {
pt.advanceTo(4)
}
}
func (pt *ProgressTracker) advanceTo(idx int) {
for i := range pt.milestones {
if i < idx {
pt.milestones[i].Status = "done"
} else if i == idx {
pt.milestones[i].Status = "active"
}
}
}
func containsAny(s string, substrs ...string) bool {
for _, sub := range substrs {
if strings.Contains(s, sub) {
return true
}
}
return false
}

View File

@@ -0,0 +1,55 @@
package provider
import (
"context"
"encoding/json"
"io"
"github.com/google/uuid"
)
type Status string
const (
StatusPending Status = "pending"
StatusRunning Status = "running"
StatusCompleted Status = "completed"
StatusFailed Status = "failed"
StatusStopped Status = "stopped"
)
type SessionConfig struct {
SessionID uuid.UUID `json:"session_id"`
AppName string `json:"app_name"`
RepoClone string `json:"repo_clone_url"`
Branch string `json:"branch"`
Prompt string `json:"prompt"`
Provider json.RawMessage `json:"provider_config,omitempty"`
}
type SessionHandle struct {
SessionID uuid.UUID
PodName string
}
type Capability struct {
Name string `json:"name"`
Description string `json:"description"`
}
type Info struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Capabilities []Capability `json:"capabilities"`
ConfigSchema json.RawMessage `json:"config_schema,omitempty"`
}
type AgentProvider interface {
Info() Info
CreateSession(ctx context.Context, cfg SessionConfig) (*SessionHandle, error)
StopSession(ctx context.Context, handle *SessionHandle) error
SendMessage(ctx context.Context, handle *SessionHandle, msg string) error
StreamOutput(ctx context.Context, handle *SessionHandle) (io.ReadCloser, error)
GetStatus(ctx context.Context, handle *SessionHandle) (Status, error)
}

View File

@@ -0,0 +1,31 @@
package provider
import "fmt"
type Registry struct {
providers map[string]AgentProvider
}
func NewRegistry() *Registry {
return &Registry{providers: make(map[string]AgentProvider)}
}
func (r *Registry) Register(p AgentProvider) {
r.providers[p.Info().Name] = p
}
func (r *Registry) Get(name string) (AgentProvider, error) {
p, ok := r.providers[name]
if !ok {
return nil, fmt.Errorf("provider %q not found", name)
}
return p, nil
}
func (r *Registry) List() []Info {
infos := make([]Info, 0, len(r.providers))
for _, p := range r.providers {
infos = append(infos, p.Info())
}
return infos
}

View File

@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
repo_owner TEXT NOT NULL DEFAULT '',
repo_name TEXT NOT NULL DEFAULT '',
preview_url TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
provider TEXT NOT NULL DEFAULT 'claude-code',
status TEXT NOT NULL DEFAULT 'pending',
prompt TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT '',
pod_name TEXT NOT NULL DEFAULT '',
config JSONB NOT NULL DEFAULT '{}',
cost_json JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sessions_app_id ON sessions(app_id);
CREATE INDEX idx_sessions_status ON sessions(status);
CREATE INDEX idx_apps_status ON apps(status);

202
internal/store/postgres.go Normal file
View File

@@ -0,0 +1,202 @@
package store
import (
"context"
"embed"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type Store struct {
pool *pgxpool.Pool
}
func New(ctx context.Context, dsn string) (*Store, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("connect to postgres: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping postgres: %w", err)
}
return &Store{pool: pool}, nil
}
func (s *Store) Migrate(ctx context.Context) error {
sql, err := migrationsFS.ReadFile("migrations/001_init.sql")
if err != nil {
return fmt.Errorf("read migration: %w", err)
}
_, err = s.pool.Exec(ctx, string(sql))
if err != nil {
return fmt.Errorf("run migration: %w", err)
}
return nil
}
func (s *Store) Close() {
s.pool.Close()
}
// App
type App struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
RepoOwner string `json:"repo_owner,omitempty"`
RepoName string `json:"repo_name,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *Store) CreateApp(ctx context.Context, name, description string) (*App, error) {
app := &App{}
err := s.pool.QueryRow(ctx,
`INSERT INTO apps (name, description) VALUES ($1, $2)
RETURNING id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at`,
name, description,
).Scan(&app.ID, &app.Name, &app.Description, &app.Status, &app.RepoOwner, &app.RepoName, &app.PreviewURL, &app.CreatedAt, &app.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("create app: %w", err)
}
return app, nil
}
func (s *Store) GetApp(ctx context.Context, id uuid.UUID) (*App, error) {
app := &App{}
err := s.pool.QueryRow(ctx,
`SELECT id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at
FROM apps WHERE id = $1`, id,
).Scan(&app.ID, &app.Name, &app.Description, &app.Status, &app.RepoOwner, &app.RepoName, &app.PreviewURL, &app.CreatedAt, &app.UpdatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("get app: %w", err)
}
return app, nil
}
func (s *Store) ListApps(ctx context.Context) ([]App, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, name, description, status, repo_owner, repo_name, preview_url, created_at, updated_at
FROM apps ORDER BY updated_at DESC`)
if err != nil {
return nil, fmt.Errorf("list apps: %w", err)
}
defer rows.Close()
var apps []App
for rows.Next() {
var a App
if err := rows.Scan(&a.ID, &a.Name, &a.Description, &a.Status, &a.RepoOwner, &a.RepoName, &a.PreviewURL, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan app: %w", err)
}
apps = append(apps, a)
}
return apps, nil
}
func (s *Store) UpdateAppStatus(ctx context.Context, id uuid.UUID, status string) error {
_, err := s.pool.Exec(ctx,
`UPDATE apps SET status = $1, updated_at = now() WHERE id = $2`, status, id)
return err
}
func (s *Store) UpdateAppRepo(ctx context.Context, id uuid.UUID, owner, name string) error {
_, err := s.pool.Exec(ctx,
`UPDATE apps SET repo_owner = $1, repo_name = $2, updated_at = now() WHERE id = $3`, owner, name, id)
return err
}
func (s *Store) DeleteApp(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, `DELETE FROM apps WHERE id = $1`, id)
return err
}
// Session
type Session struct {
ID uuid.UUID `json:"id"`
AppID uuid.UUID `json:"app_id"`
Provider string `json:"provider"`
Status string `json:"status"`
Prompt string `json:"prompt"`
Branch string `json:"branch"`
PodName string `json:"pod_name,omitempty"`
Config []byte `json:"config,omitempty"`
CostJSON []byte `json:"cost,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *Store) CreateSession(ctx context.Context, appID uuid.UUID, provider, prompt, branch string, config []byte) (*Session, error) {
sess := &Session{}
err := s.pool.QueryRow(ctx,
`INSERT INTO sessions (app_id, provider, prompt, branch, config)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at`,
appID, provider, prompt, branch, config,
).Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
return sess, nil
}
func (s *Store) GetSession(ctx context.Context, id uuid.UUID) (*Session, error) {
sess := &Session{}
err := s.pool.QueryRow(ctx,
`SELECT id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at
FROM sessions WHERE id = $1`, id,
).Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("get session: %w", err)
}
return sess, nil
}
func (s *Store) ListSessionsByApp(ctx context.Context, appID uuid.UUID) ([]Session, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, app_id, provider, status, prompt, branch, pod_name, config, cost_json, created_at, updated_at
FROM sessions WHERE app_id = $1 ORDER BY created_at DESC`, appID)
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}
defer rows.Close()
var sessions []Session
for rows.Next() {
var sess Session
if err := rows.Scan(&sess.ID, &sess.AppID, &sess.Provider, &sess.Status, &sess.Prompt, &sess.Branch, &sess.PodName, &sess.Config, &sess.CostJSON, &sess.CreatedAt, &sess.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan session: %w", err)
}
sessions = append(sessions, sess)
}
return sessions, nil
}
func (s *Store) UpdateSessionStatus(ctx context.Context, id uuid.UUID, status string) error {
_, err := s.pool.Exec(ctx,
`UPDATE sessions SET status = $1, updated_at = now() WHERE id = $2`, status, id)
return err
}
func (s *Store) UpdateSessionPod(ctx context.Context, id uuid.UUID, podName string) error {
_, err := s.pool.Exec(ctx,
`UPDATE sessions SET pod_name = $1, updated_at = now() WHERE id = $2`, podName, id)
return err
}

24
ui/dist/index.html vendored Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>asp.now</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0a; --surface: #141414; --surface-2: #1e1e1e;
--border: #2a2a2a; --text: #e5e5e5; --text-dim: #888;
--accent: #6366f1; --accent-hover: #818cf8;
--green: #22c55e; --yellow: #eab308; --red: #ef4444;
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
</style>
</head>
<body>
<div id="app"></div>
<script src="/index.js"></script>
</body>
</html>

17
ui/embed.go Normal file
View File

@@ -0,0 +1,17 @@
package ui
import (
"embed"
"io/fs"
)
//go:embed dist/*
var distFS embed.FS
func StaticFS() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
return nil
}
return sub
}

16
ui/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "agent-mgr-ui",
"private": true,
"scripts": {
"dev": "esbuild src/index.tsx --bundle --outdir=dist --servedir=dist --loader:.tsx=tsx --loader:.ts=ts --jsx=automatic --jsx-import-source=preact --define:process.env.NODE_ENV=\\\"development\\\"",
"build": "esbuild src/index.tsx --bundle --outdir=dist --minify --loader:.tsx=tsx --loader:.ts=ts --jsx=automatic --jsx-import-source=preact --define:process.env.NODE_ENV=\\\"production\\\""
},
"dependencies": {
"preact": "^10.25.0",
"preact-router": "^4.1.2"
},
"devDependencies": {
"esbuild": "^0.24.0",
"typescript": "^5.7.0"
}
}

97
ui/src/api.ts Normal file
View File

@@ -0,0 +1,97 @@
const BASE = '/api/v1';
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(BASE + path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
if (!res.ok) {
const body = await res.text();
throw new Error(`${res.status}: ${body}`);
}
if (res.status === 204) return {} as T;
return res.json();
}
export interface App {
id: string;
name: string;
description: string;
status: string;
repo_owner: string;
repo_name: string;
preview_url: string;
created_at: string;
updated_at: string;
}
export interface Session {
id: string;
app_id: string;
provider: string;
status: string;
prompt: string;
branch: string;
pod_name: string;
created_at: string;
updated_at: string;
}
export interface Provider {
name: string;
display_name: string;
description: string;
capabilities: { name: string; description: string }[];
}
export const api = {
createApp(name: string, description: string) {
return request<{ app: App; session: Session }>('/apps', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
},
listApps() {
return request<{ apps: App[] }>('/apps');
},
getApp(id: string) {
return request<{ app: App; sessions: Session[] }>(`/apps/${id}`);
},
deleteApp(id: string) {
return request<{}>(`/apps/${id}`, { method: 'DELETE' });
},
createSession(appId: string, prompt: string) {
return request<{ session: Session }>(`/apps/${appId}/sessions`, {
method: 'POST',
body: JSON.stringify({ prompt }),
});
},
getSession(id: string) {
return request<{ session: Session }>(`/sessions/${id}`);
},
stopSession(id: string) {
return request<{}>(`/sessions/${id}/stop`, { method: 'POST' });
},
sendMessage(id: string, message: string) {
return request<{}>(`/sessions/${id}/message`, {
method: 'POST',
body: JSON.stringify({ message }),
});
},
listProviders() {
return request<{ providers: Provider[] }>('/providers');
},
sessionWsUrl(id: string): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}${BASE}/sessions/${id}/ws`;
},
};

View File

@@ -0,0 +1,77 @@
import { App } from '../api';
import { route } from 'preact-router';
interface Props {
app: App;
}
const statusColors: Record<string, string> = {
draft: 'var(--text-dim)',
building: 'var(--yellow)',
live: 'var(--green)',
stopped: 'var(--text-dim)',
failed: 'var(--red)',
};
const statusLabels: Record<string, string> = {
draft: 'Draft',
building: 'Building...',
live: 'Live',
stopped: 'Stopped',
failed: 'Failed',
};
export function AppCard({ app }: Props) {
const color = statusColors[app.status] || 'var(--text-dim)';
return (
<div
onClick={() => route(app.status === 'building' ? `/apps/${app.id}/live` : `/apps/${app.id}`)}
style={styles.card}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--accent)')}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--border)')}
>
<div style={styles.icon}>
{app.name.slice(0, 2).toUpperCase()}
</div>
<div style={styles.name}>{app.name}</div>
<div style={{ ...styles.status, color }}>
{app.status === 'building' || app.status === 'live' ? '\u25CF ' : '\u25CB '}
{statusLabels[app.status] || app.status}
</div>
</div>
);
}
const styles = {
card: {
padding: '20px',
borderRadius: '12px',
border: '1px solid var(--border)',
background: 'var(--surface)',
cursor: 'pointer',
transition: 'border-color 0.15s',
display: 'flex',
flexDirection: 'column' as const,
gap: '8px',
},
icon: {
width: '40px',
height: '40px',
borderRadius: '8px',
background: 'var(--surface-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: '700',
color: 'var(--accent)',
},
name: {
fontSize: '15px',
fontWeight: '600',
},
status: {
fontSize: '13px',
},
};

121
ui/src/components/chat.tsx Normal file
View File

@@ -0,0 +1,121 @@
import { useState, useEffect, useRef } from 'preact/hooks';
interface Message {
from: 'agent' | 'user';
text: string;
}
interface Props {
messages: Message[];
onSend: (msg: string) => void;
disabled?: boolean;
}
export function Chat({ messages, onSend, disabled }: Props) {
const [input, setInput] = useState('');
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
const handleSend = (e: Event) => {
e.preventDefault();
if (input.trim() && !disabled) {
onSend(input.trim());
setInput('');
}
};
return (
<div style={styles.container}>
<div style={styles.messages}>
{messages.map((m, i) => (
<div key={i} style={{
...styles.message,
...(m.from === 'user' ? styles.userMessage : styles.agentMessage),
}}>
{m.text}
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSend} style={styles.inputRow}>
<input
type="text"
value={input}
onInput={(e) => setInput((e.target as HTMLInputElement).value)}
placeholder={disabled ? 'Session ended' : 'Send a follow-up...'}
disabled={disabled}
style={styles.input}
/>
<button type="submit" disabled={!input.trim() || disabled} style={{
...styles.sendButton,
opacity: (!input.trim() || disabled) ? 0.5 : 1,
}}>
Send
</button>
</form>
</div>
);
}
const styles = {
container: {
display: 'flex',
flexDirection: 'column' as const,
border: '1px solid var(--border)',
borderRadius: '12px',
background: 'var(--surface)',
overflow: 'hidden',
},
messages: {
flex: 1,
padding: '16px',
maxHeight: '300px',
overflowY: 'auto' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '8px',
},
message: {
padding: '10px 14px',
borderRadius: '10px',
fontSize: '14px',
lineHeight: '1.5',
maxWidth: '85%',
},
agentMessage: {
background: 'var(--surface-2)',
alignSelf: 'flex-start',
color: 'var(--text)',
},
userMessage: {
background: 'var(--accent)',
alignSelf: 'flex-end',
color: 'white',
},
inputRow: {
display: 'flex',
borderTop: '1px solid var(--border)',
},
input: {
flex: 1,
padding: '12px 16px',
border: 'none',
background: 'transparent',
color: 'var(--text)',
fontSize: '14px',
outline: 'none',
fontFamily: 'inherit',
},
sendButton: {
padding: '12px 20px',
border: 'none',
background: 'var(--accent)',
color: 'white',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
},
};

44
ui/src/components/nav.tsx Normal file
View File

@@ -0,0 +1,44 @@
interface Props {
title?: string;
back?: string;
}
export function Nav({ title, back }: Props) {
return (
<nav style={styles.nav}>
<div style={styles.inner}>
{back ? (
<a href={back} style={styles.backLink}>{'\u2190'} {title || 'Back'}</a>
) : (
<a href="/" style={styles.logo}>asp.now</a>
)}
</div>
</nav>
);
}
const styles = {
nav: {
borderBottom: '1px solid var(--border)',
background: 'var(--surface)',
},
inner: {
maxWidth: '960px',
margin: '0 auto',
padding: '14px 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
},
logo: {
fontSize: '18px',
fontWeight: '700',
color: 'var(--text)',
textDecoration: 'none',
},
backLink: {
fontSize: '15px',
color: 'var(--text)',
textDecoration: 'none',
},
};

View File

@@ -0,0 +1,55 @@
interface Milestone {
label: string;
status: 'done' | 'active' | 'pending';
}
interface Props {
milestones: Milestone[];
}
export function Progress({ milestones }: Props) {
return (
<div style={styles.container}>
{milestones.map((m) => (
<div key={m.label} style={styles.item}>
<span style={{ ...styles.icon, color: iconColor(m.status) }}>
{m.status === 'done' ? '\u2705' : m.status === 'active' ? '\u25D0' : '\u25CB'}
</span>
<span style={{
color: m.status === 'pending' ? 'var(--text-dim)' : 'var(--text)',
fontWeight: m.status === 'active' ? '600' : '400',
}}>
{m.label}
</span>
</div>
))}
</div>
);
}
function iconColor(status: string): string {
switch (status) {
case 'done': return 'var(--green)';
case 'active': return 'var(--accent)';
default: return 'var(--text-dim)';
}
}
const styles = {
container: {
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
},
item: {
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '15px',
},
icon: {
fontSize: '16px',
width: '20px',
textAlign: 'center' as const,
},
};

View File

@@ -0,0 +1,77 @@
import { useState } from 'preact/hooks';
interface Props {
onSubmit: (text: string) => void;
placeholder?: string;
buttonText?: string;
loading?: boolean;
}
export function PromptInput({ onSubmit, placeholder, buttonText, loading }: Props) {
const [text, setText] = useState('');
const handleSubmit = (e: Event) => {
e.preventDefault();
if (text.trim() && !loading) {
onSubmit(text.trim());
setText('');
}
};
return (
<form onSubmit={handleSubmit} style={styles.form}>
<textarea
value={text}
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
placeholder={placeholder || 'Describe your app idea...'}
style={styles.textarea}
rows={3}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit(e);
}
}}
/>
<button type="submit" disabled={!text.trim() || loading} style={{
...styles.button,
opacity: (!text.trim() || loading) ? 0.5 : 1,
}}>
{loading ? 'Starting...' : (buttonText || 'Build it')}
</button>
</form>
);
}
const styles = {
form: {
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
width: '100%',
maxWidth: '640px',
},
textarea: {
width: '100%',
padding: '16px',
borderRadius: '12px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text)',
fontSize: '16px',
lineHeight: '1.5',
resize: 'vertical' as const,
fontFamily: 'inherit',
outline: 'none',
},
button: {
alignSelf: 'flex-end',
padding: '10px 24px',
borderRadius: '8px',
border: 'none',
background: 'var(--accent)',
color: 'white',
fontSize: '15px',
fontWeight: '600',
cursor: 'pointer',
},
};

21
ui/src/index.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { render } from 'preact';
import Router from 'preact-router';
import { Home } from './pages/home';
import { AppDetail } from './pages/app-detail';
import { Building } from './pages/building';
import { Apps } from './pages/apps';
function App() {
return (
<div style={{ minHeight: '100vh' }}>
<Router>
<Home path="/" />
<Apps path="/apps" />
<AppDetail path="/apps/:id" />
<Building path="/apps/:id/live" />
</Router>
</div>
);
}
render(<App />, document.getElementById('app')!);

264
ui/src/pages/app-detail.tsx Normal file
View File

@@ -0,0 +1,264 @@
import { useState, useEffect } from 'preact/hooks';
import { route } from 'preact-router';
import { api, App, Session } from '../api';
import { PromptInput } from '../components/prompt-input';
import { Nav } from '../components/nav';
interface Props {
id?: string;
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
const statusColors: Record<string, string> = {
draft: 'var(--text-dim)',
building: 'var(--yellow)',
live: 'var(--green)',
stopped: 'var(--text-dim)',
failed: 'var(--red)',
};
export function AppDetail({ id }: Props) {
const [app, setApp] = useState<App | null>(null);
const [sessions, setSessions] = useState<Session[]>([]);
const [improving, setImproving] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!id) return;
api.getApp(id).then((r) => {
setApp(r.app);
setSessions(r.sessions);
});
}, [id]);
const handleImprove = async (prompt: string) => {
if (!id) return;
setLoading(true);
try {
await api.createSession(id, prompt);
route(`/apps/${id}/live`);
} catch (e) {
console.error('create session failed', e);
setLoading(false);
}
};
if (!app) {
return (
<div>
<Nav />
<main style={styles.main}>
<div style={styles.loading}>Loading...</div>
</main>
</div>
);
}
const statusColor = statusColors[app.status] || 'var(--text-dim)';
return (
<div>
<Nav title={app.name} back="/apps" />
<main style={styles.main}>
<div style={styles.header}>
<div>
<h1 style={styles.heading}>{app.name}</h1>
<p style={styles.description}>{app.description}</p>
</div>
<span style={{ ...styles.status, color: statusColor }}>
{'\u25CF'} {app.status}
</span>
</div>
<div style={styles.actions}>
{app.preview_url && (
<a href={app.preview_url} target="_blank" rel="noopener" style={styles.primaryButton}>
Open App
</a>
)}
<button onClick={() => setImproving(!improving)} style={styles.secondaryButton}>
{improving ? 'Cancel' : 'Improve this app'}
</button>
</div>
{improving && (
<div style={styles.improveSection}>
<PromptInput
onSubmit={handleImprove}
placeholder="What would you like to change or add?"
buttonText="Start"
loading={loading}
/>
</div>
)}
<div style={styles.historySection}>
<h2 style={styles.sectionTitle}>History</h2>
{sessions.length === 0 ? (
<div style={styles.empty}>No sessions yet.</div>
) : (
<div style={styles.timeline}>
{sessions.map((sess, i) => (
<div key={sess.id} style={styles.timelineItem}>
<div style={styles.timelineDot} />
<div style={styles.timelineContent}>
<div style={styles.timelineHeader}>
<span style={styles.version}>v{sessions.length - i}</span>
<span style={styles.timeAgo}>{timeAgo(sess.created_at)}</span>
</div>
<div style={styles.timelinePrompt}>
{i === sessions.length - 1 ? 'Created app' : `"${sess.prompt}"`}
</div>
<div style={{
...styles.sessionStatus,
color: sess.status === 'completed' ? 'var(--green)' :
sess.status === 'running' ? 'var(--yellow)' :
sess.status === 'failed' ? 'var(--red)' : 'var(--text-dim)',
}}>
{sess.status}
</div>
</div>
</div>
))}
</div>
)}
</div>
</main>
</div>
);
}
const styles = {
main: {
maxWidth: '720px',
margin: '0 auto',
padding: '32px 24px',
},
loading: {
textAlign: 'center' as const,
padding: '60px 0',
color: 'var(--text-dim)',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '24px',
},
heading: {
fontSize: '28px',
fontWeight: '700',
marginBottom: '8px',
},
description: {
color: 'var(--text-dim)',
fontSize: '15px',
},
status: {
fontSize: '14px',
fontWeight: '600',
whiteSpace: 'nowrap' as const,
},
actions: {
display: 'flex',
gap: '12px',
marginBottom: '32px',
},
primaryButton: {
padding: '10px 20px',
borderRadius: '8px',
background: 'var(--accent)',
color: 'white',
fontSize: '14px',
fontWeight: '600',
textDecoration: 'none',
border: 'none',
cursor: 'pointer',
},
secondaryButton: {
padding: '10px 20px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'transparent',
color: 'var(--text)',
fontSize: '14px',
cursor: 'pointer',
},
improveSection: {
marginBottom: '32px',
display: 'flex',
justifyContent: 'center',
},
historySection: {
borderTop: '1px solid var(--border)',
paddingTop: '24px',
},
sectionTitle: {
fontSize: '18px',
fontWeight: '600',
marginBottom: '16px',
},
empty: {
color: 'var(--text-dim)',
fontSize: '14px',
},
timeline: {
display: 'flex',
flexDirection: 'column' as const,
gap: '0',
},
timelineItem: {
display: 'flex',
gap: '16px',
paddingBottom: '20px',
paddingLeft: '8px',
borderLeft: '2px solid var(--border)',
position: 'relative' as const,
},
timelineDot: {
width: '10px',
height: '10px',
borderRadius: '50%',
background: 'var(--accent)',
position: 'absolute' as const,
left: '-6px',
top: '4px',
},
timelineContent: {
paddingLeft: '8px',
},
timelineHeader: {
display: 'flex',
gap: '12px',
alignItems: 'center',
marginBottom: '4px',
},
version: {
fontSize: '14px',
fontWeight: '600',
color: 'var(--text)',
},
timeAgo: {
fontSize: '13px',
color: 'var(--text-dim)',
},
timelinePrompt: {
fontSize: '14px',
color: 'var(--text)',
marginBottom: '2px',
},
sessionStatus: {
fontSize: '12px',
fontWeight: '500',
},
};

73
ui/src/pages/apps.tsx Normal file
View File

@@ -0,0 +1,73 @@
import { useState, useEffect } from 'preact/hooks';
import { api, App } from '../api';
import { AppCard } from '../components/app-card';
import { Nav } from '../components/nav';
export function Apps() {
const [apps, setApps] = useState<App[]>([]);
useEffect(() => {
api.listApps().then((r) => setApps(r.apps));
}, []);
return (
<div>
<Nav />
<main style={styles.main}>
<div style={styles.header}>
<h1 style={styles.heading}>Your Apps</h1>
<a href="/" style={styles.newButton}>+ New App</a>
</div>
{apps.length === 0 ? (
<div style={styles.empty}>
No apps yet. <a href="/">Create your first one.</a>
</div>
) : (
<div style={styles.grid}>
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
)}
</main>
</div>
);
}
const styles = {
main: {
maxWidth: '960px',
margin: '0 auto',
padding: '32px 24px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
},
heading: {
fontSize: '24px',
fontWeight: '700',
},
newButton: {
padding: '8px 16px',
borderRadius: '8px',
background: 'var(--accent)',
color: 'white',
fontSize: '14px',
fontWeight: '600',
textDecoration: 'none',
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '12px',
},
empty: {
textAlign: 'center' as const,
padding: '60px 0',
color: 'var(--text-dim)',
fontSize: '15px',
},
};

191
ui/src/pages/building.tsx Normal file
View File

@@ -0,0 +1,191 @@
import { useState, useEffect, useRef } from 'preact/hooks';
import { route } from 'preact-router';
import { api, App, Session } from '../api';
import { Progress } from '../components/progress';
import { Chat } from '../components/chat';
import { Nav } from '../components/nav';
interface Milestone {
label: string;
status: 'done' | 'active' | 'pending';
}
interface Message {
from: 'agent' | 'user';
text: string;
}
interface Props {
id?: string;
}
const defaultMilestones: Milestone[] = [
{ label: 'Setting up project', status: 'active' },
{ label: 'Creating data models', status: 'pending' },
{ label: 'Building UI components', status: 'pending' },
{ label: 'Adding styling and polish', status: 'pending' },
{ label: 'Final checks', status: 'pending' },
];
export function Building({ id }: Props) {
const [app, setApp] = useState<App | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [milestones, setMilestones] = useState<Milestone[]>(defaultMilestones);
const [messages, setMessages] = useState<Message[]>([]);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
if (!id) return;
api.getApp(id).then((r) => {
setApp(r.app);
const running = r.sessions.find((s) => s.status === 'running' || s.status === 'pending');
if (running) {
setSession(running);
connectWs(running.id);
} else if (r.sessions.length > 0) {
setSession(r.sessions[0]);
}
});
return () => wsRef.current?.close();
}, [id]);
const connectWs = (sessionId: string) => {
const ws = new WebSocket(api.sessionWsUrl(sessionId));
wsRef.current = ws;
ws.onmessage = (e) => {
const line = e.data as string;
try {
const data = JSON.parse(line);
if (data.type === 'assistant' && data.message?.content) {
for (const block of data.message.content) {
if (block.type === 'text' && block.text) {
setMessages((prev) => [...prev, { from: 'agent', text: block.text }]);
}
}
}
} catch {
// Non-JSON line — might be a status update
if (line.trim()) {
updateMilestones(line);
}
}
};
ws.onclose = () => {
if (id) {
api.getApp(id).then((r) => {
setApp(r.app);
if (r.sessions.length > 0) setSession(r.sessions[0]);
});
}
};
};
const updateMilestones = (line: string) => {
const lower = line.toLowerCase();
setMilestones((prev) => {
const next = [...prev];
let target = 0;
if (lower.includes('model') || lower.includes('schema') || lower.includes('database')) target = 1;
if (lower.includes('component') || lower.includes('page') || lower.includes('route')) target = 2;
if (lower.includes('css') || lower.includes('style') || lower.includes('tailwind')) target = 3;
if (lower.includes('test') || lower.includes('done') || lower.includes('complete')) target = 4;
for (let i = 0; i < next.length; i++) {
if (i < target) next[i] = { ...next[i], status: 'done' };
else if (i === target) next[i] = { ...next[i], status: 'active' };
}
return next;
});
};
const handleSend = async (msg: string) => {
if (!session) return;
setMessages((prev) => [...prev, { from: 'user', text: msg }]);
try {
await api.sendMessage(session.id, msg);
} catch (e) {
console.error('send message failed', e);
}
};
const handleStop = async () => {
if (!session) return;
await api.stopSession(session.id);
route(`/apps/${id}`);
};
const done = session?.status === 'completed' || session?.status === 'failed' || session?.status === 'stopped';
return (
<div>
<Nav title={app?.name || 'Building...'} back={`/apps/${id}`} />
<main style={styles.main}>
<div style={styles.header}>
<h1 style={styles.heading}>
{done ? (session?.status === 'completed' ? 'Build complete!' : 'Build ended') : 'Building your app...'}
</h1>
{!done && (
<button onClick={handleStop} style={styles.stopButton}>Stop</button>
)}
{done && (
<a href={`/apps/${id}`} style={styles.viewButton}>View App</a>
)}
</div>
<div style={styles.content}>
<Progress milestones={milestones} />
<div style={styles.chatSection}>
<Chat messages={messages} onSend={handleSend} disabled={done} />
</div>
</div>
</main>
</div>
);
}
const styles = {
main: {
maxWidth: '720px',
margin: '0 auto',
padding: '32px 24px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '32px',
},
heading: {
fontSize: '24px',
fontWeight: '700',
},
stopButton: {
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid var(--red)',
background: 'transparent',
color: 'var(--red)',
fontSize: '14px',
cursor: 'pointer',
},
viewButton: {
padding: '8px 16px',
borderRadius: '8px',
background: 'var(--accent)',
color: 'white',
fontSize: '14px',
fontWeight: '600',
textDecoration: 'none',
},
content: {
display: 'flex',
flexDirection: 'column' as const,
gap: '32px',
},
chatSection: {
marginTop: '8px',
},
};

109
ui/src/pages/home.tsx Normal file
View File

@@ -0,0 +1,109 @@
import { useState, useEffect } from 'preact/hooks';
import { route } from 'preact-router';
import { api, App } from '../api';
import { PromptInput } from '../components/prompt-input';
import { AppCard } from '../components/app-card';
import { Nav } from '../components/nav';
export function Home() {
const [apps, setApps] = useState<App[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
api.listApps().then((r) => setApps(r.apps));
}, []);
const handleCreate = async (description: string) => {
setLoading(true);
try {
const result = await api.createApp('', description);
route(`/apps/${result.app.id}/live`);
} catch (e) {
console.error('create app failed', e);
setLoading(false);
}
};
return (
<div>
<Nav />
<main style={styles.main}>
<section style={styles.hero}>
<h1 style={styles.heading}>What do you want to build?</h1>
<PromptInput
onSubmit={handleCreate}
placeholder="A todo app with categories, due dates, and a clean minimal design..."
buttonText="Build it"
loading={loading}
/>
<div style={styles.examples}>
Try: &quot;A recipe manager&quot; &middot; &quot;A habit tracker&quot; &middot; &quot;A team standup board&quot;
</div>
</section>
{apps.length > 0 && (
<section style={styles.appsSection}>
<div style={styles.sectionHeader}>
<h2 style={styles.sectionTitle}>Your Apps</h2>
<a href="/apps" style={styles.viewAll}>View all</a>
</div>
<div style={styles.grid}>
{apps.slice(0, 6).map((app) => (
<AppCard key={app.id} app={app} />
))}
</div>
</section>
)}
</main>
</div>
);
}
const styles = {
main: {
maxWidth: '960px',
margin: '0 auto',
padding: '0 24px',
},
hero: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
textAlign: 'center' as const,
padding: '80px 0 60px',
gap: '20px',
},
heading: {
fontSize: '36px',
fontWeight: '700',
letterSpacing: '-0.02em',
},
examples: {
fontSize: '14px',
color: 'var(--text-dim)',
},
appsSection: {
paddingBottom: '60px',
},
sectionHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
borderTop: '1px solid var(--border)',
paddingTop: '24px',
},
sectionTitle: {
fontSize: '18px',
fontWeight: '600',
},
viewAll: {
fontSize: '14px',
color: 'var(--accent)',
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '12px',
},
};

14
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}