package serve
import (
"context"
"crypto/rand"
"encoding/hex"
"sync"
"codey/internal/acp"
"codey/internal/agent"
"codey/internal/approval"
)
// session is one conversation. A client may run several at once against the
// same process — T3 Code opens one per thread — so everything a turn touches
// lives here rather than on the server.
type session struct {
id string
root string
// state is the conversation, and it outlives any one model choice:
// selecting another model rebuilds the agent around this same state, so
// the history survives the switch.
state *agent.State
policy *approval.Policy
// agents is this session's subagent supervisor, nil when the session may
// not spawn. agentsMoved is how a child wakes a turn that is waiting for
// it: buffered, so a signal nobody is waiting for costs nothing.
agents Agents
agentsMoved chan struct{}
// mu serializes turns. A second prompt while one is running waits for it,
// which is the same thing the TUI does and the only thing the agent's
// history can survive.
mu sync.Mutex
agent *agent.Agent
model string
emit *emitter
// cmu guards the cancellation of the turn in flight. It is separate from
// mu because a cancel arrives while mu is held by the turn it cancels.
cmu sync.Mutex
cancel context.CancelFunc
cancelled bool
}
// newSessionID names a session. It is ours to choose and the client only
// echoes it back, so the only requirements are that it is unique within the
// process and recognisable in a log.
func newSessionID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand does not fail in practice, and a session id is not a
// secret: a readable fallback beats failing to open a conversation.
return "codey-fallback"
}
return "codey-" + hex.EncodeToString(b[:])
}
// begin makes a turn cancellable and clears any cancellation left over from
// the last one. A stop button pressed after a turn ended must not cancel the
// next one.
func (s *session) begin(parent context.Context) context.Context {
ctx, cancel := context.WithCancel(parent)
s.cmu.Lock()
s.cancel, s.cancelled = cancel, false
s.cmu.Unlock()
return ctx
}
func (s *session) end() {
s.cmu.Lock()
cancel := s.cancel
s.cancel = nil
s.cmu.Unlock()
if cancel != nil {
cancel()
}
}
// stop cancels the turn in flight, if there is one. It reports nothing: the
// acknowledgement the client gets is the prompt coming back cancelled.
func (s *session) stop() {
s.cmu.Lock()
cancel := s.cancel
s.cancelled = true
s.cmu.Unlock()
if cancel != nil {
cancel()
}
}
func (s *session) wasCancelled() bool {
s.cmu.Lock()
defer s.cmu.Unlock()
return s.cancelled
}
// promptFor builds the approval prompt for this session: a permission request
// over the wire, and whatever the client answers.
//
// The tool call id is the one the emitter just announced. The agent emits
// tool_start before it asks for approval, and dispatch runs one call at a
// time, so "the call currently in flight" is exactly the one being decided —
// and attaching the prompt to that row is what lets a client show the question
// beside the command it is about.
func (s *session) promptFor(conn *acp.Conn, mode PermissionMode) approval.Prompt {
return func(ctx context.Context, req approval.Request) (approval.Decision, error) {
if mode.allows(req) {
return approval.Once, nil
}
params := acp.RequestPermissionParams{
SessionID: s.id,
ToolCall: acp.PermissionToolCall{
ToolCallID: s.emit.currentCall(),
Title: title(req),
Kind: toolKind(req.Tool),
Status: acp.StatusPending,
},
Options: options(req),
}
var result acp.RequestPermissionResult
if err := conn.Request(ctx, acp.MethodRequestPermission, params, &result); err != nil {
return approval.Deny, err
}
if result.Outcome.Outcome != acp.OutcomeSelected {
return approval.Deny, context.Canceled
}
switch result.Outcome.OptionID {
case acp.OptAllowAlways:
// "Always" means the tool from here on when the call itself cannot
// be pinned down — an opaque shell command is not a thing to
// remember by name.
if req.Rememberable {
return approval.AlwaysThisCommand, nil
}
return approval.AlwaysThisTool, nil
case acp.OptAllowOnce:
return approval.Once, nil
default:
return approval.Deny, nil
}
}
}
// options are the three answers offered. The ids are fixed literals rather
// than anything derived: a client that replies with an id it chose itself —
// which is what T3 Code's Cursor adapter does — must find one of these.
func options(req approval.Request) []acp.PermissionOption {
always := "Always allow this command"
if !req.Rememberable {
always = "Always allow " + req.Tool
}
return []acp.PermissionOption{
{OptionID: acp.OptAllowOnce, Name: "Allow once", Kind: acp.KindAllowOnce},
{OptionID: acp.OptAllowAlways, Name: always, Kind: acp.KindAllowAlways},
{OptionID: acp.OptRejectOnce, Name: "Reject", Kind: acp.KindRejectOnce},
}
}