Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3fa26cf
feat: Keep the local index running and serve it
nfebe Aug 28, 2026
9699499
feat: Draw the local graph in a browser
nfebe Aug 28, 2026
80370b9
feat: Start whichever core was installed
nfebe Aug 28, 2026
f1d8d43
feat: Make the local view a place to work, not only a picture
nfebe Aug 28, 2026
a8c2160
feat: Build the view on one design system rather than a copy of one
nfebe Aug 28, 2026
4470e0a
feat: Draw the graph the way a person can read it
nfebe Aug 28, 2026
ad85993
fix: Keep what a public repository must not say out of it
nfebe Aug 28, 2026
8e2d00e
fix: Make re-index re-read, and say what it read
nfebe Aug 29, 2026
a00a883
refactor: Take the design from a package instead of holding a copy
nfebe Aug 29, 2026
b40a7e9
feat: Ask for a model once, and keep it in Settings
nfebe Aug 29, 2026
9fa0491
feat: Offer the model where there is one to ask
nfebe Aug 29, 2026
2ea27e5
refactor(ui): Take the forms, cards and switchers from the design pac…
nfebe Aug 29, 2026
7f64598
feat: Read a checkout's work and the rules it is meant to follow
nfebe Aug 29, 2026
b5e6419
feat: Open a repository, write down a rule, and settle in one place
nfebe Aug 29, 2026
f8929f8
fix: Name the parts of a repository instead of counting objects
nfebe Aug 29, 2026
995c0bc
feat: Give a rule a page to be written on
nfebe Aug 29, 2026
92f0485
fix: Stop holding a connection open for the length of a review
nfebe Aug 29, 2026
d0d7ca6
feat: Say what a skill is for, and stop calling everywhere a machine
nfebe Aug 29, 2026
d284430
fix: Name where a skill is going instead of inferring it
nfebe Aug 29, 2026
09dd094
feat: Accept what was proposed, and keep reading without being asked
nfebe Aug 29, 2026
31a7b7c
fix: Replace a list of folder names with somewhere to start
nfebe Aug 29, 2026
a6035c1
fix: Lay a review out like the pull request it is about to become
nfebe Aug 29, 2026
f10a518
feat: Give a review an address, and stop holding it in memory
nfebe Aug 29, 2026
2ff5cc5
feat: Show the review, then the files it came from
nfebe Aug 29, 2026
69b3f7e
fix: Put the tabs above the ribbons, and stop announcing the ordinary
nfebe Aug 29, 2026
951bda6
feat: Reach the MCP endpoint and read a review as a page
nfebe Aug 30, 2026
b5303ae
ci: Run formatting, vet, tests and a build on every change
nfebe Aug 30, 2026
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
59 changes: 59 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Checks

on:
push:
branches: [main]
pull_request:

jobs:
checks:
runs-on: ubuntu-latest
steps:
# Side by side, because the view depends on the design package by
# relative path. Checked out anywhere else that path resolves to nothing.
- uses: actions/checkout@v4
with:
path: agent

- uses: actions/checkout@v4
with:
repository: sourceant/design
path: design

- uses: actions/setup-go@v5
with:
go-version-file: agent/go.mod
cache-dependency-path: agent/go.sum

- uses: actions/setup-node@v4
with:
node-version: 22

# The design preset imports a peer dependency, and node resolves that
# from where the preset lives. Installed as a package it would find the
# consumer's copy by walking up; linked by relative path it looks in a
# directory with nothing in it. This goes when that dependency is a
# version rather than a path.
- name: Peer dependencies the design preset resolves from its own folder
working-directory: design
run: npm install --no-audit --no-fund --no-save @tailwindcss/typography

- name: Formatting
working-directory: agent
run: make fmt-check

- name: Vet
working-directory: agent
run: make vet

- name: Tests
working-directory: agent
run: make test-race

# The view is built before the binary, because the binary embeds it. A
# build against stale assets proves nothing about the change.
- name: Build
working-directory: agent
run: |
make ui-deps
make build
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
sourceant-agent
coverage.out
coverage.html
ui/node_modules/
ui/dist/
80 changes: 80 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
.PHONY: help deps ui ui-deps ui-dev build build-go test test-race test-coverage fmt fmt-check vet lint lint-install clean qa

BINARY_NAME=sourceant-agent
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GitCommit=$(GIT_COMMIT)"

help:
@echo "SourceAnt agent - build commands"
@echo ""
@echo "make deps - Download dependencies, Go and npm"
@echo "make ui - Build the local view into what the binary embeds"
@echo "make ui-dev - Run the view against a running agent"
@echo "make build - Build the view, then the agent binary"
@echo "make build-go - Build the binary with whatever view is embedded"
@echo "make test - Run unit tests"
@echo "make test-race - Run unit tests under the race detector"
@echo "make test-coverage - Run tests with a coverage report"
@echo "make fmt - Format code with gofmt"
@echo "make fmt-check - Check gofmt formatting"
@echo "make vet - Run go vet"
@echo "make lint - Run golangci-lint"
@echo "make qa - Run fmt-check, vet, lint, and tests"
@echo "make clean - Clean build artifacts"

deps: ui-deps
go mod download
go mod tidy

ui-deps:
cd ui && npm install --no-audit --no-fund

# Vite writes into internal/ui/assets, which is what the binary embeds, so a
# build and the sources it came from cannot disagree.
ui:
cd ui && npm run build

ui-dev:
cd ui && npm run dev

build: ui build-go

build-go:
go build $(LDFLAGS) -o $(BINARY_NAME) ./cmd/agent

test:
go test ./...

test-race:
go test -race ./...

test-coverage:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"

fmt:
go fmt ./...

fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Run gofmt on:" && gofmt -l . && exit 1)

vet:
go vet ./...

lint:
@command -v golangci-lint > /dev/null 2>&1 && golangci-lint run ./... || \
(test -x "$$(go env GOPATH)/bin/golangci-lint" && "$$(go env GOPATH)/bin/golangci-lint" run ./... || \
(echo "golangci-lint not found. Run 'make lint-install' first." && exit 1))

lint-install:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest

qa: fmt-check vet lint test

clean:
rm -f $(BINARY_NAME)
rm -f coverage.out coverage.html
go clean
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# SourceAnt agent

The process that stays up on a developer's machine. It supervises the SourceAnt indexer, keeps the local code graph current, and serves it to the CLI and the local UI.

It never parses code. The grammars and the graph shape live in the Python core, so that one index serves every client.

## What it does

Starts the core on a free port and waits for it to answer. Restarts it when it dies, backing off as failures repeat. Serves its own HTTP surface on `127.0.0.1:8930`, where `/health` reports whether the core is up and how many times it has been started, and `/api/repositories` and `/api/graph` read the index.

It also serves the local view at `/`: Overview, Knowledge, Graphs, Repositories, Settings. The assets are embedded in the binary, so the view works with no network and cannot drift from the agent serving it.

The view is Vue, built by Vite from `ui/`: Tailwind for the design tokens, lucide for icons, force-graph in 2D and 3d-force-graph for Tree, Radial, Layered and Force.

There are no reviews here. A review reads a pull request, and nothing on a machine produces one.

Loopback is the default because the agent reads a working tree. The machine it runs on is the only audience it has.

## Running it

Building needs Go and npm: `make build` builds the view, then the binary that embeds it. `make build-go` skips the view when nothing about it changed, and `make ui-dev` runs it against an agent already running.

```bash
make build
./sourceant-agent
```

It starts whatever `sourceant install` put on this machine, reading `~/.sourceant/config.json`: a container, or the core as a program. A container binds every interface inside and is published to loopback outside, because one binding the container's own loopback could be reached by nothing.

With nothing installed it looks for `sourceant` on `PATH`, which is what somebody working on the core itself already has. See [sourceant/cli](https://github.com/sourceant/cli) to install one, and [sourceant/sourceant](https://github.com/sourceant/sourceant) for the core.

| Variable | Default | Meaning |
|---|---|---|
| `SOURCEANT_AGENT_LISTEN` | `127.0.0.1:8930` | Where the agent answers |
| `SOURCEANT_CORE` | what was installed | A core to supervise instead, overriding the install |
| `SOURCEANT_CORE_PORT` | chosen at start | The port to start the core on |

## Building

```bash
make qa # fmt-check, vet, lint, test
make build
```

## Licence

MIT.
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.1.0
113 changes: 113 additions & 0 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Command sourceant-agent keeps the local index running and serves it.
package main

import (
"context"
"fmt"
"os"
"os/signal"
"strconv"
"syscall"
"time"

"github.com/sourceant/agent/internal/api"
"github.com/sourceant/agent/internal/config"
"github.com/sourceant/agent/internal/core"
"github.com/sourceant/agent/internal/runtime"
"github.com/sourceant/agent/internal/supervise"
)

// Set at build time. See the Makefile.
var (
Version = "dev"
BuildTime = "unknown"
GitCommit = "unknown"
)

func main() {
if len(os.Args) > 1 && (os.Args[1] == "version" || os.Args[1] == "--version") {
fmt.Printf("sourceant-agent %s (%s, built %s)\n", Version, GitCommit, BuildTime)
return
}
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "sourceant-agent:", err)
os.Exit(1)
}
}

func run() error {
cfg, err := config.FromEnvironment()
if err != nil {
return err
}

port := cfg.CorePort
if port == 0 {
if port, err = supervise.FreePort(); err != nil {
return fmt.Errorf("finding a port for the core: %w", err)
}
}
coreURL := "http://127.0.0.1:" + strconv.Itoa(port)
client := core.New(coreURL, 30*time.Second)

installed := resolveCore(cfg)
// So a review asked for over MCP answers with a link somebody can click
// rather than a path they have to assemble. The agent serves the screen, so
// it is the only thing that knows this.
installed.UIURL = "http://" + cfg.Listen
name, args, err := installed.Serve(port)
if err != nil {
return err
}

supervisor := supervise.New(supervise.Options{
Name: name,
Args: args,
Ready: client.Healthy,
ReadyWithin: 60 * time.Second,
Output: os.Stderr,
})

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

supervised := make(chan error, 1)
go func() { supervised <- supervisor.Run(ctx) }()

served := make(chan error, 1)
server := api.New(client, supervisor, Version, coreURL)
go func() { served <- server.Serve(ctx, cfg.Listen) }()

// A repository read once answers about last month, so it is read again on
// whatever schedule somebody set. This is the process that is always up,
// which is what makes it the one to do it.
go server.Keep(ctx)

fmt.Fprintf(os.Stderr, "sourceant-agent %s listening on %s, core on %s (%s)\n",
Version, cfg.Listen, coreURL, installed.Describe())

// Whichever half stops first ends the agent: an agent serving without a
// core answers nothing, and a core nobody serves is not reachable.
select {
case err := <-supervised:
return err
case err := <-served:
return err
}
}

// resolveCore decides which core to start, most specific first.
//
// An explicit command wins, because somebody naming one means it. Then what
// the installer wrote. Then the core on PATH, which is what a person working
// on the core itself already has and what makes the agent runnable before
// anything has been installed at all.
func resolveCore(cfg config.Config) runtime.Core {
if cfg.CoreWasChosen {
return runtime.Core{Runtime: runtime.Python, Command: cfg.Core}
}
if installed, err := runtime.Load(runtime.ConfigPath()); err == nil {
return installed.Core
}
return runtime.Core{Runtime: runtime.Python, Command: cfg.Core}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/sourceant/agent

go 1.26.1
45 changes: 45 additions & 0 deletions internal/api/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package api

import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
)

// mcp proxies the MCP endpoint through to the core.
//
// The agent is the address a client is given: it is always up, and it knows
// which port the core landed on this time. A client pointed straight at the
// core would have to be reconfigured every restart.
//
// FlushInterval is -1 because a streamable HTTP response is written as it is
// produced. Buffered, the client waits for a response the server considers
// already sent, and the call hangs.
func (s *Server) mcp() http.Handler {
target, err := url.Parse(s.coreURL)
if err != nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "the core address is not a URL", http.StatusBadGateway)
})
}

proxy := httputil.NewSingleHostReverseProxy(target)
proxy.FlushInterval = -1
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
http.Error(w, "the core is not answering", http.StatusBadGateway)
}

forward := proxy.Director
proxy.Director = func(r *http.Request) {
forward(r)
// Rewritten so the core sees its own address rather than the agent's.
// The core refuses a Host it does not recognise, which is what keeps an
// unauthenticated endpoint off anything but loopback.
r.Host = target.Host
if !strings.HasPrefix(r.URL.Path, "/mcp") {
r.URL.Path = "/mcp" + r.URL.Path
}
}
return proxy
}
Loading
Loading