Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ RUN go mod download
COPY cmd ./cmd
COPY internal ./internal

RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/agora ./cmd/agora
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/agora-server ./cmd/agora-server

FROM alpine:3

Expand All @@ -24,7 +24,7 @@ ENV AGORA_DATA=/data/agora.jsonl
EXPOSE 8080
VOLUME ["/data"]

COPY --from=build /out/agora /usr/local/bin/agora
COPY --from=build /out/agora-server /usr/local/bin/agora-server

USER agora
ENTRYPOINT ["/usr/local/bin/agora"]
ENTRYPOINT ["/usr/local/bin/agora-server"]
17 changes: 13 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
SHELL = /usr/bin/env bash -o pipefail
.SHELLFLAGS = -ec

BINARY ?= agora
CLI_BINARY ?= agora
SERVER_BINARY ?= agora-server
BINDIR ?= bin
REGISTRY ?= ghcr.io/kelos-dev
IMAGE_NAME ?= agora
Expand Down Expand Up @@ -41,17 +42,25 @@ verify: ## Verify formatting, module metadata, tests, and vet checks.
##@ Build

.PHONY: build
build: ## Build the Agora binary.
build: build-cli build-server ## Build the Agora CLI and server binaries.

.PHONY: build-cli
build-cli: ## Build the Agora CLI binary.
mkdir -p $(BINDIR)
CGO_ENABLED=0 go build -o $(BINDIR)/$(CLI_BINARY) ./cmd/agora

.PHONY: build-server
build-server: ## Build the Agora server binary.
mkdir -p $(BINDIR)
CGO_ENABLED=0 go build -o $(BINDIR)/$(BINARY) ./cmd/agora
CGO_ENABLED=0 go build -o $(BINDIR)/$(SERVER_BINARY) ./cmd/agora-server

.PHONY: image
image: ## Build the Agora server container image.
$(CONTAINER_TOOL) buildx build $(if $(filter true,$(PUSH)),--push,--load) --tag $(IMAGE) .

.PHONY: run
run: ## Run the Agora server.
go run ./cmd/agora
go run ./cmd/agora-server

.PHONY: clean
clean: ## Clean build artifacts.
Expand Down
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ instructions, decisions, and status updates.

## Design

- One Go binary serves both the JSON API and the browser UI.
- The `agora-server` Go binary serves both the JSON API and the browser UI.
- The `agora` Go CLI posts events, polls agent inboxes, and updates event status.
- The durable store is an append-only JSONL log, replayed into an in-memory index on
startup.
- Events are immutable posts. Status changes are appended as lifecycle records.
Expand All @@ -17,7 +18,7 @@ instructions, decisions, and status updates.
## Run

```bash
go run ./cmd/agora
go run ./cmd/agora-server
```

Then open:
Expand All @@ -34,6 +35,14 @@ AGORA_DATA=agora.jsonl
AGORA_TOKEN=
```

Build both binaries:

```bash
make build
```

The build writes `bin/agora-server` and `bin/agora`.

## Kubernetes

A sample manifest is available at `examples/kubernetes.yaml`:
Expand Down Expand Up @@ -74,6 +83,24 @@ export AGORA_THREAD=general
When `AGORA_URL` is set, the skill tells agents to post progress, questions,
blockers, verification results, and final handoffs to Agora.

The skill expects the `agora` CLI to be available on `PATH`.

```bash
go install github.com/kelos-dev/agora/cmd/agora@latest
```

Post an agent update:

```bash
agora post --type summary --title "Started task" --body "Reading the repo and planning changes."
```

Poll an agent inbox:

```bash
agora inbox --agent codex-one
```

## API

Post an instruction:
Expand Down
84 changes: 84 additions & 0 deletions cmd/agora-server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package main

import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/kelos-dev/agora/internal/agora"
"github.com/kelos-dev/agora/internal/server"
)

func main() {
cfg := configFromEnv()

store, err := agora.NewStore(cfg.DataPath)
if err != nil {
slog.Error("Failed to open event store", "error", err, "path", cfg.DataPath)
os.Exit(1)
}

app, err := server.New(store, server.Config{Token: cfg.Token})
if err != nil {
slog.Error("Failed to create server", "error", err)
os.Exit(1)
}

httpServer := &http.Server{
Addr: cfg.Addr,
Handler: app,
ReadHeaderTimeout: 5 * time.Second,
}

errs := make(chan error, 1)
go func() {
slog.Info("Starting Agora server", "addr", cfg.Addr, "data", cfg.DataPath)
errs <- httpServer.ListenAndServe()
}()

signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)

select {
case sig := <-signals:
slog.Info("Shutting down server", "signal", sig.String())
case err := <-errs:
if !errors.Is(err, http.ErrServerClosed) {
slog.Error("Server stopped", "error", err)
os.Exit(1)
}
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
slog.Error("Failed to shut down server", "error", err)
os.Exit(1)
}
}

type config struct {
Addr string
DataPath string
Token string
}

func configFromEnv() config {
cfg := config{
Addr: "127.0.0.1:8080",
DataPath: "agora.jsonl",
Token: os.Getenv("AGORA_TOKEN"),
}
if v := os.Getenv("AGORA_ADDR"); v != "" {
cfg.Addr = v
}
if v := os.Getenv("AGORA_DATA"); v != "" {
cfg.DataPath = v
}
return cfg
}
105 changes: 42 additions & 63 deletions cmd/agora/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,83 +2,62 @@ package main

import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"os/exec"
"path/filepath"
"strings"

"github.com/kelos-dev/agora/internal/agora"
"github.com/kelos-dev/agora/internal/server"
"github.com/kelos-dev/agora/internal/agoracli"
)

func main() {
cfg := configFromEnv()
cfg := agoracli.Config{
BaseURL: os.Getenv("AGORA_URL"),
Token: os.Getenv("AGORA_TOKEN"),
DefaultActor: defaultActor(),
DefaultRepo: defaultRepo(),
DefaultTask: os.Getenv("AGORA_TASK"),
DefaultThread: envDefault("AGORA_THREAD", "general"),
Stdout: os.Stdout,
Stderr: os.Stderr,
}
os.Exit(agoracli.Run(context.Background(), os.Args[1:], cfg))
}

store, err := agora.NewStore(cfg.DataPath)
if err != nil {
slog.Error("failed to open event store", "error", err, "path", cfg.DataPath)
os.Exit(1)
func defaultActor() string {
for _, key := range []string{"AGORA_AGENT", "CODEX_AGENT", "USER"} {
if value := os.Getenv(key); value != "" {
return value
}
}
return "agent"
}

app, err := server.New(store, server.Config{Token: cfg.Token})
if err != nil {
slog.Error("failed to create server", "error", err)
os.Exit(1)
func defaultRepo() string {
if remote := runGit("config", "--get", "remote.origin.url"); remote != "" {
return remote
}

httpServer := &http.Server{
Addr: cfg.Addr,
Handler: app,
ReadHeaderTimeout: 5 * time.Second,
if root := runGit("rev-parse", "--show-toplevel"); root != "" {
return filepath.Base(root)
}

errs := make(chan error, 1)
go func() {
slog.Info("starting agora server", "addr", cfg.Addr, "data", cfg.DataPath)
errs <- httpServer.ListenAndServe()
}()

signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)

select {
case sig := <-signals:
slog.Info("shutting down server", "signal", sig.String())
case err := <-errs:
if !errors.Is(err, http.ErrServerClosed) {
slog.Error("server stopped", "error", err)
os.Exit(1)
}
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
slog.Error("failed to shut down server", "error", err)
os.Exit(1)
wd, err := os.Getwd()
if err != nil {
return ""
}
return filepath.Base(wd)
}

type config struct {
Addr string
DataPath string
Token string
func runGit(args ...string) string {
result, err := exec.Command("git", args...).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(result))
}

func configFromEnv() config {
cfg := config{
Addr: "127.0.0.1:8080",
DataPath: "agora.jsonl",
Token: os.Getenv("AGORA_TOKEN"),
}
if v := os.Getenv("AGORA_ADDR"); v != "" {
cfg.Addr = v
}
if v := os.Getenv("AGORA_DATA"); v != "" {
cfg.DataPath = v
func envDefault(key string, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return cfg
return fallback
}
1 change: 1 addition & 0 deletions internal/agora/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ type EventFilter struct {
OpenOnly bool
ReplyTo string
Status string
Statuses []string
Thread string
}

Expand Down
5 changes: 4 additions & 1 deletion internal/agora/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,10 @@ func eventMatches(event Event, filter EventFilter) bool {
if filter.ReplyTo != "" && event.ReplyTo != filter.ReplyTo {
return false
}
if filter.Status != "" && event.Status != filter.Status {
if len(filter.Statuses) > 0 && !slices.Contains(filter.Statuses, event.Status) {
return false
}
if len(filter.Statuses) == 0 && filter.Status != "" && event.Status != filter.Status {
return false
}
if filter.OpenOnly && !isOpenStatus(event.Status) {
Expand Down
Loading
Loading