2062 lines
64 KiB
Go
2062 lines
64 KiB
Go
package acp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/fileutil"
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
"reasonix/internal/jobs"
|
|
"reasonix/internal/plugin"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/store"
|
|
"reasonix/internal/tool/builtin"
|
|
)
|
|
|
|
// SessionParams is everything a Factory needs to assemble one ACP session's
|
|
// controller. Sink is owned by this package (an updateSink bound to the session
|
|
// id) and must be wired into the controller's event sink; the controller's
|
|
// interactive approval (see control.Controller.EnableInteractiveApproval) then
|
|
// routes "ask" decisions back through that sink as ApprovalRequest events, which
|
|
// the sink forwards to the client over session/request_permission.
|
|
//
|
|
// Cwd roots the session's file tools and bash (built via builtin.Workspace).
|
|
// Model and EffortOverride are optional session-local provider selectors from
|
|
// ACP config options. MCPServers are the MCP servers the client asked the agent
|
|
// to connect for this session. OnSessionRecovered is the service's bookkeeping
|
|
// hook for automatic transcript recovery branches (see sessionRecoveredHandler);
|
|
// factories must wire it into the controller they build.
|
|
type SessionParams struct {
|
|
Cwd string
|
|
MCPServers []plugin.Spec
|
|
Sink event.Sink
|
|
Model string
|
|
EffortOverride *string
|
|
OnSessionRecovered func(control.SessionRecoveryInfo) error
|
|
// FileOverlay and Terminal are non-nil when the client advertised the
|
|
// matching capability at initialize: file tools then see unsaved editor
|
|
// buffers, and foreground bash can run in a client-owned terminal.
|
|
// Factories thread them into the controller's tool assembly.
|
|
FileOverlay builtin.FileOverlay
|
|
Terminal builtin.TerminalRunner
|
|
}
|
|
|
|
// Factory builds the per-session controller. The composition root (the cli's
|
|
// `reasonix acp` command) implements it by reusing setup()'s assembly: a
|
|
// Provider for Model, a tool Registry rooted at Cwd via builtin.Workspace, a
|
|
// per-session MCP host from MCPServers, the event Sink, all wired into a
|
|
// control.Controller. The returned controller owns its own cleanup (Close stops
|
|
// MCP subprocesses), so the service calls ctrl.Close() on teardown.
|
|
type Factory interface {
|
|
NewSession(ctx context.Context, p SessionParams) (*control.Controller, error)
|
|
}
|
|
|
|
// SessionConfigStateParams asks the Factory for normalized session config
|
|
// selectors. Empty Model means the Factory should use its configured default.
|
|
// Nil EffortOverride means provider config wins; a non-nil empty string means
|
|
// provider default for this session.
|
|
type SessionConfigStateParams struct {
|
|
Cwd string
|
|
Model string
|
|
EffortOverride *string
|
|
}
|
|
|
|
// SessionConfigState is the complete ACP-visible config state for a session.
|
|
type SessionConfigState struct {
|
|
Model string
|
|
EffortOverride *string
|
|
Models *SessionModelState
|
|
ConfigOptions []SessionConfigOption
|
|
}
|
|
|
|
// SessionConfigStateProvider lets a Factory expose model and effort selectors
|
|
// without making the ACP transport depend on a concrete config backend.
|
|
type SessionConfigStateProvider interface {
|
|
SessionConfigState(ctx context.Context, p SessionConfigStateParams) (SessionConfigState, error)
|
|
}
|
|
|
|
// SessionDirProvider lets a Factory expose the persistent session directory
|
|
// without forcing session/list to build a controller first.
|
|
type SessionDirProvider interface {
|
|
SessionDir() string
|
|
}
|
|
|
|
// AgentInfo identifies this agent to clients in the initialize reply.
|
|
type AgentInfo struct {
|
|
Name string
|
|
Version string
|
|
}
|
|
|
|
// Serve runs an ACP agent on r/w (stdin/stdout in production) until the input
|
|
// ends or ctx is cancelled. It owns the JSON-RPC connection and the session
|
|
// registry; the Factory supplies the kernel wiring. This is the single entry
|
|
// point the `reasonix acp` command calls.
|
|
//
|
|
// stdout is the JSON-RPC channel: callers must keep all other output (logs,
|
|
// diagnostics) off w and on stderr, or the wire corrupts.
|
|
func Serve(ctx context.Context, r io.Reader, w io.Writer, factory Factory, info AgentInfo) error {
|
|
conn := NewConn(r, w)
|
|
svc := &service{
|
|
conn: conn,
|
|
factory: factory,
|
|
info: info,
|
|
sessions: make(map[string]*acpSession),
|
|
}
|
|
conn.Handle("initialize", svc.initialize)
|
|
conn.Handle("authenticate", svc.authenticate)
|
|
conn.Handle("session/new", svc.sessionNew)
|
|
conn.Handle("session/load", svc.sessionLoad)
|
|
conn.Handle("session/resume", svc.sessionResume)
|
|
conn.Handle("session/prompt", svc.sessionPrompt)
|
|
conn.Handle("session/set_config_option", svc.sessionSetConfigOption)
|
|
conn.Handle("session/set_model", svc.sessionSetModel)
|
|
conn.Handle("session/set_mode", svc.sessionSetMode)
|
|
conn.Handle("session/close", svc.sessionClose)
|
|
conn.Handle("session/list", svc.sessionList)
|
|
conn.Handle("session/delete", svc.sessionDelete)
|
|
conn.HandleNotify("session/cancel", svc.sessionCancel)
|
|
defer svc.closeAll()
|
|
return conn.Serve(ctx)
|
|
}
|
|
|
|
// service holds the connection-wide ACP state: the factory, agent identity, and
|
|
// the live session registry.
|
|
type service struct {
|
|
conn *Conn
|
|
factory Factory
|
|
info AgentInfo
|
|
|
|
mu sync.Mutex
|
|
sessions map[string]*acpSession
|
|
// clientCaps is what the client offered at initialize (fs proxy, host
|
|
// terminals). Zero until initialize arrives; sessions opened later bind a
|
|
// clientIO built from it.
|
|
clientCaps ClientCapabilities
|
|
}
|
|
|
|
func (s *service) setClientCapabilities(caps ClientCapabilities) {
|
|
s.mu.Lock()
|
|
s.clientCaps = caps
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *service) clientCapabilities() ClientCapabilities {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.clientCaps
|
|
}
|
|
|
|
// bindClientIO fills SessionParams' overlay/terminal fields from the client's
|
|
// declared capabilities. The nil checks keep absent capabilities as nil
|
|
// interface fields (a typed-nil *clientIO must never reach the interface).
|
|
func (s *service) bindClientIO(p *SessionParams, sessionID string) {
|
|
io := newClientIO(s.conn, sessionID, s.clientCapabilities())
|
|
if !io.hasAny() {
|
|
return
|
|
}
|
|
if fo := io.fileOverlay(); fo != nil {
|
|
p.FileOverlay = fo
|
|
}
|
|
if tr := io.terminalRunner(); tr != nil {
|
|
p.Terminal = tr
|
|
}
|
|
}
|
|
|
|
// acpController is the slice of the controller's driving port the ACP transport
|
|
// drives: session lifecycle + persistence, turn execution, interactive approval,
|
|
// and the capability surface (commands/skills/MCP prompts). ACP never touches
|
|
// goals, checkpoints, or memory, so it depends on those sub-ports only — not the
|
|
// concrete *control.Controller.
|
|
type acpController interface {
|
|
control.Lifecycle
|
|
control.TurnControl
|
|
control.Approvals
|
|
control.Capabilities
|
|
control.SessionPersistence
|
|
// Goals is included for the session-mode surface only (PlanMode read-back
|
|
// after a turn); the goal FSM itself is not driven over ACP.
|
|
control.Goals
|
|
}
|
|
|
|
// acpSession is one open session: its controller, the on-disk transcript path
|
|
// (empty when persistence is off), and the cancel func of the in-flight turn
|
|
// (nil when idle) so session/cancel can abort it.
|
|
type acpSession struct {
|
|
id string
|
|
ctrl acpController
|
|
sink *updateSink
|
|
transcript string
|
|
cwd string
|
|
mcpServers []plugin.Spec
|
|
model string
|
|
// nil means use config; non-nil empty string means provider default.
|
|
effortOverride *string
|
|
// modeID is the ACP session mode last reported to the client (default |
|
|
// plan | auto). Guarded by mu; compared after each turn so controller-side
|
|
// flips (plan mode auto-exit) surface as current_mode_update.
|
|
modeID string
|
|
pendingConfig *SessionConfigState
|
|
title string
|
|
createdAt time.Time
|
|
updatedAt time.Time
|
|
|
|
mu sync.Mutex
|
|
cancel context.CancelFunc
|
|
done chan struct{}
|
|
running bool
|
|
deleted bool
|
|
// lease is the session lease guarding transcript against other runtimes
|
|
// (a desktop window, the CLI) for the life of this session. Held from
|
|
// session/new / session/load and released on close/delete/teardown.
|
|
// Config rebuilds keep the same transcript; when a snapshot conflict
|
|
// retargets the controller to a recovery branch, sessionRecoveredHandler
|
|
// moves transcript and this lease to the recovery file at commit time.
|
|
lease *agent.SessionLease
|
|
// maintenanceDone is non-nil while session-owned maintenance, such as an
|
|
// idle config rebuild, is in flight outside mu.
|
|
maintenanceDone chan struct{}
|
|
}
|
|
|
|
func (s *acpSession) begin(ctx context.Context) (context.Context, context.CancelFunc, bool) {
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
s.mu.Lock()
|
|
// A queued pendingConfig blocks new turns so a prompt never runs on the
|
|
// outgoing config. The turn or maintenance that queued it applies it from
|
|
// its defer, so no new turn is needed to drain the queue.
|
|
if s.running || s.deleted || s.maintenanceDone != nil || s.pendingConfig != nil {
|
|
s.mu.Unlock()
|
|
cancel()
|
|
return nil, nil, false
|
|
}
|
|
s.running = true
|
|
s.cancel = cancel
|
|
s.done = make(chan struct{})
|
|
s.mu.Unlock()
|
|
return runCtx, cancel, true
|
|
}
|
|
|
|
func (s *acpSession) finish() {
|
|
s.mu.Lock()
|
|
done := s.done
|
|
s.running = false
|
|
s.cancel = nil
|
|
s.done = nil
|
|
s.mu.Unlock()
|
|
if done != nil {
|
|
close(done)
|
|
}
|
|
}
|
|
|
|
func (s *acpSession) abort() {
|
|
s.mu.Lock()
|
|
c := s.cancel
|
|
s.mu.Unlock()
|
|
if c != nil {
|
|
c()
|
|
}
|
|
}
|
|
|
|
func (s *acpSession) abortAndWait() {
|
|
s.mu.Lock()
|
|
c := s.cancel
|
|
done := s.done
|
|
maintenanceDone := s.maintenanceDone
|
|
s.mu.Unlock()
|
|
if c != nil {
|
|
c()
|
|
}
|
|
if done != nil {
|
|
<-done
|
|
}
|
|
if maintenanceDone != nil {
|
|
<-maintenanceDone
|
|
}
|
|
}
|
|
|
|
func (s *acpSession) deleteAndWait() {
|
|
s.mu.Lock()
|
|
s.deleted = true
|
|
c := s.cancel
|
|
done := s.done
|
|
maintenanceDone := s.maintenanceDone
|
|
s.mu.Unlock()
|
|
if c != nil {
|
|
c()
|
|
}
|
|
if done != nil {
|
|
<-done
|
|
}
|
|
if maintenanceDone != nil {
|
|
<-maintenanceDone
|
|
}
|
|
}
|
|
|
|
func (s *acpSession) finishMaintenance(done chan struct{}) {
|
|
if done == nil {
|
|
return
|
|
}
|
|
closeDone := false
|
|
s.mu.Lock()
|
|
if s.maintenanceDone == done {
|
|
s.maintenanceDone = nil
|
|
closeDone = true
|
|
}
|
|
s.mu.Unlock()
|
|
if closeDone {
|
|
close(done)
|
|
}
|
|
}
|
|
|
|
// swapModeID records the mode reported to the client and returns the previous
|
|
// value, so callers can emit current_mode_update only on change.
|
|
func (s *acpSession) swapModeID(id string) (old string) {
|
|
s.mu.Lock()
|
|
old = s.modeID
|
|
s.modeID = id
|
|
s.mu.Unlock()
|
|
return old
|
|
}
|
|
|
|
// currentModeID returns the mode last reported to the client.
|
|
func (s *acpSession) currentModeID() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.modeID == "" {
|
|
return sessionModeDefault
|
|
}
|
|
return s.modeID
|
|
}
|
|
|
|
// currentCtrl returns the session's controller under mu. rebuildSession swaps
|
|
// ctrl while holding mu, so any read of the field outside mu races with a
|
|
// concurrent config rebuild; always go through this accessor unless mu is
|
|
// already held.
|
|
func (s *acpSession) currentCtrl() acpController {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.ctrl
|
|
}
|
|
|
|
// releaseSessionLease drops the session's transcript lease, if any. Idempotent.
|
|
func (s *acpSession) releaseSessionLease() {
|
|
s.mu.Lock()
|
|
lease := s.lease
|
|
s.lease = nil
|
|
s.mu.Unlock()
|
|
if lease != nil {
|
|
lease.Release()
|
|
}
|
|
}
|
|
|
|
// sessionLeaseBindError maps a lease-acquisition failure to the protocol
|
|
// error the client sees: a held session names its holder with the shared CLI
|
|
// wording; anything else is an internal error.
|
|
func sessionLeaseBindError(method string, err error) *RPCError {
|
|
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
|
return &RPCError{
|
|
Code: ErrInvalidRequest,
|
|
Message: method + ": " + control.SessionInUseMessage(err) + "; " + control.SessionLeaseCloseHint,
|
|
}
|
|
}
|
|
return &RPCError{Code: ErrInternal, Message: method + ": session lease: " + err.Error()}
|
|
}
|
|
|
|
// sessionRecoveredHandler returns the OnSessionRecovered callback wired into
|
|
// every controller built for session id. When a snapshot conflict retargets
|
|
// the controller to a recovery branch (turn-end autosave in persistAfterTurn,
|
|
// or the pre-rebuild snapshot in rebuildSession), the ACP bookkeeping must
|
|
// follow at commit time: session/prompt reports sess.transcript,
|
|
// session/delete destroys it, and the session lease must guard the file the
|
|
// controller actually writes. The recovery lease is acquired before the old
|
|
// one is released so the outgoing transcript stays guarded until the new one
|
|
// is secured; a failure aborts the recovery commit and the controller stays
|
|
// on the original path (the next save retries).
|
|
func (s *service) sessionRecoveredHandler(id string) func(control.SessionRecoveryInfo) error {
|
|
return func(info control.SessionRecoveryInfo) error {
|
|
recoveryPath := strings.TrimSpace(info.RecoveryPath)
|
|
if recoveryPath == "" {
|
|
return nil
|
|
}
|
|
sess := s.session(id)
|
|
if sess == nil {
|
|
return nil
|
|
}
|
|
lease, err := agent.TryAcquireSessionLease(recoveryPath)
|
|
if err != nil {
|
|
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
|
return fmt.Errorf("bind recovery session: %s; %s",
|
|
control.SessionInUseMessage(err), control.SessionLeaseCloseHint)
|
|
}
|
|
return fmt.Errorf("bind recovery session: %w", err)
|
|
}
|
|
sess.mu.Lock()
|
|
if sess.deleted {
|
|
sess.mu.Unlock()
|
|
lease.Release()
|
|
return fmt.Errorf("bind recovery session: session is deleted")
|
|
}
|
|
old := sess.lease
|
|
sess.lease = lease
|
|
sess.transcript = recoveryPath
|
|
meta := sess.metaLocked()
|
|
sess.mu.Unlock()
|
|
if old != nil {
|
|
old.Release()
|
|
}
|
|
_ = saveACPMeta(recoveryPath, meta)
|
|
// Leave a redirect on the id-keyed sidecar so restart-time lookups
|
|
// (session/load, session/resume, session/delete, loadMeta) resolve the
|
|
// id to the recovery file; without it the next process reopens the
|
|
// pre-recovery transcript. Always written against the id-keyed path,
|
|
// so resolution stays a single hop even for recovery-of-recovery.
|
|
if dir := s.sessionDir(); dir != "" {
|
|
if idPath := transcriptPath(dir, id); idPath != recoveryPath {
|
|
idMeta, _, err := loadACPMeta(idPath)
|
|
if err != nil {
|
|
slog.Warn("acp: load id-keyed meta for recovery redirect", "err", err)
|
|
idMeta = acpSessionMeta{}
|
|
}
|
|
if idMeta.SessionID == "" {
|
|
idMeta.SessionID = id
|
|
}
|
|
if idMeta.Cwd == "" {
|
|
idMeta.Cwd = meta.Cwd
|
|
}
|
|
if idMeta.CreatedAt.IsZero() {
|
|
idMeta.CreatedAt = meta.CreatedAt
|
|
}
|
|
idMeta.ActiveTranscript = filepath.Base(recoveryPath)
|
|
if err := saveACPMeta(idPath, idMeta); err != nil {
|
|
slog.Warn("acp: save recovery redirect", "err", err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// initialize advertises the agent's capability set: persisted load plus ACP v1
|
|
// list/resume/close/delete lifecycle helpers, prompts carrying inline resource
|
|
// text (embeddedContext) but not image/audio, and stdio / Streamable HTTP MCP
|
|
// (no legacy sse).
|
|
func (s *service) initialize(_ context.Context, raw json.RawMessage) (any, error) {
|
|
var p InitializeParams
|
|
if len(raw) > 0 && json.Unmarshal(raw, &p) == nil {
|
|
s.setClientCapabilities(p.ClientCapabilities)
|
|
}
|
|
return InitializeResult{
|
|
ProtocolVersion: ProtocolVersion,
|
|
AgentCapabilities: AgentCapabilities{
|
|
LoadSession: true,
|
|
SessionCapabilities: SessionCapabilities{
|
|
List: &EmptyCapability{},
|
|
Resume: &EmptyCapability{},
|
|
Close: &EmptyCapability{},
|
|
Delete: &EmptyCapability{},
|
|
},
|
|
PromptCapabilities: PromptCapabilities{
|
|
Image: false,
|
|
Audio: false,
|
|
EmbeddedContext: true,
|
|
},
|
|
MCPCapabilities: MCPCapabilities{HTTP: true, SSE: false},
|
|
},
|
|
AgentInfo: Implementation{Name: s.info.Name, Version: s.info.Version},
|
|
AuthMethods: []AuthMethod{reasonixSetupAuthMethod()},
|
|
}, nil
|
|
}
|
|
|
|
func reasonixSetupAuthMethod() AuthMethod {
|
|
return AuthMethod{
|
|
ID: "reasonix-setup",
|
|
Name: "Reasonix setup",
|
|
Description: "Configure Reasonix providers and credentials in a terminal",
|
|
Type: "terminal",
|
|
Args: []string{"setup"},
|
|
}
|
|
}
|
|
|
|
func (s *service) authenticate(_ context.Context, raw json.RawMessage) (any, error) {
|
|
var p AuthenticateParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "authenticate: " + err.Error()}
|
|
}
|
|
if strings.TrimSpace(p.MethodID) != reasonixSetupAuthMethod().ID {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "authenticate: unknown methodId " + p.MethodID}
|
|
}
|
|
return AuthenticateResult{}, nil
|
|
}
|
|
|
|
// sessionNew opens a session: it mints an id, builds the session's sink bound to
|
|
// that id, asks the Factory to assemble the controller, switches the controller
|
|
// to interactive approval (so tool gates surface as ApprovalRequest events the
|
|
// sink forwards), and registers it.
|
|
func (s *service) sessionNew(ctx context.Context, raw json.RawMessage) (any, error) {
|
|
var p SessionNewParams
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/new: " + err.Error()}
|
|
}
|
|
}
|
|
cwd, err := s.resolveSessionCwd(p.Cwd, "")
|
|
if err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/new: " + err.Error()}
|
|
}
|
|
mcpServers, err := mcpSpecs(p.MCPServers, cwd)
|
|
if err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/new: " + err.Error()}
|
|
}
|
|
cfgState, err := s.sessionConfigState(ctx, SessionConfigStateParams{Cwd: cwd})
|
|
if err != nil {
|
|
return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
|
|
}
|
|
|
|
id, err := newSessionID()
|
|
if err != nil {
|
|
return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
|
|
}
|
|
|
|
sink := newUpdateSink(s.conn, id)
|
|
sink.bindCwd(cwd)
|
|
sessionParams := SessionParams{
|
|
Cwd: cwd,
|
|
MCPServers: mcpServers,
|
|
Sink: sink,
|
|
Model: cfgState.Model,
|
|
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
|
|
OnSessionRecovered: s.sessionRecoveredHandler(id),
|
|
}
|
|
s.bindClientIO(&sessionParams, id)
|
|
ctrl, err := s.factory.NewSession(ctx, sessionParams)
|
|
if err != nil {
|
|
return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
|
|
}
|
|
ctrl.EnableInteractiveApproval()
|
|
sink.bindApprove(ctrl.Approve)
|
|
sink.bindAnswer(ctrl.AnswerQuestion)
|
|
|
|
now := time.Now().UTC()
|
|
sess := &acpSession{
|
|
id: id,
|
|
ctrl: ctrl,
|
|
sink: sink,
|
|
cwd: cwd,
|
|
mcpServers: clonePluginSpecs(mcpServers),
|
|
model: cfgState.Model,
|
|
effortOverride: cloneStringPtr(cfgState.EffortOverride),
|
|
modeID: sessionModeDefault,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
// Pin a transcript file keyed by session id when the controller has a session
|
|
// dir, so every turn auto-saves there, session/prompt can hand the path back,
|
|
// and session/load can find it again by id across process restarts. The
|
|
// session lease is taken with it (defensive: the id-keyed path is brand new)
|
|
// so no other runtime can bind the transcript while this session lives.
|
|
if dir := ctrl.SessionDir(); dir != "" {
|
|
sess.transcript = transcriptPath(dir, id)
|
|
lease, err := agent.TryAcquireSessionLease(sess.transcript)
|
|
if err != nil {
|
|
ctrl.Close()
|
|
return nil, sessionLeaseBindError("session/new", err)
|
|
}
|
|
sess.lease = lease
|
|
ctrl.SetSessionPath(sess.transcript)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.sessions[id] = sess
|
|
s.mu.Unlock()
|
|
s.sendAvailableCommands(sess)
|
|
|
|
return SessionNewResult{
|
|
SessionID: id,
|
|
Models: cfgState.Models,
|
|
Modes: sessionModesState(sessionModeDefault),
|
|
ConfigOptions: cfgState.ConfigOptions,
|
|
}, nil
|
|
}
|
|
|
|
// Session modes exposed over ACP, mapped onto the controller's approval
|
|
// switches: default asks per write-capable call, plan flips the read-only plan
|
|
// gate, auto approves tool calls without asking.
|
|
const (
|
|
sessionModeDefault = "default"
|
|
sessionModePlan = "plan"
|
|
sessionModeAuto = "auto"
|
|
)
|
|
|
|
func sessionModesState(current string) *SessionModeState {
|
|
return &SessionModeState{
|
|
CurrentModeID: current,
|
|
AvailableModes: []SessionMode{
|
|
{ID: sessionModeDefault, Name: "Always Ask", Description: "Ask before write-capable tool calls"},
|
|
{ID: sessionModePlan, Name: "Plan", Description: "Read-only research and planning; no writes or commands"},
|
|
{ID: sessionModeAuto, Name: "Auto-Approve", Description: "Run tool calls without asking"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// sessionSetMode switches the session's operating mode and confirms it with a
|
|
// current_mode_update, per the ACP session-mode contract.
|
|
func (s *service) sessionSetMode(_ context.Context, raw json.RawMessage) (any, error) {
|
|
var p SessionSetModeParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_mode: " + err.Error()}
|
|
}
|
|
sess := s.session(p.SessionID)
|
|
if sess == nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_mode: unknown session " + p.SessionID}
|
|
}
|
|
ctrl := sess.currentCtrl()
|
|
switch p.ModeID {
|
|
case sessionModeDefault:
|
|
ctrl.SetMode(false, false)
|
|
case sessionModePlan:
|
|
ctrl.SetMode(true, false)
|
|
case sessionModeAuto:
|
|
ctrl.SetMode(false, true)
|
|
default:
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_mode: unknown modeId " + p.ModeID}
|
|
}
|
|
if sess.swapModeID(p.ModeID) != p.ModeID {
|
|
sess.sink.send(currentModeUpdate{SessionUpdate: "current_mode_update", CurrentModeID: p.ModeID})
|
|
}
|
|
return SessionSetModeResult{}, nil
|
|
}
|
|
|
|
// emitModeDrift reports controller-side mode flips (plan mode auto-exits when
|
|
// a plan is approved, a config rebuild resets switches) as current_mode_update
|
|
// so the client's mode picker stays truthful.
|
|
func (s *service) emitModeDrift(sess *acpSession) {
|
|
ctrl := sess.currentCtrl()
|
|
current := sessionModeDefault
|
|
switch {
|
|
case ctrl.PlanMode():
|
|
current = sessionModePlan
|
|
case ctrl.AutoApproveTools():
|
|
current = sessionModeAuto
|
|
}
|
|
if sess.swapModeID(current) != current {
|
|
sess.sink.send(currentModeUpdate{SessionUpdate: "current_mode_update", CurrentModeID: current})
|
|
}
|
|
}
|
|
|
|
// sessionLoad resumes a previously-saved session by id: it builds a controller
|
|
// (rooted at the requested cwd), seeds it from the on-disk transcript, replays
|
|
// the conversation to the client as session/update notifications, and registers
|
|
// it for subsequent prompts. A session already live in this process is replayed
|
|
// from memory without rebuilding.
|
|
func (s *service) sessionLoad(ctx context.Context, raw json.RawMessage) (any, error) {
|
|
var p SessionLoadParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/load: " + err.Error()}
|
|
}
|
|
cfgState, err := s.openExistingSession(ctx, "session/load", p.SessionID, p.Cwd, p.MCPServers, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return SessionLoadResult{Models: cfgState.Models, Modes: s.sessionModesFor(p.SessionID), ConfigOptions: cfgState.ConfigOptions}, nil
|
|
}
|
|
|
|
// sessionModesFor reports the modes state for a just-opened session: a session
|
|
// already live in this process keeps whatever mode the user put it in (plan /
|
|
// auto), so load/resume must not tell a reconnecting client "default".
|
|
func (s *service) sessionModesFor(id string) *SessionModeState {
|
|
if sess := s.session(id); sess != nil {
|
|
return sessionModesState(sess.currentModeID())
|
|
}
|
|
return sessionModesState(sessionModeDefault)
|
|
}
|
|
|
|
// sessionResume restores a previously-saved session without replaying its
|
|
// conversation history to the client.
|
|
func (s *service) sessionResume(ctx context.Context, raw json.RawMessage) (any, error) {
|
|
var p SessionResumeParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/resume: " + err.Error()}
|
|
}
|
|
cfgState, err := s.openExistingSession(ctx, "session/resume", p.SessionID, p.Cwd, p.MCPServers, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return SessionResumeResult{Models: cfgState.Models, Modes: s.sessionModesFor(p.SessionID), ConfigOptions: cfgState.ConfigOptions}, nil
|
|
}
|
|
|
|
func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam string, servers []MCPServerSpec, replay bool) (SessionConfigState, error) {
|
|
if err := validateSessionID(method, id); err != nil {
|
|
return SessionConfigState{}, err
|
|
}
|
|
cwd, err := s.resolveSessionCwd(cwdParam, id)
|
|
if err != nil {
|
|
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": " + err.Error()}
|
|
}
|
|
mcpServers, err := mcpSpecs(servers, cwd)
|
|
if err != nil {
|
|
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": " + err.Error()}
|
|
}
|
|
|
|
if sess := s.session(id); sess != nil {
|
|
if agent.IsCleanupPending(sess.transcript) {
|
|
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
|
|
}
|
|
if replay {
|
|
ctrl := sess.currentCtrl()
|
|
replaySink := newUpdateSink(s.conn, id)
|
|
replaySink.bindCwd(sess.cwd)
|
|
replaySink.replay(ctrl.History())
|
|
}
|
|
cfgState, err := s.configStateForSession(ctx, sess)
|
|
if err != nil {
|
|
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
|
|
}
|
|
return cfgState, nil
|
|
}
|
|
|
|
var saved acpSessionMeta
|
|
persistedPath := ""
|
|
if dir := s.sessionDir(); dir != "" {
|
|
persistedPath = resolveTranscriptPath(dir, id)
|
|
if agent.IsCleanupPending(persistedPath) {
|
|
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
|
|
}
|
|
meta, _, metaErr := loadACPMeta(persistedPath)
|
|
if metaErr != nil {
|
|
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + metaErr.Error()}
|
|
}
|
|
saved = meta
|
|
}
|
|
cfgParams := SessionConfigStateParams{
|
|
Cwd: cwd,
|
|
Model: saved.Model,
|
|
EffortOverride: cloneStringPtr(saved.EffortOverride),
|
|
}
|
|
cfgState, err := s.sessionConfigState(ctx, cfgParams)
|
|
if err != nil && (strings.TrimSpace(saved.Model) != "" || saved.EffortOverride != nil) {
|
|
cfgState, err = s.sessionConfigState(ctx, SessionConfigStateParams{Cwd: cwd})
|
|
}
|
|
if err != nil {
|
|
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
|
|
}
|
|
|
|
sink := newUpdateSink(s.conn, id)
|
|
sink.bindCwd(cwd)
|
|
sessionParams := SessionParams{
|
|
Cwd: cwd,
|
|
MCPServers: mcpServers,
|
|
Sink: sink,
|
|
Model: cfgState.Model,
|
|
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
|
|
OnSessionRecovered: s.sessionRecoveredHandler(id),
|
|
}
|
|
s.bindClientIO(&sessionParams, id)
|
|
ctrl, err := s.factory.NewSession(ctx, sessionParams)
|
|
if err != nil {
|
|
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
|
|
}
|
|
ctrl.EnableInteractiveApproval()
|
|
sink.bindApprove(ctrl.Approve)
|
|
sink.bindAnswer(ctrl.AnswerQuestion)
|
|
|
|
dir := ctrl.SessionDir()
|
|
if dir == "" {
|
|
ctrl.Close()
|
|
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": persistence is disabled"}
|
|
}
|
|
path := resolveTranscriptPath(dir, id)
|
|
if path != persistedPath && agent.IsCleanupPending(path) {
|
|
ctrl.Close()
|
|
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
|
|
}
|
|
// Bind the transcript for writing only if no other runtime (a desktop
|
|
// window, the CLI) holds it; the editor should not silently double-write a
|
|
// session that is open elsewhere.
|
|
lease, leaseErr := agent.TryAcquireSessionLease(path)
|
|
if leaseErr != nil {
|
|
ctrl.Close()
|
|
return SessionConfigState{}, sessionLeaseBindError(method, leaseErr)
|
|
}
|
|
loaded, err := agent.LoadSession(path)
|
|
if err != nil {
|
|
lease.Release()
|
|
ctrl.Close()
|
|
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
|
|
}
|
|
ctrl.Resume(loaded, path)
|
|
|
|
meta := metadataForLoadedSession(path, id, cwd, ctrl.History())
|
|
meta.Model = cfgState.Model
|
|
meta.EffortOverride = cloneStringPtr(cfgState.EffortOverride)
|
|
sess := &acpSession{
|
|
id: id,
|
|
ctrl: ctrl,
|
|
sink: sink,
|
|
transcript: path,
|
|
cwd: meta.Cwd,
|
|
mcpServers: clonePluginSpecs(mcpServers),
|
|
model: cfgState.Model,
|
|
effortOverride: cloneStringPtr(cfgState.EffortOverride),
|
|
modeID: sessionModeDefault,
|
|
title: meta.Title,
|
|
createdAt: meta.CreatedAt,
|
|
updatedAt: meta.UpdatedAt,
|
|
lease: lease,
|
|
}
|
|
if err := saveACPMeta(path, sess.meta()); err != nil {
|
|
sess.releaseSessionLease()
|
|
ctrl.Close()
|
|
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
|
|
}
|
|
s.mu.Lock()
|
|
s.sessions[id] = sess
|
|
s.mu.Unlock()
|
|
s.sendAvailableCommands(sess)
|
|
|
|
if replay {
|
|
sink.replay(ctrl.History())
|
|
}
|
|
return cfgState, nil
|
|
}
|
|
|
|
// transcriptPath is where a session's transcript lives — keyed by id so
|
|
// session/load can recover it. Distinct from the cli's timestamp-labelled
|
|
// chat/run session files (those are addressed by a picker, not by id).
|
|
func transcriptPath(dir, id string) string {
|
|
return filepath.Join(dir, id+".jsonl")
|
|
}
|
|
|
|
// resolveTranscriptPath returns the transcript file session id currently
|
|
// lives in. That is the id-keyed path by default; after a snapshot recovery
|
|
// moved the live session onto a recovery branch, the id-keyed sidecar carries
|
|
// an ActiveTranscript redirect (written by sessionRecoveredHandler) that
|
|
// load/resume/delete/meta lookups must follow, or a restart silently reopens
|
|
// the pre-recovery transcript. The redirect is a basename, must stay inside
|
|
// dir, and its target must exist and claim the same session id; anything else
|
|
// falls back to the id-keyed path.
|
|
func resolveTranscriptPath(dir, id string) string {
|
|
path := transcriptPath(dir, id)
|
|
meta, ok, err := loadACPMeta(path)
|
|
if err != nil || !ok {
|
|
return path
|
|
}
|
|
active := strings.TrimSpace(meta.ActiveTranscript)
|
|
if active == "" || active == filepath.Base(path) {
|
|
return path
|
|
}
|
|
if filepath.Base(active) != active {
|
|
return path
|
|
}
|
|
resolved := filepath.Join(dir, active)
|
|
if !sessionFileExists(resolved) {
|
|
return path
|
|
}
|
|
targetMeta, ok, err := loadACPMeta(resolved)
|
|
if err != nil || !ok || targetMeta.SessionID != id {
|
|
return path
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
// sessionPrompt runs one turn. It flattens the prompt blocks to text and runs the
|
|
// session's controller synchronously under a per-turn cancelable context (so
|
|
// session/cancel can stop it), then reports why the turn ended. The controller
|
|
// streams the turn's events to the session's sink as it runs.
|
|
func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any, error) {
|
|
var p SessionPromptParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: " + err.Error()}
|
|
}
|
|
sess := s.session(p.SessionID)
|
|
if sess == nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: unknown session " + p.SessionID}
|
|
}
|
|
text := FlattenPrompt(p.Prompt)
|
|
if text == "" {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: empty prompt"}
|
|
}
|
|
text = s.resolveSlashPrompt(ctx, sess, text)
|
|
|
|
runCtx, cancel, ok := sess.begin(ctx)
|
|
if !ok {
|
|
return nil, &RPCError{Code: ErrInvalidRequest, Message: "session/prompt: session already has an active prompt"}
|
|
}
|
|
sess.sink.setTurnContext(runCtx)
|
|
defer func() {
|
|
sess.sink.clearTurnContext()
|
|
sess.finish()
|
|
s.reportPendingSessionConfigError(ctx, sess, s.applyPendingSessionConfig(ctx, sess), "after turn")
|
|
s.emitModeDrift(sess)
|
|
cancel()
|
|
}()
|
|
runErr := sess.ctrl.RunTurn(runCtx, text)
|
|
|
|
// Persist after the turn (best-effort) so a crash loses at most this prompt;
|
|
// save even on cancel/error since the partial conversation is still resumable.
|
|
sess.persistAfterTurn(text)
|
|
|
|
stop := StopEndTurn
|
|
if runErr != nil {
|
|
if runCtx.Err() != nil {
|
|
stop = StopCancelled
|
|
} else {
|
|
stop = StopError
|
|
}
|
|
}
|
|
res := SessionPromptResult{StopReason: stop}
|
|
if sess.transcript != "" {
|
|
res.TranscriptPath = &sess.transcript
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// sessionSetConfigOption applies ACP's generic session-level selector. Reasonix
|
|
// currently exposes model and reasoning-effort selectors through this path.
|
|
func (s *service) sessionSetConfigOption(ctx context.Context, raw json.RawMessage) (any, error) {
|
|
var p SetSessionConfigOptionParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: " + err.Error()}
|
|
}
|
|
sess := s.session(p.SessionID)
|
|
if sess == nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: unknown session " + p.SessionID}
|
|
}
|
|
cfgState, err := s.configStateForSession(ctx, sess)
|
|
if err != nil {
|
|
return nil, &RPCError{Code: ErrInternal, Message: "session/set_config_option: " + err.Error()}
|
|
}
|
|
option, ok := findConfigOption(cfgState.ConfigOptions, p.ConfigID)
|
|
if !ok {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: unknown config option " + p.ConfigID}
|
|
}
|
|
if !configOptionHasValue(option, p.Value) {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: invalid value " + p.Value + " for " + option.ID}
|
|
}
|
|
|
|
var next SessionConfigState
|
|
switch configOptionCategory(option) {
|
|
case "model":
|
|
next, err = s.switchSessionModel(ctx, sess, p.Value)
|
|
case "thought_level":
|
|
next, err = s.switchSessionEffort(ctx, sess, p.Value)
|
|
default:
|
|
err = &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: unsupported config option " + option.ID}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return SetSessionConfigOptionResult{ConfigOptions: next.ConfigOptions}, nil
|
|
}
|
|
|
|
// sessionSetModel keeps older ACP clients working while configOptions becomes
|
|
// the preferred model selector.
|
|
func (s *service) sessionSetModel(ctx context.Context, raw json.RawMessage) (any, error) {
|
|
var p SetSessionModelParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_model: " + err.Error()}
|
|
}
|
|
sess := s.session(p.SessionID)
|
|
if sess == nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_model: unknown session " + p.SessionID}
|
|
}
|
|
if _, err := s.switchSessionModel(ctx, sess, p.ModelID); err != nil {
|
|
return nil, err
|
|
}
|
|
return SetSessionModelResult{}, nil
|
|
}
|
|
|
|
func (s *service) switchSessionModel(ctx context.Context, sess *acpSession, modelID string) (SessionConfigState, error) {
|
|
params := sess.configStateParams()
|
|
params.Model = modelID
|
|
cfgState, err := s.sessionConfigState(ctx, params)
|
|
if err != nil {
|
|
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: "session/set_model: " + err.Error()}
|
|
}
|
|
if cfgState.Model == "" {
|
|
return SessionConfigState{}, &RPCError{Code: ErrInvalidRequest, Message: "session/set_model: model switching is unavailable in this session"}
|
|
}
|
|
if err := s.rebuildSession(ctx, sess, cfgState); err != nil {
|
|
return SessionConfigState{}, err
|
|
}
|
|
return cfgState, nil
|
|
}
|
|
|
|
func (s *service) switchSessionEffort(ctx context.Context, sess *acpSession, effort string) (SessionConfigState, error) {
|
|
params := sess.configStateParams()
|
|
level := strings.TrimSpace(effort)
|
|
if level == "auto" {
|
|
level = ""
|
|
}
|
|
params.EffortOverride = &level
|
|
cfgState, err := s.sessionConfigState(ctx, params)
|
|
if err != nil {
|
|
return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: " + err.Error()}
|
|
}
|
|
if err := s.rebuildSession(ctx, sess, cfgState); err != nil {
|
|
return SessionConfigState{}, err
|
|
}
|
|
return cfgState, nil
|
|
}
|
|
|
|
func (s *service) rebuildSession(ctx context.Context, sess *acpSession, cfgState SessionConfigState) (retErr error) {
|
|
sess.mu.Lock()
|
|
if sess.deleted {
|
|
sess.mu.Unlock()
|
|
return &RPCError{Code: ErrInvalidRequest, Message: "session config: session is deleted"}
|
|
}
|
|
status := sess.ctrl.RuntimeStatus()
|
|
if status.PendingPrompt {
|
|
sess.mu.Unlock()
|
|
return sessionConfigActiveWorkError("answer pending prompts before switching config")
|
|
}
|
|
if !sess.running && !status.Running && status.BackgroundJobs > 0 {
|
|
sess.mu.Unlock()
|
|
return sessionConfigActiveWorkError("stop background jobs before switching config")
|
|
}
|
|
if sess.running || status.Running || sess.maintenanceDone != nil {
|
|
pending := cloneSessionConfigState(cfgState)
|
|
sess.pendingConfig = &pending
|
|
sess.mu.Unlock()
|
|
sess.sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: cfgState.ConfigOptions})
|
|
return nil
|
|
}
|
|
// Claim the queue in the same critical section that raises maintenanceDone
|
|
// below: begin must never observe an idle session between the two.
|
|
sess.pendingConfig = nil
|
|
|
|
cur := sess.ctrl
|
|
sink := sess.sink
|
|
mcpServers := clonePluginSpecs(sess.mcpServers)
|
|
cwd := sess.cwd
|
|
maintenanceDone := make(chan struct{})
|
|
sess.maintenanceDone = maintenanceDone
|
|
sess.mu.Unlock()
|
|
defer func() {
|
|
sess.finishMaintenance(maintenanceDone)
|
|
if retErr == nil {
|
|
s.reportPendingSessionConfigError(ctx, sess, s.applyPendingSessionConfig(ctx, sess), "after maintenance")
|
|
}
|
|
}()
|
|
|
|
if err := cur.Snapshot(); err != nil {
|
|
return &RPCError{Code: ErrInternal, Message: "session config: snapshot before switch: " + err.Error()}
|
|
}
|
|
// Capture the adopt path and history only after Snapshot: a snapshot
|
|
// conflict can retarget cur to a recovery branch (or adopt the newer disk
|
|
// transcript), and a pre-snapshot capture would bind the rebuilt controller
|
|
// back to the original file, re-conflicting on every later save. When that
|
|
// recovery fired, sessionRecoveredHandler already moved sess.transcript
|
|
// and the session lease to the recovery file, so prevPath, the session
|
|
// bookkeeping, and the controller agree on one path here.
|
|
// SessionPath is controller-locked, so reading it off sess.mu is safe.
|
|
prevPath := cur.SessionPath()
|
|
carried := cur.History()
|
|
|
|
rebuildParams := SessionParams{
|
|
Cwd: cwd,
|
|
MCPServers: mcpServers,
|
|
Sink: sink,
|
|
Model: cfgState.Model,
|
|
EffortOverride: cloneStringPtr(cfgState.EffortOverride),
|
|
OnSessionRecovered: s.sessionRecoveredHandler(sess.id),
|
|
}
|
|
// The rebuilt controller must keep the client-capability wiring (fs
|
|
// overlay, host terminal) a model/effort switch would otherwise drop.
|
|
s.bindClientIO(&rebuildParams, sess.id)
|
|
newCtrl, err := s.factory.NewSession(ctx, rebuildParams)
|
|
if err != nil {
|
|
return &RPCError{Code: ErrInternal, Message: "session config: " + err.Error()}
|
|
}
|
|
newCtrl.EnableInteractiveApproval()
|
|
sink.bindApprove(newCtrl.Approve)
|
|
sink.bindAnswer(newCtrl.AnswerQuestion)
|
|
newCtrl.AdoptHistory(carried, prevPath)
|
|
// Re-apply the session's ACP mode: a fresh controller boots with default
|
|
// switches, which would silently drop a user-selected plan/auto mode and
|
|
// desynchronize the client's mode picker.
|
|
switch sess.currentModeID() {
|
|
case sessionModePlan:
|
|
newCtrl.SetMode(true, false)
|
|
case sessionModeAuto:
|
|
newCtrl.SetMode(false, true)
|
|
}
|
|
// InheritLifecycleFrom wires two concrete controllers' turn/hook state; it's a
|
|
// construction concern, not part of the driving port. cur is always the
|
|
// *control.Controller the factory built for this session, so this is safe.
|
|
if prev, ok := cur.(*control.Controller); ok {
|
|
newCtrl.InheritLifecycleFrom(prev)
|
|
}
|
|
|
|
sess.mu.Lock()
|
|
if sess.deleted {
|
|
sess.mu.Unlock()
|
|
newCtrl.Close()
|
|
return &RPCError{Code: ErrInvalidRequest, Message: "session config: session is deleted"}
|
|
}
|
|
if sess.ctrl != cur {
|
|
sess.mu.Unlock()
|
|
newCtrl.Close()
|
|
return sessionConfigActiveWorkError("session changed while switching config; retry")
|
|
}
|
|
sess.ctrl = newCtrl
|
|
sess.model = cfgState.Model
|
|
sess.effortOverride = cloneStringPtr(cfgState.EffortOverride)
|
|
if sess.transcript != "" && sessionFileExists(sess.transcript) {
|
|
_ = saveACPMeta(sess.transcript, sess.metaLocked())
|
|
}
|
|
sess.mu.Unlock()
|
|
|
|
cur.ReleaseResources()
|
|
s.sendAvailableCommands(sess)
|
|
sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: cfgState.ConfigOptions})
|
|
return nil
|
|
}
|
|
|
|
func (s *service) applyPendingSessionConfig(ctx context.Context, sess *acpSession) error {
|
|
if s.session(sess.id) != sess {
|
|
return nil
|
|
}
|
|
sess.mu.Lock()
|
|
if sess.deleted || sess.pendingConfig == nil {
|
|
sess.mu.Unlock()
|
|
return nil
|
|
}
|
|
cfgState := cloneSessionConfigState(*sess.pendingConfig)
|
|
// Keep pendingConfig set while rebuilding: begin refuses new turns until
|
|
// rebuildSession claims it together with raising maintenanceDone, so no
|
|
// promptable instant is visible in between.
|
|
sess.mu.Unlock()
|
|
|
|
if err := s.rebuildSession(ctx, sess, cfgState); err != nil {
|
|
// Once this attempt failed nothing in flight is left to retry a parked
|
|
// config, and begin refuses new turns while one is queued — drop it so
|
|
// the session stays promptable (the caller reports the failure).
|
|
sess.mu.Lock()
|
|
if !sess.deleted && !sess.running && sess.maintenanceDone == nil {
|
|
sess.pendingConfig = nil
|
|
}
|
|
sess.mu.Unlock()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *service) reportPendingSessionConfigError(ctx context.Context, sess *acpSession, err error, when string) {
|
|
if err == nil || sess == nil || sess.sink == nil {
|
|
return
|
|
}
|
|
sess.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "session config switch failed " + when + ": " + err.Error()})
|
|
if isSessionConfigActiveWorkError(err) {
|
|
if current, stateErr := s.configStateForSession(ctx, sess); stateErr == nil {
|
|
sess.sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: current.ConfigOptions})
|
|
}
|
|
}
|
|
}
|
|
|
|
type activeSessionConfigWorkError struct {
|
|
*RPCError
|
|
}
|
|
|
|
func (e *activeSessionConfigWorkError) Unwrap() error {
|
|
return e.RPCError
|
|
}
|
|
|
|
func sessionConfigActiveWorkError(message string) error {
|
|
return &activeSessionConfigWorkError{
|
|
RPCError: &RPCError{Code: ErrInvalidRequest, Message: "session config: " + message},
|
|
}
|
|
}
|
|
|
|
func isSessionConfigActiveWorkError(err error) bool {
|
|
var activeErr *activeSessionConfigWorkError
|
|
return errors.As(err, &activeErr)
|
|
}
|
|
|
|
// sessionClose releases an active session. Unknown sessions are accepted as a
|
|
// no-op because closing is an idempotent resource cleanup request.
|
|
func (s *service) sessionClose(_ context.Context, raw json.RawMessage) (any, error) {
|
|
var p SessionCloseParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/close: " + err.Error()}
|
|
}
|
|
if err := validateSessionID("session/close", p.SessionID); err != nil {
|
|
return nil, err
|
|
}
|
|
if sess := s.takeSession(p.SessionID); sess != nil {
|
|
sess.abortAndWait()
|
|
sess.ctrl.Close()
|
|
sess.releaseSessionLease()
|
|
}
|
|
return SessionCloseResult{}, nil
|
|
}
|
|
|
|
// sessionList returns ACP sessions known to this process or persisted as ACP
|
|
// sidecars. It deliberately ignores ordinary CLI timestamp sessions.
|
|
func (s *service) sessionList(_ context.Context, raw json.RawMessage) (any, error) {
|
|
var p SessionListParams
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/list: " + err.Error()}
|
|
}
|
|
}
|
|
filterCwd := strings.TrimSpace(p.Cwd)
|
|
if filterCwd != "" && !filepath.IsAbs(filterCwd) {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/list: cwd must be an absolute path"}
|
|
}
|
|
if strings.TrimSpace(p.Cursor) != "" {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/list: unsupported cursor"}
|
|
}
|
|
|
|
byID := map[string]SessionInfo{}
|
|
if dir := s.sessionDir(); dir != "" {
|
|
metas, err := listACPMetas(dir)
|
|
if err != nil {
|
|
return nil, &RPCError{Code: ErrInternal, Message: "session/list: " + err.Error()}
|
|
}
|
|
// A recovered session has two sidecars claiming the same id: the
|
|
// active recovery transcript's own meta and the id-keyed redirect.
|
|
// Reduce to one representative per id before filtering, so the entry
|
|
// shown never carries the stale pre-recovery title/timestamps.
|
|
best := map[string]acpSessionMeta{}
|
|
for _, meta := range metas {
|
|
cur, ok := best[meta.SessionID]
|
|
if !ok || listMetaBeats(meta, cur) {
|
|
best[meta.SessionID] = meta
|
|
}
|
|
}
|
|
for _, meta := range best {
|
|
info := meta.info(nil)
|
|
if sessionInfoMatchesCwd(info, filterCwd) {
|
|
byID[info.SessionID] = info
|
|
}
|
|
}
|
|
}
|
|
for _, sess := range s.liveSessions() {
|
|
info := sess.info()
|
|
if sessionInfoMatchesCwd(info, filterCwd) {
|
|
byID[info.SessionID] = info
|
|
}
|
|
}
|
|
|
|
sessions := make([]SessionInfo, 0, len(byID))
|
|
for _, info := range byID {
|
|
sessions = append(sessions, info)
|
|
}
|
|
sort.Slice(sessions, func(i, j int) bool {
|
|
ti := parseSessionUpdatedAt(sessions[i].UpdatedAt)
|
|
tj := parseSessionUpdatedAt(sessions[j].UpdatedAt)
|
|
if ti.Equal(tj) {
|
|
return sessions[i].SessionID < sessions[j].SessionID
|
|
}
|
|
return ti.After(tj)
|
|
})
|
|
return SessionListResult{Sessions: sessions}, nil
|
|
}
|
|
|
|
// sessionDelete removes a session from future list results. Deleting a missing
|
|
// session succeeds silently, matching ACP's idempotent delete guidance.
|
|
func (s *service) sessionDelete(_ context.Context, raw json.RawMessage) (any, error) {
|
|
var p SessionDeleteParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/delete: " + err.Error()}
|
|
}
|
|
if err := validateSessionID("session/delete", p.SessionID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
path := ""
|
|
var destroy control.SessionDestroyHandle
|
|
var delayed bool
|
|
if sess := s.takeSession(p.SessionID); sess != nil {
|
|
sess.deleteAndWait()
|
|
// The session is going away; drop its lease before removing files so
|
|
// the lease sidecars retire with the release (they are not in
|
|
// SessionSidecarFiles and would otherwise linger).
|
|
sess.releaseSessionLease()
|
|
path = sess.transcript
|
|
destroy = sess.ctrl.BeginDestroySession(path)
|
|
if result := destroy.Wait(); result.HasTimedOut() {
|
|
if err := agent.MarkCleanupPending(path, "delete"); err != nil {
|
|
go delayedDeleteSessionFiles(path, destroy)
|
|
sess.ctrl.CloseAfterDestroy()
|
|
return nil, &RPCError{Code: ErrInternal, Message: "session/delete: " + err.Error()}
|
|
}
|
|
go delayedDeleteSessionFiles(path, destroy)
|
|
delayed = true
|
|
}
|
|
sess.ctrl.CloseAfterDestroy()
|
|
}
|
|
if path == "" {
|
|
if dir := s.sessionDir(); dir != "" {
|
|
path = resolveTranscriptPath(dir, p.SessionID)
|
|
}
|
|
}
|
|
if path != "" && !delayed {
|
|
if err := deleteSessionFiles(path); err != nil {
|
|
return nil, &RPCError{Code: ErrInternal, Message: "session/delete: " + err.Error()}
|
|
}
|
|
if destroy.Finish != nil {
|
|
destroy.Finish()
|
|
}
|
|
}
|
|
// A recovered session lives in two files: the recovery transcript (deleted
|
|
// above) and the id-keyed original holding the redirect. Remove the twin
|
|
// too, or it resurfaces in session/list as a ghost that delete-by-id can
|
|
// never reach again.
|
|
if dir := s.sessionDir(); dir != "" {
|
|
if idPath := transcriptPath(dir, p.SessionID); idPath != path {
|
|
if err := deleteSessionFiles(idPath); err != nil {
|
|
return nil, &RPCError{Code: ErrInternal, Message: "session/delete: " + err.Error()}
|
|
}
|
|
}
|
|
}
|
|
return SessionDeleteResult{}, nil
|
|
}
|
|
|
|
// sessionCancel aborts a session's in-flight turn, if any. It is a notification:
|
|
// no reply, and an unknown session is silently ignored.
|
|
func (s *service) sessionCancel(_ context.Context, raw json.RawMessage) {
|
|
var p SessionCancelParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return
|
|
}
|
|
if sess := s.session(p.SessionID); sess != nil {
|
|
sess.abort()
|
|
}
|
|
}
|
|
|
|
func (s *service) session(id string) *acpSession {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.sessions[id]
|
|
}
|
|
|
|
func (s *service) takeSession(id string) *acpSession {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
sess := s.sessions[id]
|
|
delete(s.sessions, id)
|
|
return sess
|
|
}
|
|
|
|
func (s *service) liveSessions() []*acpSession {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]*acpSession, 0, len(s.sessions))
|
|
for _, sess := range s.sessions {
|
|
out = append(out, sess)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *service) sessionDir() string {
|
|
if p, ok := s.factory.(SessionDirProvider); ok {
|
|
if dir := strings.TrimSpace(p.SessionDir()); dir != "" {
|
|
return dir
|
|
}
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, sess := range s.sessions {
|
|
if dir := sess.currentCtrl().SessionDir(); dir != "" {
|
|
return dir
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *service) sessionConfigState(ctx context.Context, p SessionConfigStateParams) (SessionConfigState, error) {
|
|
if provider, ok := s.factory.(SessionConfigStateProvider); ok {
|
|
return provider.SessionConfigState(ctx, p)
|
|
}
|
|
return SessionConfigState{}, nil
|
|
}
|
|
|
|
func (s *service) configStateForSession(ctx context.Context, sess *acpSession) (SessionConfigState, error) {
|
|
return s.sessionConfigState(ctx, sess.configStateParams())
|
|
}
|
|
|
|
func (s *acpSession) configStateParams() SessionConfigStateParams {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return SessionConfigStateParams{
|
|
Cwd: s.cwd,
|
|
Model: s.model,
|
|
EffortOverride: cloneStringPtr(s.effortOverride),
|
|
}
|
|
}
|
|
|
|
func findConfigOption(options []SessionConfigOption, id string) (SessionConfigOption, bool) {
|
|
id = normalizeConfigID(id)
|
|
for _, opt := range options {
|
|
if normalizeConfigID(opt.ID) == id {
|
|
return opt, true
|
|
}
|
|
}
|
|
return SessionConfigOption{}, false
|
|
}
|
|
|
|
func normalizeConfigID(id string) string {
|
|
switch strings.TrimSpace(id) {
|
|
case "models":
|
|
return "model"
|
|
case "reasoning_effort", "thought_level":
|
|
return "effort"
|
|
default:
|
|
return strings.TrimSpace(id)
|
|
}
|
|
}
|
|
|
|
func configOptionHasValue(option SessionConfigOption, value string) bool {
|
|
for _, opt := range option.Options {
|
|
if opt.Value == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func configOptionCategory(option SessionConfigOption) string {
|
|
if option.Category != "" {
|
|
return option.Category
|
|
}
|
|
switch normalizeConfigID(option.ID) {
|
|
case "model":
|
|
return "model"
|
|
case "effort":
|
|
return "thought_level"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func cloneStringPtr(p *string) *string {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
cp := *p
|
|
return &cp
|
|
}
|
|
|
|
func cloneSessionConfigState(in SessionConfigState) SessionConfigState {
|
|
out := in
|
|
out.EffortOverride = cloneStringPtr(in.EffortOverride)
|
|
if in.Models != nil {
|
|
models := *in.Models
|
|
models.AvailableModels = append([]ModelInfo(nil), in.Models.AvailableModels...)
|
|
out.Models = &models
|
|
}
|
|
out.ConfigOptions = append([]SessionConfigOption(nil), in.ConfigOptions...)
|
|
for i := range out.ConfigOptions {
|
|
out.ConfigOptions[i].Options = append([]SessionConfigSelectOption(nil), out.ConfigOptions[i].Options...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func clonePluginSpecs(in []plugin.Spec) []plugin.Spec {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]plugin.Spec, len(in))
|
|
copy(out, in)
|
|
return out
|
|
}
|
|
|
|
func (s *service) resolveSessionCwd(cwd, sessionID string) (string, error) {
|
|
cwd = strings.TrimSpace(cwd)
|
|
if cwd != "" {
|
|
if !filepath.IsAbs(cwd) {
|
|
return "", fmt.Errorf("cwd must be an absolute path")
|
|
}
|
|
return filepath.Clean(cwd), nil
|
|
}
|
|
if sessionID != "" {
|
|
if meta, ok := s.loadMeta(sessionID); ok && meta.Cwd != "" {
|
|
if !filepath.IsAbs(meta.Cwd) {
|
|
return "", fmt.Errorf("stored cwd must be an absolute path")
|
|
}
|
|
return filepath.Clean(meta.Cwd), nil
|
|
}
|
|
}
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve cwd: %w", err)
|
|
}
|
|
return wd, nil
|
|
}
|
|
|
|
func (s *service) loadMeta(id string) (acpSessionMeta, bool) {
|
|
dir := s.sessionDir()
|
|
if dir == "" {
|
|
return acpSessionMeta{}, false
|
|
}
|
|
meta, ok, err := loadACPMeta(resolveTranscriptPath(dir, id))
|
|
if err != nil {
|
|
return acpSessionMeta{}, false
|
|
}
|
|
return meta, ok
|
|
}
|
|
|
|
// closeAll tears down every open session (aborting any in-flight turn and
|
|
// stopping its MCP subprocesses) when the connection ends.
|
|
func (s *service) closeAll() {
|
|
s.mu.Lock()
|
|
sessions := s.sessions
|
|
s.sessions = make(map[string]*acpSession)
|
|
s.mu.Unlock()
|
|
for _, sess := range sessions {
|
|
sess.abortAndWait()
|
|
sess.currentCtrl().Close()
|
|
sess.releaseSessionLease()
|
|
}
|
|
}
|
|
|
|
func (s *acpSession) persistAfterTurn(prompt string) {
|
|
s.mu.Lock()
|
|
if s.deleted {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
ctrl := s.ctrl
|
|
s.mu.Unlock()
|
|
|
|
_ = ctrl.Snapshot()
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.deleted || s.ctrl != ctrl {
|
|
return
|
|
}
|
|
if s.title == "" {
|
|
s.title = previewTitle(prompt)
|
|
}
|
|
s.updatedAt = time.Now().UTC()
|
|
if s.createdAt.IsZero() {
|
|
s.createdAt = s.updatedAt
|
|
}
|
|
if s.transcript != "" && sessionFileExists(s.transcript) {
|
|
_ = saveACPMeta(s.transcript, s.metaLocked())
|
|
}
|
|
}
|
|
|
|
func (s *acpSession) meta() acpSessionMeta {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.metaLocked()
|
|
}
|
|
|
|
func (s *acpSession) metaLocked() acpSessionMeta {
|
|
return acpSessionMeta{
|
|
SessionID: s.id,
|
|
Cwd: s.cwd,
|
|
Model: s.model,
|
|
EffortOverride: cloneStringPtr(s.effortOverride),
|
|
Title: s.title,
|
|
CreatedAt: s.createdAt,
|
|
UpdatedAt: s.updatedAt,
|
|
}
|
|
}
|
|
|
|
func (s *acpSession) info() SessionInfo {
|
|
meta := s.meta()
|
|
ctrl := s.currentCtrl()
|
|
extra := map[string]any{}
|
|
if n := len(ctrl.History()); n > 0 {
|
|
extra["messageCount"] = n
|
|
}
|
|
if len(extra) == 0 {
|
|
extra = nil
|
|
}
|
|
return meta.info(extra)
|
|
}
|
|
|
|
func (s *service) sendAvailableCommands(sess *acpSession) {
|
|
if sess == nil {
|
|
return
|
|
}
|
|
ctrl := sess.currentCtrl()
|
|
if ctrl == nil {
|
|
return
|
|
}
|
|
cmds := availableCommandsFor(ctrl)
|
|
if len(cmds) == 0 {
|
|
return
|
|
}
|
|
sess.sink.send(availableCommandsUpdate{
|
|
SessionUpdate: "available_commands_update",
|
|
AvailableCommands: cmds,
|
|
})
|
|
}
|
|
|
|
func availableCommandsFor(ctrl acpController) []AvailableCommand {
|
|
if ctrl == nil {
|
|
return nil
|
|
}
|
|
byName := map[string]AvailableCommand{}
|
|
for _, cmd := range ctrl.Commands() {
|
|
if cmd.Hidden {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(cmd.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
desc := strings.TrimSpace(cmd.Description)
|
|
if desc == "" {
|
|
desc = "Run the " + name + " command"
|
|
}
|
|
ac := AvailableCommand{Name: name, Description: desc}
|
|
if hint := strings.TrimSpace(cmd.ArgHint); hint != "" {
|
|
ac.Input = &AvailableCommandInput{Hint: hint}
|
|
}
|
|
byName[name] = ac
|
|
}
|
|
for _, sk := range ctrl.SlashSkills() {
|
|
name := strings.TrimSpace(sk.SlashName())
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if _, exists := byName[name]; exists {
|
|
continue
|
|
}
|
|
desc := strings.TrimSpace(sk.Description)
|
|
if desc == "" {
|
|
desc = "Run the " + name + " skill"
|
|
}
|
|
byName[name] = AvailableCommand{
|
|
Name: name,
|
|
Description: desc,
|
|
Input: &AvailableCommandInput{Hint: "instructions"},
|
|
}
|
|
}
|
|
if host := ctrl.Host(); host != nil {
|
|
for _, prompt := range host.Prompts() {
|
|
name := strings.TrimSpace(prompt.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
desc := strings.TrimSpace(prompt.Description)
|
|
if desc == "" {
|
|
desc = "Run the " + name + " MCP prompt"
|
|
}
|
|
ac := AvailableCommand{Name: name, Description: desc}
|
|
if len(prompt.Args) > 0 {
|
|
ac.Input = &AvailableCommandInput{Hint: "arguments"}
|
|
}
|
|
byName[name] = ac
|
|
}
|
|
}
|
|
out := make([]AvailableCommand, 0, len(byName))
|
|
for _, cmd := range byName {
|
|
out = append(out, cmd)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
func (s *service) resolveSlashPrompt(ctx context.Context, sess *acpSession, text string) string {
|
|
line := strings.TrimSpace(text)
|
|
if sess == nil || !strings.HasPrefix(line, "/") {
|
|
return text
|
|
}
|
|
ctrl := sess.currentCtrl()
|
|
if ctrl == nil {
|
|
return text
|
|
}
|
|
if sent, ok := ctrl.CustomCommand(line); ok {
|
|
return sent
|
|
}
|
|
if sent, ok := ctrl.RunSkill(line); ok {
|
|
return sent
|
|
}
|
|
if sent, ok, err := ctrl.MCPPrompt(ctx, line); err == nil && ok {
|
|
return sent
|
|
}
|
|
return text
|
|
}
|
|
|
|
type acpSessionMeta struct {
|
|
SessionID string `json:"sessionId"`
|
|
Cwd string `json:"cwd"`
|
|
Model string `json:"model,omitempty"`
|
|
EffortOverride *string `json:"effortOverride,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
// ActiveTranscript, when set on the id-keyed sidecar, is the basename of
|
|
// the transcript this session currently lives in: a snapshot recovery
|
|
// moved the live session onto a recovery branch and left this redirect
|
|
// behind so restart-time lookups (resolveTranscriptPath) follow the
|
|
// session instead of reopening the pre-recovery file.
|
|
ActiveTranscript string `json:"activeTranscript,omitempty"`
|
|
}
|
|
|
|
func (m acpSessionMeta) info(extra map[string]any) SessionInfo {
|
|
updatedAt := ""
|
|
if !m.UpdatedAt.IsZero() {
|
|
updatedAt = m.UpdatedAt.Format(time.RFC3339Nano)
|
|
}
|
|
return SessionInfo{
|
|
SessionID: m.SessionID,
|
|
Cwd: m.Cwd,
|
|
Title: m.Title,
|
|
UpdatedAt: updatedAt,
|
|
Meta: extra,
|
|
}
|
|
}
|
|
|
|
func metadataForLoadedSession(path, id, cwd string, history []provider.Message) acpSessionMeta {
|
|
now := time.Now().UTC()
|
|
meta, ok, err := loadACPMeta(path)
|
|
if err != nil || !ok {
|
|
meta = acpSessionMeta{
|
|
SessionID: id,
|
|
Cwd: cwd,
|
|
Title: titleFromHistory(history),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if info, statErr := os.Stat(path); statErr == nil {
|
|
meta.CreatedAt = info.ModTime().UTC()
|
|
meta.UpdatedAt = info.ModTime().UTC()
|
|
}
|
|
}
|
|
if meta.SessionID == "" {
|
|
meta.SessionID = id
|
|
}
|
|
if cwd != "" {
|
|
meta.Cwd = cwd
|
|
}
|
|
if meta.Title == "" {
|
|
meta.Title = titleFromHistory(history)
|
|
}
|
|
if meta.CreatedAt.IsZero() {
|
|
meta.CreatedAt = now
|
|
}
|
|
if meta.UpdatedAt.IsZero() {
|
|
meta.UpdatedAt = meta.CreatedAt
|
|
}
|
|
return meta
|
|
}
|
|
|
|
func loadACPMeta(sessionPath string) (acpSessionMeta, bool, error) {
|
|
path := acpMetaPath(sessionPath)
|
|
if path == "" {
|
|
return acpSessionMeta{}, false, nil
|
|
}
|
|
b, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return acpSessionMeta{}, false, nil
|
|
}
|
|
return acpSessionMeta{}, false, err
|
|
}
|
|
var meta acpSessionMeta
|
|
if err := json.Unmarshal(b, &meta); err != nil {
|
|
return acpSessionMeta{}, false, fmt.Errorf("decode ACP session metadata %s: %w", path, err)
|
|
}
|
|
return meta, true, nil
|
|
}
|
|
|
|
func saveACPMeta(sessionPath string, meta acpSessionMeta) error {
|
|
path := acpMetaPath(sessionPath)
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
now := time.Now().UTC()
|
|
if meta.SessionID == "" {
|
|
meta.SessionID = sessionIDFromTranscript(sessionPath)
|
|
}
|
|
if meta.CreatedAt.IsZero() {
|
|
meta.CreatedAt = now
|
|
}
|
|
if meta.UpdatedAt.IsZero() {
|
|
meta.UpdatedAt = meta.CreatedAt
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
b, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b = append(b, '\n')
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".acp-session.*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
if _, err := tmp.Write(b); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
return fileutil.ReplaceFile(tmpPath, path)
|
|
}
|
|
|
|
func listACPMetas(dir string) ([]acpSessionMeta, error) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
out := []acpSessionMeta{}
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".acp.json") {
|
|
continue
|
|
}
|
|
id := strings.TrimSuffix(e.Name(), ".acp.json")
|
|
sessionPath := transcriptPath(dir, id)
|
|
if agent.IsCleanupPending(sessionPath) {
|
|
continue
|
|
}
|
|
if !sessionFileExists(sessionPath) {
|
|
continue
|
|
}
|
|
meta, ok, err := loadACPMeta(sessionPath)
|
|
if err != nil || !ok {
|
|
continue
|
|
}
|
|
if meta.SessionID == "" {
|
|
meta.SessionID = id
|
|
}
|
|
if meta.Cwd == "" {
|
|
continue
|
|
}
|
|
out = append(out, meta)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func sessionFileExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
func acpMetaPath(sessionPath string) string {
|
|
if sessionPath == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimSuffix(sessionPath, filepath.Ext(sessionPath)) + ".acp.json"
|
|
}
|
|
|
|
func sessionIDFromTranscript(path string) string {
|
|
base := filepath.Base(path)
|
|
if ext := filepath.Ext(base); ext != "" {
|
|
base = strings.TrimSuffix(base, ext)
|
|
}
|
|
return base
|
|
}
|
|
|
|
// listMetaBeats reports whether a should represent its session id in
|
|
// session/list over b. A meta without an ActiveTranscript redirect is the
|
|
// session's live transcript and always beats a redirect sidecar; between two
|
|
// of the same kind the later UpdatedAt wins.
|
|
func listMetaBeats(a, b acpSessionMeta) bool {
|
|
aRedirect := strings.TrimSpace(a.ActiveTranscript) != ""
|
|
bRedirect := strings.TrimSpace(b.ActiveTranscript) != ""
|
|
if aRedirect != bRedirect {
|
|
return !aRedirect
|
|
}
|
|
return a.UpdatedAt.After(b.UpdatedAt)
|
|
}
|
|
|
|
func sessionInfoMatchesCwd(info SessionInfo, filter string) bool {
|
|
if filter == "" {
|
|
return true
|
|
}
|
|
return filepath.Clean(info.Cwd) == filepath.Clean(filter)
|
|
}
|
|
|
|
func titleFromHistory(history []provider.Message) string {
|
|
for _, m := range history {
|
|
if m.Role == provider.RoleUser {
|
|
if title := previewTitle(m.Content); title != "" {
|
|
return title
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func previewTitle(text string) string {
|
|
text = strings.Join(strings.Fields(text), " ")
|
|
if len([]rune(text)) <= 80 {
|
|
return text
|
|
}
|
|
runes := []rune(text)
|
|
return string(runes[:77]) + "..."
|
|
}
|
|
|
|
func validateSessionID(method, id string) error {
|
|
trimmed := strings.TrimSpace(id)
|
|
if trimmed == "" {
|
|
return &RPCError{Code: ErrInvalidParams, Message: method + ": missing sessionId"}
|
|
}
|
|
if trimmed != id || trimmed == "." || trimmed == ".." || !isSafeSessionID(trimmed) {
|
|
return &RPCError{Code: ErrInvalidParams, Message: method + ": invalid sessionId"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isSafeSessionID(id string) bool {
|
|
for _, r := range id {
|
|
if r >= 'a' && r <= 'z' {
|
|
continue
|
|
}
|
|
if r >= 'A' && r <= 'Z' {
|
|
continue
|
|
}
|
|
if r >= '0' && r <= '9' {
|
|
continue
|
|
}
|
|
if r == '-' || r == '_' || r == '.' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseSessionUpdatedAt(s string) time.Time {
|
|
t, err := time.Parse(time.RFC3339Nano, s)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
return t
|
|
}
|
|
|
|
func deleteSessionFiles(sessionPath string) error {
|
|
paths := []string{
|
|
sessionPath,
|
|
acpMetaPath(sessionPath),
|
|
}
|
|
paths = append(paths, store.SessionSidecarFiles(sessionPath)...)
|
|
for _, path := range paths {
|
|
if path == "" {
|
|
continue
|
|
}
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
}
|
|
if dir := checkpointPath(sessionPath); dir != "" {
|
|
if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
}
|
|
if err := agent.DeleteSubagentsByParent(filepath.Dir(sessionPath), agent.BranchID(sessionPath)); err != nil {
|
|
return err
|
|
}
|
|
if err := jobs.RemoveArtifacts(sessionPath); err != nil {
|
|
return err
|
|
}
|
|
return agent.ClearCleanupPending(sessionPath)
|
|
}
|
|
|
|
// ReconcileCleanupPending retries delayed ACP session cleanup left by a previous
|
|
// process, including ACP's own metadata sidecar.
|
|
func ReconcileCleanupPending(dir string) error {
|
|
return agent.ReconcileCleanupPending(dir, func(item agent.CleanupPendingInfo) error {
|
|
return deleteSessionFiles(item.SessionPath)
|
|
})
|
|
}
|
|
|
|
func delayedDeleteSessionFiles(sessionPath string, destroy control.SessionDestroyHandle) {
|
|
if destroy.WaitAll != nil {
|
|
destroy.WaitAll()
|
|
}
|
|
if err := deleteSessionFiles(sessionPath); err != nil {
|
|
slog.Warn("acp: delayed session delete failed", "path", sessionPath, "err", err)
|
|
}
|
|
if destroy.Finish != nil {
|
|
destroy.Finish()
|
|
}
|
|
}
|
|
|
|
func checkpointPath(sessionPath string) string {
|
|
return store.SessionCheckpointDir(sessionPath)
|
|
}
|
|
|
|
// mcpSpecs converts ACP MCP server declarations to plugin.Spec.
|
|
func mcpSpecs(in []MCPServerSpec, cwd string) ([]plugin.Spec, error) {
|
|
if len(in) == 0 {
|
|
return nil, nil
|
|
}
|
|
out := make([]plugin.Spec, 0, len(in))
|
|
for _, m := range in {
|
|
typ := strings.ToLower(strings.TrimSpace(m.Type))
|
|
if typ == "" {
|
|
typ = "stdio"
|
|
}
|
|
if strings.TrimSpace(m.Name) == "" {
|
|
return nil, fmt.Errorf("MCP server name is required")
|
|
}
|
|
switch typ {
|
|
case "stdio":
|
|
if strings.TrimSpace(m.Command) == "" {
|
|
return nil, fmt.Errorf("MCP server %q command is required", m.Name)
|
|
}
|
|
case "http", "streamable-http", "streamable_http":
|
|
if strings.TrimSpace(m.URL) == "" {
|
|
return nil, fmt.Errorf("MCP server %q url is required", m.Name)
|
|
}
|
|
typ = "http"
|
|
default:
|
|
return nil, fmt.Errorf("MCP server %q uses unsupported transport %q", m.Name, m.Type)
|
|
}
|
|
out = append(out, plugin.Spec{
|
|
Name: strings.TrimSpace(m.Name),
|
|
Type: typ,
|
|
Command: strings.TrimSpace(m.Command),
|
|
Args: append([]string(nil), m.Args...),
|
|
Env: mapString(m.Env),
|
|
URL: strings.TrimSpace(m.URL),
|
|
Headers: mapString(m.Headers),
|
|
Dir: cwd,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func mapString(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// newSessionID returns a random RFC 4122 v4 UUID string used to address a session.
|
|
func newSessionID() (string, error) {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", err
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
|
}
|