This commit is contained in:
Ian Gulliver
2026-10-16 09:51:32 -07:00
parent 59c2b2cb9c
commit 48ef2d6bb5
12 changed files with 343 additions and 192 deletions
+33 -1
View File
@@ -42,6 +42,10 @@ func leadActor(login string) string {
return login + " (lead)"
}
func humanActor(actor string) bool {
return actor != "byre" && actor != agentActor && !strings.HasSuffix(actor, leadActor(""))
}
func (m *Manager) LeadSessions(ctx context.Context, owner string) ([]api.AgentSession, error) {
return m.store.LeadSessions(ctx, owner)
}
@@ -199,7 +203,7 @@ func (m *Manager) leadStateText(ctx context.Context, owner string) (string, erro
pane = fmt.Sprintf("Your right pane shows project %s (%s) on its %s tab.", project.Name, project.Id, f.Tab)
}
m.mu.Lock()
focused := m.paneFocused[owner]
focused := m.paneFocus[owner].Focused
m.mu.Unlock()
if focused {
pane += " The human is working in it."
@@ -313,6 +317,10 @@ func (m *Manager) warnLeadContext(ctx context.Context, l *live) error {
func (m *Manager) Observe(ev api.Event) {
switch ev.Kind {
case "inbox.created", "inbox.updated", "task.created", "task.merge_started", "task.merged", "task.discarded", "turn.ended", "task.workflow_stage", "task.workflow_finished":
case "user.message":
if ev.TaskId == nil || !humanActor(ev.Actor) {
return
}
default:
return
}
@@ -370,6 +378,9 @@ func (m *Manager) signal(ctx context.Context, ev api.Event) error {
}
func (m *Manager) freedText(ctx context.Context, owner string, ev api.Event) (string, error) {
if ev.Kind == "user.message" {
return m.waitingText(ctx, owner, ev)
}
switch ev.Kind {
case "inbox.updated", "task.merge_started", "task.merged", "task.discarded":
default:
@@ -392,6 +403,24 @@ func (m *Manager) freedText(ctx context.Context, owner string, ev api.Event) (st
return fmt.Sprintf("Your right pane is free: %s %s.", m.taskLabel(ctx, *f.TaskId), finished), nil
}
func (m *Manager) waitingText(ctx context.Context, owner string, ev api.Event) (string, error) {
if ev.Actor != owner {
return "", nil
}
f, err := m.store.GetFocus(ctx, owner)
if err != nil {
return "", err
}
if f.TaskId == nil || *f.TaskId != deref(ev.TaskId) {
return "", nil
}
waiting, err := m.paneWaiting(ctx, owner, f)
if err != nil || !waiting {
return "", err
}
return fmt.Sprintf("Your right pane is free: the human is waiting on %s's agent.", m.taskLabel(ctx, *f.TaskId)), nil
}
func (m *Manager) taskLabel(ctx context.Context, taskID string) string {
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
@@ -450,6 +479,9 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error)
case "task.workflow_finished":
return fmt.Sprintf("%s finished workflow %q:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["workflow"], clip(fmt.Sprint(ev.Payload["output"]), 1500)), nil
case "user.message":
return fmt.Sprintf("%s sent %s a message; its agent is working on it.", ev.Actor, m.taskLabel(ctx, deref(ev.TaskId))), nil
case "turn.ended":
if ev.Payload["summary"] != true {
return "", nil
+79 -4
View File
@@ -327,7 +327,8 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
"Tabs: transcript, changes, files, terminal (a shell in the task's worktree), inbox (the task's open items), reviews (the task's reviews by reviewer agents), 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). "+
"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), "+
"or it is on the transcript tab of a task whose agent is mid-turn, the human has no unsent text there, and either the human sent the message that started that turn since the pane last moved or the pane moved there 10 seconds ago or more; byre signals you \"Your right pane is free\" when either happens. "+
"Also refused while the pane is on the project tab and the human started a TODO item there in the last 10 seconds, unless they switched task or tab or messaged you since; byre signals you \"The start hold is over\" when those 10 seconds pass with the pane still on the project tab.",
`{"type": "object", "properties": {
"task_id": {"type": "string"},
@@ -356,7 +357,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
return "Already shown on that tab; the tab order was updated.", nil
}
m.mu.Lock()
focused := m.paneFocused[user.Login]
focused := m.paneFocus[user.Login].Focused
startedTodo := m.startedTodo[user.Login]
m.mu.Unlock()
if time.Since(startedTodo) < startHold && current.Tab == api.TabProject {
@@ -367,7 +368,14 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
if err != nil {
return "", err
}
waiting := false
if finished == "" {
waiting, err = m.paneWaiting(ctx, user.Login, current)
if err != nil {
return "", err
}
}
if finished == "" && !waiting {
return "", fmt.Errorf("%w: the human is working in the right pane, so it was left as it is; tell them in chat instead", ErrConflict)
}
}
@@ -417,9 +425,53 @@ func (m *Manager) SetFocus(ctx context.Context, login string, f api.Focus, actor
if actor == login {
m.setStartedTodo(login, false)
}
at := time.Now()
m.mu.Lock()
m.paneMoved[login] = at
m.mu.Unlock()
if actor == login {
time.AfterFunc(watchHold, func() {
err := m.endWatchHold(context.Background(), login, at)
if err != nil {
slog.Error("end watch hold", "login", login, "err", err)
}
})
}
return m.store.SetFocus(ctx, login, f, actor)
}
func (m *Manager) endWatchHold(ctx context.Context, login string, at time.Time) error {
m.mu.Lock()
moved := m.paneMoved[login]
focused := m.paneFocus[login].Focused
m.mu.Unlock()
if !moved.Equal(at) || !focused {
return nil
}
f, err := m.store.GetFocus(ctx, login)
if err != nil {
return err
}
if f.TaskId == nil {
return nil
}
turn, running, err := m.store.RunningTurn(ctx, *f.TaskId)
if err != nil || !running || (turn.Actor == login && turn.At.After(at)) {
return err
}
waiting, err := m.paneWaiting(ctx, login, f)
if err != nil || !waiting {
return err
}
text := fmt.Sprintf("Your right pane is free: the human has watched %s's agent work for 10 seconds without typing.", m.taskLabel(ctx, *f.TaskId))
err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: login, Kind: store.LeadSignal, Actor: "byre", Text: text})
if err != nil {
return err
}
m.deliverLead(login)
return nil
}
func sameView(a, b api.Focus) bool {
if a.Tab != b.Tab {
return false
@@ -464,10 +516,33 @@ func (m *Manager) paneFinished(ctx context.Context, f api.Focus) (string, error)
return inboxEmptied, nil
}
func (m *Manager) SetPaneFocus(login string, focused bool) {
const watchHold = 10 * time.Second
func (m *Manager) paneWaiting(ctx context.Context, login string, f api.Focus) (bool, error) {
if f.TaskId == nil || f.Tab != api.TabTranscript {
return false, nil
}
m.mu.Lock()
drafting := m.paneFocus[login].Drafting
reported := m.paneReported[login]
moved := m.paneMoved[login]
m.mu.Unlock()
turn, running, err := m.store.RunningTurn(ctx, *f.TaskId)
if err != nil || !running {
return false, err
}
if turn.Actor == login && turn.At.After(moved) {
// The report of the composer emptying on send can arrive after the turn starts.
return !drafting || reported.Before(turn.At), nil
}
return !drafting && time.Since(moved) >= watchHold, nil
}
func (m *Manager) SetPaneFocus(login string, p api.PaneFocus) {
m.mu.Lock()
defer m.mu.Unlock()
m.paneFocused[login] = focused
m.paneFocus[login] = p
m.paneReported[login] = time.Now()
}
const startHold = 10 * time.Second
+6 -2
View File
@@ -43,7 +43,9 @@ type Manager struct {
closing bool
rateLimits map[api.AgentSessionVendor]map[string]any
leadAnswered map[string]bool
paneFocused map[string]bool
paneFocus map[string]api.PaneFocus
paneReported map[string]time.Time
paneMoved map[string]time.Time
startedTodo map[string]time.Time
deliverMu sync.Mutex
@@ -105,7 +107,9 @@ func New(st *store.Store, worktreeRoot, leadRoot, codexHome string) *Manager {
live: map[string]*live{},
rateLimits: map[api.AgentSessionVendor]map[string]any{},
leadAnswered: map[string]bool{},
paneFocused: map[string]bool{},
paneFocus: map[string]api.PaneFocus{},
paneReported: map[string]time.Time{},
paneMoved: map[string]time.Time{},
startedTodo: map[string]time.Time{},
signals: make(chan api.Event, 1024),
snaps: map[string]*snapState{},