Add M0 server skeleton with SQLite store, SSE events, worktrees, and tailscale service

This commit is contained in:
Ian Gulliver
2026-10-13 22:19:20 -07:00
parent 555a55efc8
commit 2328904131
26 changed files with 3030 additions and 194 deletions
+12 -189
View File
@@ -1,206 +1,29 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"os"
"os/exec"
"os/signal"
"strings"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/report"
)
func main() {
resume := flag.String("resume", "", "claude session id to resume")
flag.Parse()
dir, err := os.Getwd()
if err != nil {
fatal("getwd", "err", err)
if len(os.Args) < 2 {
usage()
}
gate := &report.Gate{OnReport: printReport}
ctx := context.Background()
s, err := claude.Start(ctx, claude.Options{
Cwd: dir,
Resume: *resume,
GitName: gitConfig(dir, "user.name"),
GitEmail: gitConfig(dir, "user.email"),
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{gate.Tool()}}},
Hooks: map[string][]claude.HookMatcher{
"Stop": {{Func: gate.StopHook}},
},
})
if err != nil {
fatal("start claude", "err", err)
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, os.Interrupt)
lines := make(chan string)
go readLines(lines)
for {
fmt.Print("> ")
select {
case line, ok := <-lines:
if !ok {
shutdown(s)
return
}
if strings.TrimSpace(line) == "" {
continue
}
gate.Reset()
err := s.Send(line)
if err != nil {
fatal("send", "err", err)
}
runTurn(ctx, s, sigs)
case <-sigs:
fmt.Println()
shutdown(s)
return
}
switch os.Args[1] {
case "serve":
serveCmd(os.Args[2:])
case "run":
runCmd(os.Args[2:])
default:
usage()
}
}
func runTurn(ctx context.Context, s *claude.Session, sigs <-chan os.Signal) {
for {
select {
case ev, ok := <-s.Events():
if !ok {
fatal("claude exited")
}
if printEvent(ev) {
return
}
case <-sigs:
fmt.Println("\n[interrupting]")
err := s.Interrupt(ctx)
if err != nil {
slog.Error("interrupt", "err", err)
}
}
}
}
func printEvent(ev claude.Event) bool {
switch {
case ev.Type == "system" && ev.Subtype == "init":
info := struct {
SessionID string `json:"session_id"`
Model string `json:"model"`
}{}
err := json.Unmarshal(ev.Raw, &info)
if err != nil {
fatal("parse init", "err", err)
}
fmt.Printf("[session %s, %s]\n", info.SessionID, info.Model)
case ev.Type == "assistant":
blocks, err := ev.Blocks()
if err != nil {
fatal("parse assistant", "err", err)
}
for _, b := range blocks {
switch b.Type {
case "text":
fmt.Println(b.Text)
case "tool_use":
fmt.Printf("→ %s %s\n", b.Name, oneLine(string(b.Input), 160))
}
}
case ev.Type == "user":
blocks, err := ev.Blocks()
if err != nil {
fatal("parse user", "err", err)
}
for _, b := range blocks {
if b.Type == "tool_result" && b.IsError {
fmt.Printf("← error %s\n", oneLine(string(b.Content), 160))
}
}
case ev.Type == "result":
res, err := ev.Result()
if err != nil {
fatal("parse result", "err", err)
}
fmt.Printf("[%s, %d turns, %.1fs, $%.4f]\n", res.Subtype, res.NumTurns, float64(res.DurationMS)/1000, res.TotalCostUSD)
return true
}
return false
}
func printReport(r report.Report) {
fmt.Println("── report")
sections := []struct {
name string
items []string
}{
{"done", r.Done},
{"open questions", r.OpenQuestions},
{"next steps", r.NextSteps},
{"risks", r.Risks},
{"deviations", r.Deviations},
}
for _, sec := range sections {
if len(sec.items) == 0 {
continue
}
fmt.Printf(" %s:\n", sec.name)
for _, item := range sec.items {
fmt.Printf(" • %s\n", item)
}
}
}
func readLines(out chan<- string) {
defer close(out)
sc := bufio.NewScanner(os.Stdin)
for sc.Scan() {
out <- sc.Text()
}
}
func shutdown(s *claude.Session) {
err := s.Close()
if err != nil {
slog.Warn("claude exit", "err", err)
}
}
func gitConfig(dir, key string) string {
cmd := exec.Command("git", "config", key)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
fatal("git config", "key", key, "err", err)
}
return strings.TrimSpace(string(out))
}
func oneLine(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > max {
return s[:max] + "…"
}
return s
func usage() {
fmt.Fprintln(os.Stderr, "usage: byre serve [--dev] | byre run [--resume ID]")
os.Exit(2)
}
func fatal(msg string, args ...any) {
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"os"
"os/exec"
"os/signal"
"strings"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/report"
)
func runCmd(args []string) {
fs := flag.NewFlagSet("run", flag.ExitOnError)
resume := fs.String("resume", "", "claude session id to resume")
fs.Parse(args)
dir, err := os.Getwd()
if err != nil {
fatal("getwd", "err", err)
}
gate := &report.Gate{OnReport: printReport}
ctx := context.Background()
s, err := claude.Start(ctx, claude.Options{
Cwd: dir,
Resume: *resume,
GitName: gitConfig(dir, "user.name"),
GitEmail: gitConfig(dir, "user.email"),
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{gate.Tool()}}},
Hooks: map[string][]claude.HookMatcher{
"Stop": {{Func: gate.StopHook}},
},
})
if err != nil {
fatal("start claude", "err", err)
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, os.Interrupt)
lines := make(chan string)
go readLines(lines)
for {
fmt.Print("> ")
select {
case line, ok := <-lines:
if !ok {
shutdown(s)
return
}
if strings.TrimSpace(line) == "" {
continue
}
gate.Reset()
err := s.Send(line)
if err != nil {
fatal("send", "err", err)
}
runTurn(ctx, s, sigs)
case <-sigs:
fmt.Println()
shutdown(s)
return
}
}
}
func runTurn(ctx context.Context, s *claude.Session, sigs <-chan os.Signal) {
for {
select {
case ev, ok := <-s.Events():
if !ok {
fatal("claude exited")
}
if printEvent(ev) {
return
}
case <-sigs:
fmt.Println("\n[interrupting]")
err := s.Interrupt(ctx)
if err != nil {
slog.Error("interrupt", "err", err)
}
}
}
}
func printEvent(ev claude.Event) bool {
switch {
case ev.Type == "system" && ev.Subtype == "init":
info := struct {
SessionID string `json:"session_id"`
Model string `json:"model"`
}{}
err := json.Unmarshal(ev.Raw, &info)
if err != nil {
fatal("parse init", "err", err)
}
fmt.Printf("[session %s, %s]\n", info.SessionID, info.Model)
case ev.Type == "assistant":
blocks, err := ev.Blocks()
if err != nil {
fatal("parse assistant", "err", err)
}
for _, b := range blocks {
switch b.Type {
case "text":
fmt.Println(b.Text)
case "tool_use":
fmt.Printf("→ %s %s\n", b.Name, oneLine(string(b.Input), 160))
}
}
case ev.Type == "user":
blocks, err := ev.Blocks()
if err != nil {
fatal("parse user", "err", err)
}
for _, b := range blocks {
if b.Type == "tool_result" && b.IsError {
fmt.Printf("← error %s\n", oneLine(string(b.Content), 160))
}
}
case ev.Type == "result":
res, err := ev.Result()
if err != nil {
fatal("parse result", "err", err)
}
fmt.Printf("[%s, %d turns, %.1fs, $%.4f]\n", res.Subtype, res.NumTurns, float64(res.DurationMS)/1000, res.TotalCostUSD)
return true
}
return false
}
func printReport(r report.Report) {
fmt.Println("── report")
sections := []struct {
name string
items []string
}{
{"done", r.Done},
{"open questions", r.OpenQuestions},
{"next steps", r.NextSteps},
{"risks", r.Risks},
{"deviations", r.Deviations},
}
for _, sec := range sections {
if len(sec.items) == 0 {
continue
}
fmt.Printf(" %s:\n", sec.name)
for _, item := range sec.items {
fmt.Printf(" • %s\n", item)
}
}
}
func readLines(out chan<- string) {
defer close(out)
sc := bufio.NewScanner(os.Stdin)
for sc.Scan() {
out <- sc.Text()
}
}
func shutdown(s *claude.Session) {
err := s.Close()
if err != nil {
slog.Warn("claude exit", "err", err)
}
}
func gitConfig(dir, key string) string {
cmd := exec.Command("git", "config", key)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
fatal("git config", "key", key, "err", err)
}
return strings.TrimSpace(string(out))
}
func oneLine(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > max {
return s[:max] + "…"
}
return s
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"context"
"errors"
"flag"
"io/fs"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"g.fc.run/misc/byre/internal/server"
"g.fc.run/misc/byre/web"
)
const listenAddr = "127.0.0.1:8270"
func serveCmd(args []string) {
flags := flag.NewFlagSet("serve", flag.ExitOnError)
dev := flags.Bool("dev", false, "serve the SPA from ./web on disk instead of the embedded copy")
flags.Parse(args)
configDir, err := os.UserConfigDir()
if err != nil {
fatal("user config dir", "err", err)
}
spa := fs.FS(web.FS)
if *dev {
spa = os.DirFS("web")
}
srv, err := server.New(filepath.Join(configDir, "byre"), spa)
if err != nil {
fatal("start server", "err", err)
}
defer srv.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
hs := &http.Server{
Addr: listenAddr,
Handler: srv.Handler(),
BaseContext: func(net.Listener) context.Context { return ctx },
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
hs.Shutdown(shutdownCtx)
}()
slog.Info("listening", "addr", listenAddr, "dev", *dev)
err = hs.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) {
fatal("serve", "err", err)
}
}