// Package serve runs Codey as an agent another program drives, speaking the
// Agent Client Protocol over stdin and stdout.
//
// It is a front end, the same way the TUI and the one-shot `-p` mode are: it
// owns no agent behaviour and makes no decisions the other two do not. What it
// adds is a translation. The agent reports what it is doing through
// [agent.Agent.Observe] and asks permission through [agent.Agent.Approve];
// this package turns the first into `session/update` notifications and the
// second into a `session/request_permission` round trip, and turns the
// client's prompts back into turns.
//
// One process serves many sessions. A client that opens three threads gets
// three conversations here, each with its own history, its own tool set rooted
// where it asked, and its own record of what the user has permanently allowed
// — because "always allow this" is a statement about one conversation, not
// about the process.
package serve
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"sync"
"github.com/johannSo/codey/internal/acp"
"github.com/johannSo/codey/internal/agent"
"github.com/johannSo/codey/internal/approval"
"github.com/johannSo/codey/internal/llm"
"github.com/johannSo/codey/internal/subagent"
)
// PermissionMode is how tool calls are gated, chosen on the command line by
// whoever launched us. It is a floor, not a policy: the client can still be
// asked about anything the mode does not settle.
type PermissionMode string
const (
// ModeAsk asks about every mutating call. The default, and the only mode
// that is safe against a tree you care about.
ModeAsk PermissionMode = "default"
// ModeAcceptEdits lets file edits through and still asks before a command
// runs. An edit is reviewable after the fact; a command is not.
ModeAcceptEdits PermissionMode = "acceptEdits"
// ModeAuto asks only about calls that cannot be pinned down — an opaque
// shell command, a search whose query is different every time. Everything
// whose exact shape could have been remembered is allowed.
ModeAuto PermissionMode = "auto"
// ModeFullAccess never asks. Only against a throwaway clone or inside a
// container.
ModeFullAccess PermissionMode = "full-access"
)
// allows reports whether the mode settles this call without asking.
func (m PermissionMode) allows(req approval.Request) bool {
// A prompt a standing ask rule insisted on is never settled by the floor:
// the rule was written to stop exactly that.
if req.Forced != "" {
return false
}
switch m {
case ModeFullAccess:
return true
case ModeAuto:
return req.Rememberable
case ModeAcceptEdits:
return req.Tool == "edit_file" || req.Tool == "write_file"
default:
return false
}
}
// AgentRequest is one session asking to be built.
type AgentRequest struct {
// Root is the directory the client asked to work in, which is not
// necessarily the one this process was started in: a client that manages
// worktrees hands out a different one per thread.
Root string
// Model is empty for whatever the process was configured with.
Model string
// State is the conversation. It is passed in rather than created by the
// builder so that selecting another model can rebuild the agent around a
// history that survives the switch.
State *agent.State
// Approve gates tool calls — the session's own and its subagents'. What
// you allow, you allow for the conversation, not for one agent in it.
Approve *approval.Policy
}
// AgentSet is what comes back: the agent, and the subagents it may start.
type AgentSet struct {
Agent *agent.Agent
// Agents is nil when this session may not spawn.
Agents Agents
}
// Agents is a session's subagent supervisor, as this package needs it.
// *subagent.Supervisor is the implementation; the interface is here so a test
// can stand in for one without starting real children.
type Agents interface {
// Subscribe registers the two callbacks, which are called from the
// children's own goroutines.
Subscribe(observe func(subagent.Event), changed func())
// TakeReports drains the reports nobody has been handed yet, and
// ClaimReport says whether this front end is still the one to hand a given
// report over — agent_wait may have collected it in the meantime.
TakeReports() []subagent.Info
ClaimReport(id string) bool
Snapshot() []subagent.Info
Shutdown()
}
// NewAgentFunc builds one session.
type NewAgentFunc func(AgentRequest) (AgentSet, error)
// Options configures a [Server].
type Options struct {
// NewAgent is required.
NewAgent NewAgentFunc
// Version is reported in the initialize handshake.
Version string
// Permission is the approval floor for every session.
Permission PermissionMode
// Models is the picker the client shows, and DefaultModel is what is
// selected in it. Both may be empty, in which case the client is told
// nothing and uses whatever the agent was configured with.
Models []acp.ModelInfo
DefaultModel string
}
// Server is the ACP end of the process.
type Server struct {
opts Options
conn *acp.Conn
mu sync.Mutex
sessions map[string]*session
}
// New builds a server. Nothing runs until [Server.Run].
func New(opts Options) *Server {
return &Server{opts: opts, sessions: make(map[string]*session)}
}
// Run serves one connection until the stream ends. A clean end of input is how
// a client says goodbye, and it returns nil.
func (s *Server) Run(ctx context.Context, r io.Reader, w io.Writer) error {
s.conn = acp.NewConn(r, w, s)
defer s.shutdown()
return s.conn.Serve(ctx)
}
// shutdown stops every session's subagents. A child outliving the connection
// would keep editing a tree nobody is watching, and nobody would be left to
// read what it found.
func (s *Server) shutdown() {
s.mu.Lock()
sessions := make([]*session, 0, len(s.sessions))
for _, sess := range s.sessions {
sessions = append(sessions, sess)
}
s.mu.Unlock()
for _, sess := range sessions {
if sess.agents != nil {
sess.agents.Shutdown()
}
}
}
// Initialize answers the handshake.
//
// The protocol version we answer with is ours, not the client's: agreeing to a
// revision we do not implement would be a handshake that succeeds and a
// session that misbehaves later, which is harder to diagnose than a refusal.
func (s *Server) Initialize(_ context.Context, _ acp.InitializeRequest) (acp.InitializeResponse, error) {
resp := acp.InitializeResponse{
ProtocolVersion: acp.ProtocolVersion,
AgentInfo: &acp.Implementation{Name: "codey", Title: "Codey", Version: s.opts.Version},
AgentCapabilities: &acp.AgentCapabilities{
// Images go through: a screenshot is the fastest way to say what
// is wrong with a layout. Session loading does not, yet — and not
// advertising it is what stops a client from asking.
PromptCapabilities: &acp.PromptCapabilities{Image: true},
},
AuthMethods: []acp.AuthMethod{},
}
// The model list, for a client that is only probing. A health check
// spawns the agent, shakes hands and stops; answering here is what puts
// the models on its provider card without it having to open a session it
// will not use.
if len(s.opts.Models) > 0 {
resp.Meta = map[string]any{acp.ModelStateMeta: s.modelState()}
}
return resp, nil
}
func (s *Server) modelState() *acp.SessionModelState {
if len(s.opts.Models) == 0 {
return nil
}
return &acp.SessionModelState{
CurrentModelID: s.opts.DefaultModel,
AvailableModels: s.opts.Models,
}
}
// Authenticate always succeeds.
//
// Codey takes its credential from the environment before this process starts,
// so there is nothing to log in to and no method id worth checking. Clients
// send this unconditionally and do not catch a failure, so an error here would
// be a session that never opens, over a question we were never asking.
func (s *Server) Authenticate(context.Context, acp.AuthenticateRequest) error { return nil }
// NewSession opens a conversation rooted where the client asked.
func (s *Server) NewSession(_ context.Context, req acp.NewSessionRequest) (acp.NewSessionResponse, error) {
if req.Cwd == "" {
return acp.NewSessionResponse{}, acp.Errorf(acp.CodeInvalidParams, "session/new needs a cwd")
}
sess := &session{
id: newSessionID(),
root: req.Cwd,
state: agent.NewState(),
model: s.opts.DefaultModel,
agentsMoved: make(chan struct{}, 1),
}
sess.emit = newEmitter(notifier(s.conn, sess.id))
sess.policy = approval.New(sess.promptFor(s.conn, s.opts.Permission))
sess.policy.YOLO = s.opts.Permission == ModeFullAccess
if err := s.build(sess); err != nil {
return acp.NewSessionResponse{}, err
}
s.mu.Lock()
s.sessions[sess.id] = sess
s.mu.Unlock()
resp := acp.NewSessionResponse{SessionID: sess.id}
if state := s.modelState(); state != nil {
state.CurrentModelID = sess.model
resp.Models = state
}
return resp, nil
}
// build attaches an agent to a session, or replaces the one it has. The caller
// holds whatever lock the session needs.
//
// A rebuild — which is what selecting another model is — reuses the subagents
// the session already has. They belong to the conversation, not to the model
// the parent happens to be running on, and killing four working children
// because somebody changed a dropdown would be a surprising thing to do.
func (s *Server) build(sess *session) error {
built, err := s.opts.NewAgent(AgentRequest{
Root: sess.root,
Model: sess.model,
State: sess.state,
Approve: sess.policy,
})
if err != nil {
return acp.Errorf(acp.CodeInternal, "cannot start an agent in %s: %v", sess.root, err)
}
built.Agent.Approve = sess.policy
built.Agent.Observe = sess.emit.handle
sess.agent = built.Agent
if sess.agents == nil && built.Agents != nil {
sess.agents = built.Agents
sess.watchAgents()
}
return nil
}
// Prompt runs one turn.
func (s *Server) Prompt(ctx context.Context, req acp.PromptRequest) (acp.PromptResponse, error) {
sess, err := s.lookup(req.SessionID)
if err != nil {
return acp.PromptResponse{}, err
}
blocks := promptBlocks(req.Prompt)
if len(blocks) == 0 {
return acp.PromptResponse{StopReason: acp.StopEndTurn}, nil
}
sess.mu.Lock()
defer sess.mu.Unlock()
turnCtx := sess.begin(ctx)
defer sess.end()
runErr := sess.runTurn(turnCtx, blocks)
// Whatever happened, say the last of what was written before reporting how
// the turn ended. A buffer dropped on the way out is a sentence the user
// never sees.
sess.emit.Flush()
switch {
case sess.wasCancelled(), errors.Is(runErr, context.Canceled):
// Not an error: the session is intact and the next message can correct
// course. That is the whole point of a stop button.
return acp.PromptResponse{StopReason: acp.StopCancelled}, nil
case errors.Is(runErr, agent.ErrMaxTurns):
sess.emit.chunk(acp.UpdateAgentMessageChunk, runErr.Error())
return acp.PromptResponse{StopReason: acp.StopMaxTurnRequests}, nil
case runErr != nil:
// The failure goes in the transcript rather than into a protocol error
// alone, because a protocol error is a line in the client's log and
// this is something the user has to read to know what to do next.
sess.emit.chunk(acp.UpdateAgentMessageChunk, "codey: "+runErr.Error())
return acp.PromptResponse{StopReason: acp.StopRefusal}, nil
}
return acp.PromptResponse{StopReason: acp.StopEndTurn}, nil
}
// maxReportRounds caps how many times one turn may be restarted to hand a
// subagent's report to the parent. A parent that answers every report by
// spawning another agent would otherwise never finish the turn it is in, and
// a cap is a cheaper guard than reasoning about when that is reasonable.
//
// Hitting it ends the turn with the outstanding reports still queued and
// unclaimed, so the next turn hands them over rather than losing them.
const maxReportRounds = 32
// runTurn runs the agent, and then keeps the turn open for as long as this
// session's subagents are still working.
//
// A turn is the protocol's unit of work, and a child's output belongs inside
// the one that started it: end the turn while four agents are still running
// and their rows have nowhere to appear, their reports have nobody to be
// handed to, and the thread looks finished while the machine is busy. So the
// turn ends when the work does.
//
// Each report the parent is entitled to comes back as a message, which is what
// the parent's own prompt promised would happen, and that runs the agent
// again. Cancelling breaks out of all of it: children keep working, and the
// next turn will pick their reports up.
func (s *session) runTurn(ctx context.Context, blocks []llm.ContentBlock) error {
if _, err := s.agent.RunBlocks(ctx, blocks); err != nil {
return err
}
for rounds := 0; ; {
if ctx.Err() != nil {
return ctx.Err()
}
// Counted against report deliveries only. Waking up because a child
// changed status and finding nothing to hand over costs nothing and
// must not eat the budget, or a long job would end its own turn.
if rounds >= maxReportRounds {
return nil
}
if text := s.reportBlocks(); text != "" {
rounds++
// What the agent said before the report arrived is finished. Left
// buffered, the end of one message and the start of the next would
// be glued into one chunk, and the client would show two answers
// as a single run-on sentence.
s.emit.Flush()
if _, err := s.agent.RunBlocks(ctx, []llm.ContentBlock{llm.TextBlock(text)}); err != nil {
return err
}
continue
}
if !s.agentsLive() {
return nil
}
// Nothing to hand over and something still running: wait to be woken.
select {
case <-s.agentsMoved:
case <-ctx.Done():
return ctx.Err()
}
}
}
// Cancel stops the turn in flight. An unknown session is not an error worth
// reporting: a stop arriving after the session closed asks for something that
// already happened.
func (s *Server) Cancel(_ context.Context, params acp.CancelParams) {
if sess, err := s.lookup(params.SessionID); err == nil {
sess.stop()
}
}
// Extra answers the methods outside the core set.
func (s *Server) Extra(_ context.Context, method string, params json.RawMessage) (any, error) {
switch method {
case "session/set_model":
var req acp.SetModelRequest
if err := json.Unmarshal(params, &req); err != nil {
return nil, acp.Errorf(acp.CodeInvalidParams, "cannot decode params: %v", err)
}
sess, err := s.lookup(req.SessionID)
if err != nil {
return nil, err
}
sess.mu.Lock()
defer sess.mu.Unlock()
if req.ModelID == sess.model {
return struct{}{}, nil
}
sess.model = req.ModelID
// The conversation comes along: only the agent around it is replaced.
if err := s.build(sess); err != nil {
return nil, err
}
return struct{}{}, nil
case "session/set_config_option":
// Accepted and ignored. Some clients send one unconditionally on every
// model selection, and refusing an option we never advertised would
// fail a turn over a setting we do not have.
return struct{}{}, nil
default:
return nil, acp.Errorf(acp.CodeMethodNotFound, "unknown method %q", method)
}
}
func (s *Server) lookup(id string) (*session, error) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[id]
if !ok {
return nil, acp.Errorf(acp.CodeInvalidParams, "no such session %q", id)
}
return sess, nil
}
// promptBlocks turns the client's content blocks into the agent's.
//
// Text and images pass through. Anything else is named rather than dropped: a
// client that attached a file we cannot decode has told the user it sent
// something, and a model that never hears about it will answer a question
// nobody asked.
func promptBlocks(blocks []acp.ContentBlock) []llm.ContentBlock {
var out []llm.ContentBlock
var text strings.Builder
for _, b := range blocks {
switch b.Type {
case "text":
if b.Text != "" {
if text.Len() > 0 {
text.WriteString("\n")
}
text.WriteString(b.Text)
}
case "image":
if b.Data != "" && b.MimeType != "" {
// Text so far goes in first, so the image lands where it was
// written rather than at the end of the message.
if t := strings.TrimSpace(text.String()); t != "" {
out = append(out, llm.TextBlock(t))
text.Reset()
}
out = append(out, llm.ImageBlock(b.MimeType, b.Data))
}
default:
if name := attachmentName(b); name != "" {
if text.Len() > 0 {
text.WriteString("\n")
}
fmt.Fprintf(&text, "[attached %s: %s]", b.Type, name)
}
}
}
if t := strings.TrimSpace(text.String()); t != "" {
out = append(out, llm.TextBlock(t))
}
return out
}
func attachmentName(b acp.ContentBlock) string {
switch {
case b.Name != "":
return b.Name
case b.URI != "":
return b.URI
default:
return ""
}
}