Add a per-user lead agent and make the right pane task-focused

This commit is contained in:
Ian Gulliver
2026-10-15 09:40:30 -07:00
parent d212590cdc
commit bdd1ae48f0
68 changed files with 3332 additions and 1364 deletions
+100 -38
View File
@@ -7,6 +7,9 @@ import (
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -20,6 +23,7 @@ import (
var (
ErrConflict = errors.New("conflict")
ErrInvalid = errors.New("invalid")
ErrNoGitIdentity = errors.New("set your git name and email (PUT /api/me) before starting agents")
)
@@ -41,15 +45,19 @@ type Manager struct {
store *store.Store
limits Limits
worktreeRoot string
leadRoot string
mu sync.Mutex
live map[string]*live
closing bool
rateLimits map[api.AgentSessionVendor]map[string]any
merges map[string]string
mu sync.Mutex
live map[string]*live
closing bool
rateLimits map[api.AgentSessionVendor]map[string]any
merges map[string]string
leadAnswered map[string]bool
deliverMu sync.Mutex
capMu sync.Mutex
leadMu sync.Mutex
signals chan api.Event
snapMu sync.Mutex
snaps map[string]*snapState
@@ -68,11 +76,13 @@ type live struct {
vendor api.AgentSessionVendor
taskID string
projectID string
owner string
stopping bool
interrupted bool
asked bool
bounced bool
compacting bool
wantCompact bool
rateLimited map[string]any
contextModel string
@@ -80,18 +90,30 @@ type live struct {
contextWindow int
}
func New(st *store.Store, limits Limits, worktreeRoot string) *Manager {
return &Manager{
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{},
merges: map[string]string{},
leadAnswered: map[string]bool{},
signals: make(chan api.Event, 1024),
snaps: map[string]*snapState{},
bad: newBadLines(),
watchStop: make(chan struct{}),
}
go m.signalLoop()
return m
}
func (m *Manager) taskFor(ctx context.Context, sess api.AgentSession) (api.Task, error) {
if sess.Owner != "" {
return api.Task{}, nil
}
return m.store.GetTask(ctx, sess.TaskId)
}
func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model string, images ...string) (api.AgentSession, error) {
@@ -156,7 +178,7 @@ func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string
sess.Model = model
}
task, err := m.store.GetTask(ctx, sess.TaskId)
task, err := m.taskFor(ctx, sess)
if err != nil {
return api.AgentSession{}, err
}
@@ -245,7 +267,7 @@ func (m *Manager) Recover(ctx context.Context) error {
}
for _, sess := range sessions {
task, err := m.store.GetTask(ctx, sess.TaskId)
task, err := m.taskFor(ctx, sess)
if err != nil {
return err
}
@@ -264,9 +286,10 @@ func (m *Manager) Recover(ctx context.Context) error {
if err != nil {
return err
}
slog.Info("resumed session", "session", sess.Id, "task", task.Id)
slog.Info("resumed session", "session", sess.Id, "task", task.Id, "owner", sess.Owner)
}
go m.deliverAllLeads()
return m.drain(ctx)
}
@@ -284,6 +307,11 @@ func (m *Manager) Close() {
for _, l := range all {
l.sess.Kill()
}
err := m.parkLeads(context.Background())
if err != nil {
slog.Error("park lead sessions", "err", err)
}
}
func (m *Manager) LiveCount() int {
@@ -315,6 +343,9 @@ func (m *Manager) Usage(ctx context.Context) (Usage, error) {
return u, err
}
for _, sess := range running {
if sess.Owner != "" {
continue
}
u.Running = append(u.Running, api.TurnRef{SessionId: sess.Id, TaskId: sess.TaskId, Vendor: string(sess.Vendor)})
}
@@ -335,6 +366,10 @@ func (m *Manager) Usage(ctx context.Context) (Usage, error) {
}
func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) {
if sess.Owner != "" {
return m.start(ctx, sess, task, actor, text, images, api.AgentSessionStateIdle)
}
m.capMu.Lock()
defer m.capMu.Unlock()
@@ -506,15 +541,18 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string, imag
imgs = append(imgs, claude.Image{MediaType: b.ContentType, Data: data})
}
head, err := git.Head(l.dir)
if err != nil {
return err
payload := map[string]any{"text": text}
if l.owner == "" {
head, err := git.Head(l.dir)
if err != nil {
return err
}
payload["head"] = head
}
payload := map[string]any{"text": text, "head": head}
if len(images) > 0 {
payload["images"] = images
}
err = m.emit(ctx, l, actor, "user.message", payload)
err := m.emit(ctx, l, actor, "user.message", payload)
if err != nil {
return err
}
@@ -523,7 +561,8 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string, imag
l.interrupted = false
l.asked = false
l.bounced = false
l.compacting = actor == "byre" && text == handoffPrompt
l.wantCompact = false
l.compacting = isHandoffPrompt(actor, text)
l.rateLimited = nil
m.mu.Unlock()
@@ -557,22 +596,36 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
vendor: sess.Vendor,
taskID: task.Id,
projectID: task.ProjectId,
owner: sess.Owner,
}
g := taskGuidance(task)
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}
hooks := map[string][]claude.HookMatcher{
"UserPromptSubmit": {{Func: remindGuidance(g)}},
"Stop": {{Func: m.bounceProseQuestion(l)}},
"PostToolUse": {{Func: m.snapshotAfterTool(l)}},
}
if l.owner != "" {
l.dir = filepath.Join(m.leadRoot, url.PathEscape(l.owner))
err = os.MkdirAll(l.dir, 0o755)
if err != nil {
return nil, err
}
g = leadGuidance
tools = m.leadTools(l)
hooks = map[string][]claude.HookMatcher{"UserPromptSubmit": {{Func: remindGuidance(g)}}}
}
opts := claude.Options{
Cwd: task.WorktreePath,
Cwd: l.dir,
Model: sess.Model,
GitName: creator.GitName,
GitEmail: creator.GitEmail,
Guidance: g,
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}}},
Hooks: map[string][]claude.HookMatcher{
"UserPromptSubmit": {{Func: remindGuidance(g)}},
"Stop": {{Func: m.bounceProseQuestion(l)}},
"PostToolUse": {{Func: m.snapshotAfterTool(l)}},
},
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: tools}},
Hooks: hooks,
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
}
started, err := m.store.SessionStarted(ctx, sess.Id)
if err != nil {
@@ -594,7 +647,9 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
m.live[sess.Id] = l
go m.pump(l)
go m.snapshotTimer(l)
if l.owner == "" {
go m.snapshotTimer(l)
}
return l, nil
}
@@ -641,7 +696,9 @@ func (m *Manager) pump(l *live) {
if err != nil {
slog.Error("record session exit", "session", l.id, "err", err)
}
m.fileError(l, "The agent process exited mid-turn. Send a message to resume it.")
if l.owner == "" {
m.fileError(l, "The agent process exited mid-turn. Send a message to resume it.")
}
err = m.drain(ctx)
if err != nil {
@@ -701,6 +758,9 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if err != nil {
return err
}
if l.owner != "" {
return m.leadTurnEnded(ctx, l, res)
}
m.mu.Lock()
interrupted := l.interrupted
@@ -777,14 +837,7 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if err != nil || text == "" {
return err
}
m.store.Broadcast(api.Event{
Kind: "agent.delta",
ProjectId: &l.projectID,
TaskId: &l.taskID,
AgentSessionId: &l.id,
Actor: agentActor,
Payload: map[string]any{"text": text},
})
m.store.Broadcast(m.event(l, agentActor, "agent.delta", map[string]any{"text": text}))
return nil
case "rate_limit_event":
@@ -870,14 +923,23 @@ func (m *Manager) handleAssistantBlock(ctx context.Context, l *live, b claude.Bl
}
func (m *Manager) emit(ctx context.Context, l *live, actor, kind string, payload map[string]any) error {
return m.store.AppendEvent(ctx, api.Event{
return m.store.AppendEvent(ctx, m.event(l, actor, kind, payload))
}
func (m *Manager) event(l *live, actor, kind string, payload map[string]any) api.Event {
ev := api.Event{
Kind: kind,
ProjectId: &l.projectID,
TaskId: &l.taskID,
AgentSessionId: &l.id,
Actor: actor,
Payload: payload,
})
}
if l.owner != "" {
ev.Owner = &l.owner
return ev
}
ev.ProjectId = &l.projectID
ev.TaskId = &l.taskID
return ev
}
func decode(raw json.RawMessage) (any, error) {