Add expose_port previews with a Preview tab; leave setup to AGENTS.md

This commit is contained in:
Ian Gulliver
2026-10-15 13:15:49 -07:00
parent 86e4e772ed
commit f95a0e5332
32 changed files with 1042 additions and 431 deletions
+2 -1
View File
@@ -61,7 +61,8 @@ func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
}
func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, error) {
g := guidance + fmt.Sprintf("\n- This task's ports are %d to %d; $PORT holds the first. Run dev servers and anything else that listens on these, so parallel tasks don't collide.",
g := guidance + fmt.Sprintf("\n- This task's ports are %d to %d; $PORT holds the first. Run dev servers and anything else that listens on these, so parallel tasks don't collide, and listen on 127.0.0.1. To show the human a running server, call mcp__byre__expose_port."+
"\n- This worktree starts with only what git tracks. Before other work in a new worktree, do any setup the project's AGENTS.md describes.",
task.PortBase, task.PortBase+store.PortBlock-1)
if task.TodoItem != nil {
g += fmt.Sprintf("\n- This task was started from the item %q in %s at the repo root. When the work is done, remove that item from %s in this worktree, so the removal lands in the same merge as the work.",
+3 -3
View File
@@ -297,15 +297,15 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
tool("show",
"Point the human's right pane at a task, or at a project with no task: tabs are the tabs to put first, in order (the rest follow), and tab is the one to select. "+
"Tabs: transcript, changes, files, terminal (a shell in the task's worktree), inbox (the task's open items), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
"Tabs: transcript, changes, files, terminal (a shell in the task's worktree), preview (the dev servers the task's agent exposed with expose_port), inbox (the task's open items), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
"Omit both task_id and project_id to clear the pane. "+
"If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+
"Otherwise refused while the human is working in the right pane, unless it is on the project tab or what it shows is finished (a merged or discarded task, or an inbox tab with nothing left).",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
"project_id": {"type": "string", "description": "Ignored when task_id is given."},
"tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "project"]}},
"tab": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "inbox", "project"]}
"tabs": {"type": "array", "items": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "preview", "inbox", "project"]}},
"tab": {"type": "string", "enum": ["transcript", "changes", "files", "terminal", "preview", "inbox", "project"]}
}, "required": ["tabs", "tab"]}`,
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
f := api.Focus{}
+16 -25
View File
@@ -62,10 +62,6 @@ type Manager struct {
snapMu sync.Mutex
snaps map[string]*snapState
setupMu sync.Mutex
setups map[string]bool
setupFailures map[string]string
mergeMu sync.Mutex
mergeHolders map[string]string
@@ -102,21 +98,19 @@ type live struct {
func New(st *store.Store, limits Limits, worktreeRoot, leadRoot string) *Manager {
m := &Manager{
store: st,
limits: limits,
worktreeRoot: worktreeRoot,
leadRoot: leadRoot,
live: map[string]*live{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
leadAnswered: map[string]bool{},
paneFocused: map[string]bool{},
signals: make(chan api.Event, 1024),
snaps: map[string]*snapState{},
setups: map[string]bool{},
setupFailures: map[string]string{},
mergeHolders: map[string]string{},
bad: newBadLines(),
watchStop: make(chan struct{}),
store: st,
limits: limits,
worktreeRoot: worktreeRoot,
leadRoot: leadRoot,
live: map[string]*live{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
leadAnswered: map[string]bool{},
paneFocused: map[string]bool{},
signals: make(chan api.Event, 1024),
snaps: map[string]*snapState{},
mergeHolders: map[string]string{},
bad: newBadLines(),
watchStop: make(chan struct{}),
}
go m.signalLoop()
return m
@@ -422,7 +416,7 @@ func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task
return api.AgentSession{}, err
}
if room && !m.settingUp(task.Id) {
if room {
return m.start(ctx, sess, task, actor, text, images, api.AgentSessionStateIdle)
}
@@ -516,9 +510,6 @@ func (m *Manager) drain(ctx context.Context) error {
}
for _, q := range queued {
if m.settingUp(q.TaskID) {
continue
}
room, err := m.hasRoom(ctx, q.Vendor)
if err != nil {
return err
@@ -669,9 +660,9 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
if err != nil {
return nil, err
}
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l), m.exposePortTool(l)}
hooks := map[string][]claude.HookMatcher{
"UserPromptSubmit": {{Func: m.remindTaskGuidance(l, g)}},
"UserPromptSubmit": {{Func: remindGuidance(g)}},
"Stop": {{Func: m.bounceProseQuestion(l)}},
"PostToolUse": {{Func: m.afterTool(l)}},
}
+118
View File
@@ -0,0 +1,118 @@
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"strconv"
"strings"
"time"
"unicode/utf8"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/store"
"g.fc.run/misc/byre/internal/tailscale"
)
const maxPreviewName = 40
func portEnv(t api.Task) []string {
return []string{"PORT=" + strconv.Itoa(t.PortBase)}
}
type exposePortArgs struct {
Name string `json:"name"`
Port int `json:"port"`
}
func (m *Manager) exposePortTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "expose_port",
Description: "Show the human a server you are running, such as a dev server, in the task's Preview tab. " +
"Start the server yourself first (in the background), listening on 127.0.0.1 (not 0.0.0.0 or localhost) on a port in this task's block. " +
"byre then serves it over HTTPS on the same port of byre's host through tailscale serve, and returns the URL. " +
"name labels it in the tab (\"web\", \"storybook\"); exposing a name or port again replaces the earlier one. " +
"The server stops when your process does (a byre restart, compaction, the task closing); the human sees it as unreachable until you start it again.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"name": {"type": "string", "description": "Short label, shown in the Preview tab."},
"port": {"type": "integer", "description": "The port the server listens on, at 127.0.0.1, within this task's block."}
},
"required": ["name", "port"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.exposePort(ctx, l, raw)
},
}
}
func (m *Manager) exposePort(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
args := exposePortArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed expose_port call: %w", err)
}
name := strings.TrimSpace(args.Name)
if name == "" || strings.ContainsAny(name, "\r\n") || utf8.RuneCountInString(name) > maxPreviewName {
return "", fmt.Errorf("name must be one non-empty line of at most %d characters", maxPreviewName)
}
task, err := m.store.GetTask(ctx, l.taskID)
if err != nil {
return "", err
}
last := task.PortBase + store.PortBlock - 1
if args.Port < task.PortBase || args.Port > last {
return "", fmt.Errorf("port %d is outside this task's block, %d to %d", args.Port, task.PortBase, last)
}
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(args.Port))
conn, err := net.DialTimeout("tcp", addr, time.Second)
if err != nil {
return "", fmt.Errorf("nothing is listening on %s; start the server listening on 127.0.0.1 first: %w", addr, err)
}
conn.Close()
err = tailscale.Serve(ctx, args.Port)
if err != nil {
return "", err
}
dropped, err := m.store.SetPreview(ctx, api.Preview{TaskId: task.Id, Name: name, Port: args.Port, CreatedAt: time.Now().UTC()}, agentActor)
if errors.Is(err, store.ErrNotFound) {
return "", errors.New("this task is no longer active")
}
if err != nil {
return "", err
}
for _, port := range dropped {
err = tailscale.Off(ctx, port)
if err != nil {
slog.Error("stop serving replaced preview", "task", task.Id, "port", port, "err", err)
}
}
return fmt.Sprintf("Exposed %q: https://<byre's host>:%d/ proxies to %s, and the human sees it in the task's Preview tab.", name, args.Port, addr), nil
}
func (m *Manager) Unexpose(ctx context.Context, taskID string) {
previews, err := m.store.ListPreviews(ctx, taskID)
if err != nil {
slog.Error("list previews", "task", taskID, "err", err)
return
}
for _, p := range previews {
err = tailscale.Off(ctx, p.Port)
if err != nil {
slog.Error("stop serving preview", "task", taskID, "port", p.Port, "err", err)
continue
}
slog.Info("stopped serving preview", "task", taskID, "name", p.Name, "port", p.Port)
}
}
+71
View File
@@ -0,0 +1,71 @@
package agents
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/store"
)
func TestExposePortRefusals(t *testing.T) {
ctx := context.Background()
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(api.Event) {})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
now := time.Now().UTC()
_, err = st.EnsureUser(ctx, "alice", "Alice")
if err != nil {
t.Fatalf("ensure user: %v", err)
}
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
if err != nil {
t.Fatalf("create project: %v", err)
}
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "t1", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
if err != nil {
t.Fatalf("create task: %v", err)
}
m := New(st, DefaultLimits, t.TempDir(), t.TempDir())
l := &live{id: "s1", vendor: api.Claude, taskID: "t1", projectID: "p1"}
call := func(name string, port int) error {
raw, err := json.Marshal(exposePortArgs{Name: name, Port: port})
if err != nil {
t.Fatalf("marshal: %v", err)
}
_, err = m.exposePort(ctx, l, raw)
return err
}
cases := []struct {
name string
port int
want string
}{
{"", 41000, "name must be"},
{"two\nlines", 41000, "name must be"},
{strings.Repeat("x", maxPreviewName+1), 41000, "name must be"},
{"web", 40999, "outside this task's block, 41000 to 41009"},
{"web", 41010, "outside this task's block, 41000 to 41009"},
{"web", 41009, "nothing is listening on 127.0.0.1:41009"},
}
for _, c := range cases {
err = call(c.name, c.port)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("expose_port(%q, %d) = %v, want an error containing %q", c.name, c.port, err, c.want)
}
}
previews, err := st.ListPreviews(ctx, "t1")
if err != nil || len(previews) != 0 {
t.Fatalf("previews after refusals = %v, %v", previews, err)
}
}
-113
View File
@@ -1,113 +0,0 @@
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
)
const (
setupScript = "script/setup"
setupTimeout = 15 * time.Minute
setupOutputBytes = 64 << 10
)
func portEnv(t api.Task) []string {
return []string{"PORT=" + strconv.Itoa(t.PortBase)}
}
func (m *Manager) startSetup(t api.Task) {
path := filepath.Join(t.WorktreePath, setupScript)
_, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
return
}
m.setupMu.Lock()
m.setups[t.Id] = true
m.setupMu.Unlock()
go m.runSetup(t, path)
}
func (m *Manager) settingUp(taskID string) bool {
m.setupMu.Lock()
defer m.setupMu.Unlock()
return m.setups[taskID]
}
func (m *Manager) takeSetupFailure(taskID string) string {
m.setupMu.Lock()
defer m.setupMu.Unlock()
msg := m.setupFailures[taskID]
delete(m.setupFailures, taskID)
return msg
}
func (m *Manager) runSetup(t api.Task, path string) {
ctx := context.Background()
m.emitTask(ctx, t, "setup.started", map[string]any{"script": setupScript, "port_base": t.PortBase})
start := time.Now()
cctx, cancel := context.WithTimeout(ctx, setupTimeout)
defer cancel()
cmd := exec.CommandContext(cctx, path)
cmd.Dir = t.WorktreePath
cmd.Env = append(os.Environ(), portEnv(t)...)
out, err := cmd.CombinedOutput()
if cctx.Err() != nil {
err = fmt.Errorf("timed out after %s", setupTimeout)
}
output := string(out)
if len(out) > setupOutputBytes {
output = "…" + string(out[len(out)-setupOutputBytes:])
}
payload := map[string]any{"script": setupScript, "output": output, "duration_ms": time.Since(start).Milliseconds()}
if err != nil {
payload["error"] = err.Error()
}
slog.Info("setup ended", "task", t.Id, "err", err, "took", time.Since(start))
m.emitTask(ctx, t, "setup.ended", payload)
m.setupMu.Lock()
delete(m.setups, t.Id)
if err != nil {
m.setupFailures[t.Id] = fmt.Sprintf("byre ran %s in this worktree when the task was created, and it failed (%s). Its output:\n\n```\n%s\n```\n\nThe worktree may be missing dependencies or env files the setup would have provided.", setupScript, err, output)
}
m.setupMu.Unlock()
err = m.drain(ctx)
if err != nil {
slog.Error("start queued turns", "err", err)
}
}
func (m *Manager) emitTask(ctx context.Context, t api.Task, kind string, payload map[string]any) {
err := m.store.AppendEvent(ctx, api.Event{Kind: kind, ProjectId: &t.ProjectId, TaskId: &t.Id, Actor: "byre", Payload: payload})
if err != nil {
slog.Error("record setup event", "task", t.Id, "kind", kind, "err", err)
}
}
func (m *Manager) remindTaskGuidance(l *live, text string) claude.HookFunc {
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
failure := m.takeSetupFailure(l.taskID)
if failure == "" {
return remindGuidance(text)(ctx, input)
}
m.fileError(l, failure)
return remindGuidance(text+"\n\n"+failure)(ctx, input)
}
}
-91
View File
@@ -1,91 +0,0 @@
package agents_test
import (
"os"
"path/filepath"
"strings"
"testing"
"g.fc.run/misc/byre/internal/api"
)
func (e *env) commitSetup(script string) {
e.t.Helper()
err := os.MkdirAll(filepath.Join(e.repo, "script"), 0o755)
if err != nil {
e.t.Fatal(err)
}
err = os.WriteFile(filepath.Join(e.repo, "script", "setup"), []byte(script), 0o755)
if err != nil {
e.t.Fatal(err)
}
gitOut(e.t, e.repo, "add", "script/setup")
gitOut(e.t, e.repo, "commit", "--quiet", "--message", "setup")
}
func (e *env) waitSetup(taskID string) api.Event {
e.t.Helper()
seen := e.waitFor("setup.ended", func(ev api.Event) bool {
return ev.Kind == "setup.ended" && ev.TaskId != nil && *ev.TaskId == taskID
})
return seen[len(seen)-1]
}
func TestSetupScriptAndPorts(t *testing.T) {
e := newEnv(t)
e.commitSetup("#!/bin/sh\nset -e\ncp \"$(git rev-parse --path-format=absolute --git-common-dir)/../.env\" .env\necho \"port $PORT\"\n")
err := os.WriteFile(filepath.Join(e.repo, ".env"), []byte("SECRET=1\n"), 0o644)
if err != nil {
t.Fatal(err)
}
m := e.manager()
task, err := m.CreateTask(e.ctx, e.project, "with setup", login, "main", login)
if err != nil {
t.Fatalf("create task: %v", err)
}
if task.PortBase != 41010 {
t.Fatalf("port_base = %d, want 41010 (41000 is taken by the first task)", task.PortBase)
}
ev := e.waitSetup(task.Id)
if ev.Payload["error"] != nil {
t.Fatalf("setup failed: %v", ev.Payload)
}
want := "port 41010"
if out, _ := ev.Payload["output"].(string); !strings.Contains(out, want) {
t.Fatalf("output %q lacks %q", out, want)
}
env, err := os.ReadFile(filepath.Join(task.WorktreePath, ".env"))
if err != nil || string(env) != "SECRET=1\n" {
t.Fatalf("worktree .env = %q, %v", env, err)
}
_, err = e.store.DiscardTask(e.ctx, e.task.Id, login)
if err != nil {
t.Fatalf("discard: %v", err)
}
reused, err := m.CreateTask(e.ctx, e.project, "reuses ports", login, "main", login)
if err != nil {
t.Fatalf("create task: %v", err)
}
if reused.PortBase != 41000 {
t.Fatalf("port_base = %d, want the freed 41000", reused.PortBase)
}
e.waitSetup(reused.Id)
}
func TestSetupFailure(t *testing.T) {
e := newEnv(t)
e.commitSetup("#!/bin/sh\necho installing\nexit 3\n")
m := e.manager()
task, err := m.CreateTask(e.ctx, e.project, "failing setup", login, "main", login)
if err != nil {
t.Fatalf("create task: %v", err)
}
ev := e.waitSetup(task.Id)
if ev.Payload["error"] != "exit status 3" || ev.Payload["output"] != "installing\n" {
t.Fatalf("setup.ended payload = %v", ev.Payload)
}
}
+1 -6
View File
@@ -85,12 +85,7 @@ func (m *Manager) createTask(ctx context.Context, project api.Project, t api.Tas
if err != nil {
return api.Task{}, err
}
t, err = m.store.GetTask(ctx, t.Id)
if err != nil {
return api.Task{}, err
}
m.startSetup(t)
return t, nil
return m.store.GetTask(ctx, t.Id)
}
type createTaskArgs struct {