返回 DeepSeek-Reasonix
service.go
根目录 / internal / acp / service.go
1 package acp
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log/slog"
11 "os"
12 "path/filepath"
13 "sort"
14 "strings"
15 "sync"
16 "time"
17
18 "reasonix/internal/agent"
19 "reasonix/internal/control"
20 "reasonix/internal/event"
21 "reasonix/internal/extension/uihub"
22 "reasonix/internal/fileutil"
23 fileencoding "reasonix/internal/fileutil/encoding"
24 "reasonix/internal/jobs"
25 "reasonix/internal/plugin"
26 "reasonix/internal/provider"
27 "reasonix/internal/store"
28 "reasonix/internal/tool/builtin"
29 )
30
31 // SessionParams is everything a Factory needs to assemble one ACP session's
32 // controller. Sink is owned by this package (an updateSink bound to the session
33 // id) and must be wired into the controller's event sink; the controller's
34 // interactive approval (see control.Controller.EnableInteractiveApproval) then
35 // routes "ask" decisions back through that sink as ApprovalRequest events, which
36 // the sink forwards to the client over session/request_permission.
37 //
38 // Cwd roots the session's file tools and bash (built via builtin.Workspace).
39 // Model, EffortOverride, and RuntimeProfile are optional session-local selectors
40 // from ACP config options. MCPServers are the MCP servers the client asked the
41 // agent to connect for this session. OnSessionRecovered is the service's
42 // bookkeeping hook for automatic transcript recovery branches (see
43 // sessionRecoveredHandler); factories must wire it into the controller they build.
44 type SessionParams struct {
45 Cwd string
46 MCPServers []plugin.Spec
47 Sink event.Sink
48 Model string
49 EffortOverride *string
50 RuntimeProfile string
51 OnSessionRecovered func(control.SessionRecoveryInfo) error
52 // FileOverlay and Terminal are non-nil when the client advertised the
53 // matching capability at initialize: file tools then see unsaved editor
54 // buffers, and foreground bash can run in a client-owned terminal.
55 // Factories thread them into the controller's tool assembly.
56 FileOverlay builtin.FileOverlay
57 Terminal builtin.TerminalRunner
58 }
59
60 // Factory builds the per-session controller. The composition root (the cli's
61 // `reasonix acp` command) implements it by reusing setup()'s assembly: a
62 // Provider for Model, a tool Registry rooted at Cwd via builtin.Workspace, a
63 // per-session MCP host from MCPServers, the event Sink, all wired into a
64 // control.Controller. The returned controller owns its own cleanup (Close stops
65 // MCP subprocesses), so the service calls ctrl.Close() on teardown.
66 type Factory interface {
67 NewSession(ctx context.Context, p SessionParams) (*control.Controller, error)
68 }
69
70 // SessionConfigStateParams asks the Factory for normalized session config
71 // selectors. Empty Model and RuntimeProfile use configured defaults. Nil
72 // EffortOverride means provider config wins; a non-nil empty string means
73 // provider default for this session.
74 type SessionConfigStateParams struct {
75 Cwd string
76 Model string
77 EffortOverride *string
78 RuntimeProfile string
79 }
80
81 // SessionConfigState is the complete ACP-visible config state for a session.
82 type SessionConfigState struct {
83 Model string
84 EffortOverride *string
85 RuntimeProfile string
86 Models *SessionModelState
87 ConfigOptions []SessionConfigOption
88 }
89
90 // SessionConfigStateProvider lets a Factory expose model, effort, and work-mode
91 // selectors without making the ACP transport depend on a concrete config backend.
92 type SessionConfigStateProvider interface {
93 SessionConfigState(ctx context.Context, p SessionConfigStateParams) (SessionConfigState, error)
94 }
95
96 // SessionDirProvider lets a Factory expose the persistent session directory
97 // without forcing session/list to build a controller first.
98 type SessionDirProvider interface {
99 SessionDir() string
100 }
101
102 // SessionRebuilder lets a Factory rebuild a session's controller via
103 // boot.Rebuild: the replacement is built with the same boot.Options NewSession
104 // would use, and the session state (history, approval grants, goal/recovery,
105 // lifecycle) migrates off old inside the boot layer. The caller keeps the
106 // swap/close ordering. Factories that do not implement it leave
107 // _reasonix.io/session/reloadExtensions reporting unavailable.
108 type SessionRebuilder interface {
109 RebuildSession(ctx context.Context, p SessionParams, old *control.Controller) (*control.Controller, error)
110 }
111
112 // AgentInfo identifies this agent to clients in the initialize reply.
113 type AgentInfo struct {
114 Name string
115 Version string
116 }
117
118 // Serve runs an ACP agent on r/w (stdin/stdout in production) until the input
119 // ends or ctx is cancelled. It owns the JSON-RPC connection and the session
120 // registry; the Factory supplies the kernel wiring. This is the single entry
121 // point the `reasonix acp` command calls.
122 //
123 // stdout is the JSON-RPC channel: callers must keep all other output (logs,
124 // diagnostics) off w and on stderr, or the wire corrupts.
125 func Serve(ctx context.Context, r io.Reader, w io.Writer, factory Factory, info AgentInfo) error {
126 conn := NewConn(r, w)
127 svc := &service{
128 conn: conn,
129 factory: factory,
130 info: info,
131 sessions: make(map[string]*acpSession),
132 }
133 conn.Handle("initialize", svc.initialize)
134 conn.Handle("authenticate", svc.authenticate)
135 conn.Handle("session/new", svc.sessionNew)
136 conn.Handle("session/load", svc.sessionLoad)
137 conn.Handle("session/resume", svc.sessionResume)
138 conn.Handle("session/prompt", svc.sessionPrompt)
139 conn.Handle(sessionSteerMethod, svc.sessionSteer)
140 conn.Handle(sessionReloadExtensionsMethod, svc.sessionReloadExtensions)
141 conn.Handle(sessionStatusMethod, svc.sessionStatus)
142 conn.Handle("session/set_config_option", svc.sessionSetConfigOption)
143 conn.Handle("session/set_model", svc.sessionSetModel)
144 conn.Handle("session/set_mode", svc.sessionSetMode)
145 conn.Handle("session/close", svc.sessionClose)
146 conn.Handle("session/list", svc.sessionList)
147 conn.Handle("session/delete", svc.sessionDelete)
148 conn.HandleNotify("session/cancel", svc.sessionCancel)
149 defer svc.closeAll()
150 return conn.Serve(ctx)
151 }
152
153 // service holds the connection-wide ACP state: the factory, agent identity, and
154 // the live session registry.
155 type service struct {
156 conn *Conn
157 factory Factory
158 info AgentInfo
159
160 mu sync.Mutex
161 sessions map[string]*acpSession
162 // clientCaps is what the client offered at initialize (fs proxy, host
163 // terminals). Zero until initialize arrives; sessions opened later bind a
164 // clientIO built from it.
165 clientCaps ClientCapabilities
166 }
167
168 // afterResponse wraps a result with work that must run after the transport has
169 // successfully written that result. Session-opening notifications use this so a
170 // client can register the returned session before receiving its first update.
171 type afterResponse struct {
172 result any
173 after func()
174 }
175
176 func (r afterResponse) Response() any { return r.result }
177
178 func (r afterResponse) AfterResponse() {
179 if r.after != nil {
180 r.after()
181 }
182 }
183
184 func (s *service) setClientCapabilities(caps ClientCapabilities) {
185 s.mu.Lock()
186 s.clientCaps = caps
187 s.mu.Unlock()
188 }
189
190 func (s *service) clientCapabilities() ClientCapabilities {
191 s.mu.Lock()
192 defer s.mu.Unlock()
193 return s.clientCaps
194 }
195
196 // extensionSurfaceSupported reports whether the connected client advertised
197 // reasonix.extensionSurface support in its initialize handshake.
198 func (s *service) extensionSurfaceSupported() bool {
199 return clientExtensionSurfaceSupported(s.clientCapabilities())
200 }
201
202 // clientExtensionSurfaceSupported tolerantly parses the client's vendor
203 // capability block: _meta["reasonix.io"]["extensionSurface"]["supported"] must
204 // be an explicit true. Absent keys, wrong shapes, or a malformed block all
205 // mean unsupported — the sink then sends only the text fallback.
206 func clientExtensionSurfaceSupported(caps ClientCapabilities) bool {
207 vendor, ok := caps.Meta["reasonix.io"].(map[string]any)
208 if !ok {
209 return false
210 }
211 capability, ok := vendor["extensionSurface"].(map[string]any)
212 if !ok {
213 return false
214 }
215 supported, _ := capability["supported"].(bool)
216 return supported
217 }
218
219 // bindClientIO fills SessionParams' overlay/terminal fields from the client's
220 // declared capabilities. The nil checks keep absent capabilities as nil
221 // interface fields (a typed-nil *clientIO must never reach the interface).
222 func (s *service) bindClientIO(p *SessionParams, sessionID string) {
223 io := newClientIO(s.conn, sessionID, s.clientCapabilities())
224 if !io.hasAny() {
225 return
226 }
227 if fo := io.fileOverlay(); fo != nil {
228 p.FileOverlay = fo
229 }
230 if tr := io.terminalRunner(); tr != nil {
231 p.Terminal = tr
232 }
233 }
234
235 // acpController is the slice of the controller's driving port the ACP transport
236 // drives: session lifecycle + persistence, turn execution, interactive approval,
237 // and the capability surface (commands/skills/MCP prompts). ACP never touches
238 // goals, checkpoints, or memory, so it depends on those sub-ports only — not the
239 // concrete *control.Controller.
240 type acpController interface {
241 control.Lifecycle
242 control.TurnControl
243 TrySteer(text string) bool
244 control.Approvals
245 control.Capabilities
246 control.SessionPersistence
247 // Goals backs ACP's normal/plan/goal collaboration-mode surface.
248 control.Goals
249 }
250
251 // acpSession is one open session: its controller, the on-disk transcript path
252 // (empty when persistence is off), and the cancel func of the in-flight turn
253 // (nil when idle) so session/cancel can abort it.
254 type acpSession struct {
255 id string
256 ctrl acpController
257 sink *updateSink
258 transcript string
259 cwd string
260 mcpServers []plugin.Spec
261 model string
262 // nil means use config; non-nil empty string means provider default.
263 effortOverride *string
264 runtimeProfile string
265 toolApprovalMode string
266 // runtimeState is the effective planner/sandbox posture captured after CLI
267 // hard overrides. status snapshots never reconstruct it from user config.
268 runtimeState SessionRuntimeState
269 status *statusTelemetry
270 // modeID is the ACP collaboration mode last reported to the client (normal |
271 // plan | goal). Goal draft mode turns the next user prompt into the goal.
272 // Both are guarded by mu; controller-side completion/plan exit is reconciled
273 // after each turn through current_mode_update.
274 modeID string
275 goalDraftMode bool
276 // pendingConfig queues config deltas requested while a turn or rebuild is
277 // in flight, holding at most one entry per axis: a later request replaces
278 // only its own axis (last-write-wins per axis), so a model change and a
279 // work-mode change queued back to back during one turn both survive to the
280 // drain instead of the second overwriting the first.
281 pendingConfig []sessionConfigDelta
282 // pendingReload coalesces _reasonix.io/session/reloadExtensions requests
283 // made while a turn or a rebuild is in flight; the finishTurn /
284 // post-maintenance drains run it once the session is idle.
285 pendingReload bool
286 title string
287 createdAt time.Time
288 updatedAt time.Time
289
290 mu sync.Mutex
291 // stateChangeMu serializes controller rebuilds with collaboration/approval
292 // changes so a swap cannot overwrite a newer user selection.
293 stateChangeMu sync.Mutex
294 cancel context.CancelFunc
295 done chan struct{}
296 running bool
297 deleted bool
298 // lease is the session lease guarding transcript against other runtimes
299 // (a desktop window, the CLI) for the life of this session. Held from
300 // session/new / session/load and released on close/delete/teardown.
301 // Config rebuilds keep the same transcript; when a snapshot conflict
302 // retargets the controller to a recovery branch, sessionRecoveredHandler
303 // moves transcript and this lease to the recovery file at commit time.
304 lease *agent.SessionLease
305 // maintenanceDone is non-nil while session-owned maintenance, such as an
306 // idle config rebuild, is in flight outside mu.
307 maintenanceDone chan struct{}
308 }
309
310 func (s *acpSession) begin(ctx context.Context) (context.Context, context.CancelFunc, bool) {
311 runCtx, cancel := context.WithCancel(ctx)
312 s.mu.Lock()
313 // A queued pendingConfig blocks new turns so a prompt never runs on the
314 // outgoing config. The turn or maintenance that queued it applies it from
315 // its defer, so no new turn is needed to drain the queue.
316 if s.running || s.deleted || s.maintenanceDone != nil || len(s.pendingConfig) > 0 {
317 s.mu.Unlock()
318 cancel()
319 return nil, nil, false
320 }
321 s.running = true
322 s.cancel = cancel
323 s.done = make(chan struct{})
324 s.mu.Unlock()
325 return runCtx, cancel, true
326 }
327
328 func (s *acpSession) finish() {
329 s.mu.Lock()
330 done := s.done
331 s.running = false
332 s.cancel = nil
333 s.done = nil
334 s.mu.Unlock()
335 if done != nil {
336 close(done)
337 }
338 }
339
340 func (s *acpSession) abort() {
341 s.mu.Lock()
342 c := s.cancel
343 s.mu.Unlock()
344 if c != nil {
345 c()
346 }
347 }
348
349 func (s *acpSession) abortAndWait() {
350 s.mu.Lock()
351 c := s.cancel
352 done := s.done
353 maintenanceDone := s.maintenanceDone
354 s.mu.Unlock()
355 if c != nil {
356 c()
357 }
358 if done != nil {
359 <-done
360 }
361 if maintenanceDone != nil {
362 <-maintenanceDone
363 }
364 }
365
366 func (s *acpSession) deleteAndWait() {
367 s.mu.Lock()
368 s.deleted = true
369 c := s.cancel
370 done := s.done
371 maintenanceDone := s.maintenanceDone
372 s.mu.Unlock()
373 if c != nil {
374 c()
375 }
376 if done != nil {
377 <-done
378 }
379 if maintenanceDone != nil {
380 <-maintenanceDone
381 }
382 }
383
384 func (s *acpSession) finishMaintenance(done chan struct{}) {
385 if done == nil {
386 return
387 }
388 closeDone := false
389 s.mu.Lock()
390 if s.maintenanceDone == done {
391 s.maintenanceDone = nil
392 closeDone = true
393 }
394 s.mu.Unlock()
395 if closeDone {
396 close(done)
397 }
398 }
399
400 // swapModeID records the mode reported to the client and returns the previous
401 // value, so callers can emit current_mode_update only on change.
402 func (s *acpSession) swapModeID(id string) (old string) {
403 s.mu.Lock()
404 old = s.modeID
405 s.modeID = id
406 s.mu.Unlock()
407 return old
408 }
409
410 // currentModeID returns the mode last reported to the client.
411 func (s *acpSession) currentModeID() string {
412 s.mu.Lock()
413 defer s.mu.Unlock()
414 if s.modeID == "" {
415 return sessionModeNormal
416 }
417 return s.modeID
418 }
419
420 func (s *acpSession) setGoalDraftMode(on bool) {
421 s.mu.Lock()
422 s.goalDraftMode = on
423 s.mu.Unlock()
424 }
425
426 func (s *acpSession) takeGoalDraftMode() bool {
427 s.mu.Lock()
428 on := s.goalDraftMode
429 s.goalDraftMode = false
430 s.mu.Unlock()
431 return on
432 }
433
434 func (s *acpSession) isGoalDraftMode() bool {
435 s.mu.Lock()
436 defer s.mu.Unlock()
437 return s.goalDraftMode
438 }
439
440 func (s *acpSession) setToolApprovalMode(mode string) {
441 s.mu.Lock()
442 s.toolApprovalMode = normalizeACPToolApprovalMode(mode)
443 s.mu.Unlock()
444 }
445
446 func (s *acpSession) swapToolApprovalMode(mode string) (old string) {
447 mode = normalizeACPToolApprovalMode(mode)
448 s.mu.Lock()
449 old = normalizeACPToolApprovalMode(s.toolApprovalMode)
450 s.toolApprovalMode = mode
451 s.mu.Unlock()
452 return old
453 }
454
455 func (s *acpSession) saveMetaIfPresent() {
456 s.mu.Lock()
457 path := s.transcript
458 meta := s.metaLocked()
459 s.mu.Unlock()
460 if path != "" && sessionFileExists(path) {
461 _ = saveACPMeta(path, meta)
462 }
463 }
464
465 // currentCtrl returns the session's controller under mu. rebuildSession swaps
466 // ctrl while holding mu, so any read of the field outside mu races with a
467 // concurrent config rebuild; always go through this accessor unless mu is
468 // already held.
469 func (s *acpSession) currentCtrl() acpController {
470 s.mu.Lock()
471 defer s.mu.Unlock()
472 return s.ctrl
473 }
474
475 // releaseSessionLease drops the session's transcript lease, if any. Idempotent.
476 func (s *acpSession) releaseSessionLease() {
477 s.mu.Lock()
478 lease := s.lease
479 s.lease = nil
480 s.mu.Unlock()
481 if lease != nil {
482 lease.Release()
483 }
484 }
485
486 // sessionLeaseBindError maps a lease-acquisition failure to the protocol
487 // error the client sees: a held session names its holder with the shared CLI
488 // wording; anything else is an internal error.
489 func sessionLeaseBindError(method string, err error) *RPCError {
490 if errors.Is(err, agent.ErrSessionLeaseHeld) {
491 return &RPCError{
492 Code: ErrInvalidRequest,
493 Message: method + ": " + control.SessionInUseMessage(err) + "; " + control.SessionLeaseCloseHint,
494 }
495 }
496 return &RPCError{Code: ErrInternal, Message: method + ": session lease: " + err.Error()}
497 }
498
499 // sessionRecoveredHandler returns the OnSessionRecovered callback wired into
500 // every controller built for session id. When a snapshot conflict retargets
501 // the controller to a recovery branch (turn-end autosave in persistAfterTurn,
502 // or the pre-rebuild snapshot in rebuildSession), the ACP bookkeeping must
503 // follow at commit time: session/prompt reports sess.transcript,
504 // session/delete destroys it, and the session lease must guard the file the
505 // controller actually writes. The recovery lease is acquired before the old
506 // one is released so the outgoing transcript stays guarded until the new one
507 // is secured; a failure aborts the recovery commit and the controller stays
508 // on the original path (the next save retries).
509 func (s *service) sessionRecoveredHandler(id string) func(control.SessionRecoveryInfo) error {
510 return func(info control.SessionRecoveryInfo) error {
511 recoveryPath := strings.TrimSpace(info.RecoveryPath)
512 if recoveryPath == "" {
513 return nil
514 }
515 sess := s.session(id)
516 if sess == nil {
517 return nil
518 }
519 lease, err := agent.TryAcquireSessionLease(recoveryPath)
520 if err != nil {
521 if errors.Is(err, agent.ErrSessionLeaseHeld) {
522 return fmt.Errorf("bind recovery session: %s; %s",
523 control.SessionInUseMessage(err), control.SessionLeaseCloseHint)
524 }
525 return fmt.Errorf("bind recovery session: %w", err)
526 }
527 sess.mu.Lock()
528 if sess.deleted {
529 sess.mu.Unlock()
530 lease.Release()
531 return fmt.Errorf("bind recovery session: session is deleted")
532 }
533 old := sess.lease
534 sess.lease = lease
535 sess.transcript = recoveryPath
536 meta := sess.metaLocked()
537 sess.mu.Unlock()
538 if old != nil {
539 old.Release()
540 }
541 _ = saveACPMeta(recoveryPath, meta)
542 // Leave a redirect on the id-keyed sidecar so restart-time lookups
543 // (session/load, session/resume, session/delete, loadMeta) resolve the
544 // id to the recovery file; without it the next process reopens the
545 // pre-recovery transcript. Always written against the id-keyed path,
546 // so resolution stays a single hop even for recovery-of-recovery.
547 if dir := s.sessionDir(); dir != "" {
548 if idPath := transcriptPath(dir, id); idPath != recoveryPath {
549 idMeta, _, err := loadACPMeta(idPath)
550 if err != nil {
551 slog.Warn("acp: load id-keyed meta for recovery redirect", "err", err)
552 idMeta = acpSessionMeta{}
553 }
554 if idMeta.SessionID == "" {
555 idMeta.SessionID = id
556 }
557 if idMeta.Cwd == "" {
558 idMeta.Cwd = meta.Cwd
559 }
560 if idMeta.CreatedAt.IsZero() {
561 idMeta.CreatedAt = meta.CreatedAt
562 }
563 idMeta.ActiveTranscript = filepath.Base(recoveryPath)
564 if err := saveACPMeta(idPath, idMeta); err != nil {
565 slog.Warn("acp: save recovery redirect", "err", err)
566 }
567 }
568 }
569 return nil
570 }
571 }
572
573 // initialize advertises the agent's capability set: persisted load plus ACP v1
574 // list/resume/close/delete lifecycle helpers, prompts carrying inline resource
575 // text (embeddedContext) but not image/audio, and stdio / Streamable HTTP MCP
576 // (no legacy sse).
577 func (s *service) initialize(_ context.Context, raw json.RawMessage) (any, error) {
578 var p InitializeParams
579 if len(raw) > 0 && json.Unmarshal(raw, &p) == nil {
580 s.setClientCapabilities(p.ClientCapabilities)
581 }
582 return InitializeResult{
583 ProtocolVersion: ProtocolVersion,
584 AgentCapabilities: AgentCapabilities{
585 LoadSession: true,
586 SessionCapabilities: SessionCapabilities{
587 List: &EmptyCapability{},
588 Resume: &EmptyCapability{},
589 Close: &EmptyCapability{},
590 Delete: &EmptyCapability{},
591 },
592 PromptCapabilities: PromptCapabilities{
593 Image: false,
594 Audio: false,
595 EmbeddedContext: true,
596 },
597 MCPCapabilities: MCPCapabilities{HTTP: true, SSE: false},
598 Meta: map[string]any{
599 "reasonix.io": ReasonixExtensionCapabilities{
600 SessionSteer: &SessionSteerCapability{Method: sessionSteerMethod},
601 SessionReloadExtensions: &SessionReloadExtensionsCapability{Method: sessionReloadExtensionsMethod},
602 ExtensionSurface: &ExtensionSurfaceCapability{Supported: true, SchemaVersion: reasonixExtensionSurfaceSchemaVersion},
603 },
604 sessionStatusMethod: ReasonixSchemaCapability{SchemaVersion: reasonixStatusSchemaVersion},
605 sessionStatusUpdateMethod: ReasonixSchemaCapability{SchemaVersion: reasonixStatusSchemaVersion},
606 },
607 },
608 AgentInfo: Implementation{Name: s.info.Name, Version: s.info.Version},
609 AuthMethods: []AuthMethod{reasonixSetupAuthMethod()},
610 }, nil
611 }
612
613 func reasonixSetupAuthMethod() AuthMethod {
614 return AuthMethod{
615 ID: "reasonix-setup",
616 Name: "Reasonix setup",
617 Description: "Configure Reasonix providers and credentials in a terminal",
618 Type: "terminal",
619 Args: []string{"setup"},
620 }
621 }
622
623 func (s *service) authenticate(_ context.Context, raw json.RawMessage) (any, error) {
624 var p AuthenticateParams
625 if err := json.Unmarshal(raw, &p); err != nil {
626 return nil, &RPCError{Code: ErrInvalidParams, Message: "authenticate: " + err.Error()}
627 }
628 if strings.TrimSpace(p.MethodID) != reasonixSetupAuthMethod().ID {
629 return nil, &RPCError{Code: ErrInvalidParams, Message: "authenticate: unknown methodId " + p.MethodID}
630 }
631 return AuthenticateResult{}, nil
632 }
633
634 // sessionNew opens a session: it mints an id, builds the session's sink bound to
635 // that id, asks the Factory to assemble the controller, switches the controller
636 // to interactive approval (so tool gates surface as ApprovalRequest events the
637 // sink forwards), and registers it.
638 func (s *service) sessionNew(ctx context.Context, raw json.RawMessage) (any, error) {
639 var p SessionNewParams
640 if len(raw) > 0 {
641 if err := json.Unmarshal(raw, &p); err != nil {
642 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/new: " + err.Error()}
643 }
644 }
645 cwd, err := s.resolveSessionCwd(p.Cwd, "")
646 if err != nil {
647 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/new: " + err.Error()}
648 }
649 mcpServers, err := mcpSpecs(p.MCPServers, cwd)
650 if err != nil {
651 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/new: " + err.Error()}
652 }
653 cfgState, err := s.sessionConfigState(ctx, SessionConfigStateParams{Cwd: cwd})
654 if err != nil {
655 return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
656 }
657 cfgState = withToolApprovalConfig(cfgState, control.ToolApprovalAsk)
658 runtimeState, err := s.sessionRuntimeState(ctx, SessionRuntimeStateParams{
659 Cwd: cwd, Model: cfgState.Model, RuntimeProfile: cfgState.RuntimeProfile,
660 })
661 if err != nil {
662 return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
663 }
664
665 id, err := newSessionID()
666 if err != nil {
667 return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
668 }
669
670 sink := newUpdateSink(s.conn, id)
671 sink.bindCwd(cwd)
672 sink.bindExtensionSurface(s.extensionSurfaceSupported())
673 sessionParams := SessionParams{
674 Cwd: cwd,
675 MCPServers: mcpServers,
676 Sink: sink,
677 Model: cfgState.Model,
678 EffortOverride: cloneStringPtr(cfgState.EffortOverride),
679 RuntimeProfile: cfgState.RuntimeProfile,
680 OnSessionRecovered: s.sessionRecoveredHandler(id),
681 }
682 s.bindClientIO(&sessionParams, id)
683 ctrl, err := s.factory.NewSession(ctx, sessionParams)
684 if err != nil {
685 return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
686 }
687 ctrl.EnableInteractiveApproval()
688 sink.bindApprove(ctrl.Approve)
689 sink.bindAnswer(ctrl.AnswerQuestion)
690
691 now := time.Now().UTC()
692 sess := &acpSession{
693 id: id,
694 ctrl: ctrl,
695 sink: sink,
696 cwd: cwd,
697 mcpServers: clonePluginSpecs(mcpServers),
698 model: cfgState.Model,
699 effortOverride: cloneStringPtr(cfgState.EffortOverride),
700 runtimeProfile: cfgState.RuntimeProfile,
701 toolApprovalMode: control.ToolApprovalAsk,
702 runtimeState: runtimeState,
703 status: newStatusTelemetry(),
704 modeID: sessionModeNormal,
705 createdAt: now,
706 updatedAt: now,
707 }
708 s.bindStatusEvents(sess)
709 // Pin a transcript file keyed by session id when the controller has a session
710 // dir, so every turn auto-saves there, session/prompt can hand the path back,
711 // and session/load can find it again by id across process restarts. The
712 // session lease is taken with it (defensive: the id-keyed path is brand new)
713 // so no other runtime can bind the transcript while this session lives.
714 if dir := ctrl.SessionDir(); dir != "" {
715 sess.transcript = transcriptPath(dir, id)
716 lease, err := agent.TryAcquireSessionLease(sess.transcript)
717 if err != nil {
718 ctrl.Close()
719 return nil, sessionLeaseBindError("session/new", err)
720 }
721 sess.lease = lease
722 ctrl.SetFreshSessionPath(sess.transcript)
723 }
724
725 s.mu.Lock()
726 s.sessions[id] = sess
727 s.mu.Unlock()
728
729 // Fold in the live controller's extension catalog so plugin/... models
730 // are discoverable from the very first session/new result.
731 cfgState = enrichStateWithExtensionModels(cfgState, ctrl.ProviderCatalog())
732 return afterResponse{
733 result: SessionNewResult{
734 SessionID: id,
735 Models: cfgState.Models,
736 Modes: sessionModesState(sessionModeNormal),
737 ConfigOptions: cfgState.ConfigOptions,
738 },
739 after: func() { s.sendAvailableCommands(sess) },
740 }, nil
741 }
742
743 // Session modes exposed over ACP describe how the agent advances the task.
744 // Tool approval and runtime profile are independent config options. The legacy
745 // default/auto ids remain accepted for clients that used the old mixed axis.
746 const (
747 sessionModeNormal = "normal"
748 sessionModePlan = "plan"
749 sessionModeGoal = "goal"
750 sessionModeLegacyDefault = "default"
751 sessionModeLegacyAuto = "auto"
752 )
753
754 func sessionModesState(current string) *SessionModeState {
755 return &SessionModeState{
756 CurrentModeID: current,
757 AvailableModes: []SessionMode{
758 {ID: sessionModeNormal, Name: "Normal", Description: "Work directly and pause when user input is required"},
759 {ID: sessionModePlan, Name: "Plan", Description: "Research and propose a plan before making changes"},
760 {ID: sessionModeGoal, Name: "Goal", Description: "Keep advancing the next prompt as a goal until complete or blocked"},
761 },
762 }
763 }
764
765 // sessionSetMode switches the session's operating mode and confirms it with a
766 // current_mode_update, per the ACP session-mode contract.
767 func (s *service) sessionSetMode(ctx context.Context, raw json.RawMessage) (any, error) {
768 var p SessionSetModeParams
769 if err := json.Unmarshal(raw, &p); err != nil {
770 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_mode: " + err.Error()}
771 }
772 sess := s.session(p.SessionID)
773 if sess == nil {
774 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_mode: unknown session " + p.SessionID}
775 }
776 sess.stateChangeMu.Lock()
777 defer sess.stateChangeMu.Unlock()
778 ctrl := sess.currentCtrl()
779 nextMode := p.ModeID
780 legacyApproval := ""
781 switch p.ModeID {
782 case sessionModeNormal:
783 ctrl.SetPlanMode(false)
784 ctrl.ClearGoal()
785 case sessionModePlan:
786 ctrl.ClearGoal()
787 ctrl.SetPlanMode(true)
788 case sessionModeGoal:
789 ctrl.SetPlanMode(false)
790 case sessionModeLegacyDefault:
791 nextMode = sessionModeNormal
792 legacyApproval = control.ToolApprovalAsk
793 ctrl.SetPlanMode(false)
794 ctrl.ClearGoal()
795 case sessionModeLegacyAuto:
796 nextMode = sessionModeNormal
797 legacyApproval = control.ToolApprovalYolo
798 ctrl.SetPlanMode(false)
799 ctrl.ClearGoal()
800 default:
801 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_mode: unknown modeId " + p.ModeID}
802 }
803 sess.setGoalDraftMode(nextMode == sessionModeGoal && ctrl.GoalStatus() != control.GoalStatusRunning)
804 if legacyApproval != "" {
805 ctrl.SetToolApprovalMode(legacyApproval)
806 sess.setToolApprovalMode(legacyApproval)
807 if cfgState, err := s.configStateForSession(ctx, sess); err == nil {
808 sess.sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: cfgState.ConfigOptions})
809 }
810 }
811 if sess.swapModeID(nextMode) != nextMode {
812 sess.sink.send(currentModeUpdate{SessionUpdate: "current_mode_update", CurrentModeID: nextMode})
813 }
814 sess.saveMetaIfPresent()
815 return SessionSetModeResult{}, nil
816 }
817
818 // emitModeDrift reports controller-side mode flips (plan mode auto-exits when
819 // a plan is approved, a config rebuild resets switches) as current_mode_update
820 // so the client's mode picker stays truthful.
821 func (s *service) emitModeDrift(sess *acpSession) {
822 // Hold stateChangeMu across the controller read and the session-state swap:
823 // a session/set_mode completing between them (it holds this lock) would
824 // otherwise be read back as drift, roll the session's modeID and metadata
825 // back to the pre-selection value, and make the next rebuild re-apply that
826 // stale mode to the replacement controller.
827 sess.stateChangeMu.Lock()
828 defer sess.stateChangeMu.Unlock()
829 ctrl := sess.currentCtrl()
830 current := sessionModeNormal
831 switch {
832 case ctrl.PlanMode():
833 current = sessionModePlan
834 case ctrl.GoalStatus() == control.GoalStatusRunning || sess.isGoalDraftMode():
835 current = sessionModeGoal
836 }
837 if sess.swapModeID(current) != current {
838 sess.sink.send(currentModeUpdate{SessionUpdate: "current_mode_update", CurrentModeID: current})
839 sess.saveMetaIfPresent()
840 }
841 }
842
843 func (s *service) emitToolApprovalDrift(ctx context.Context, sess *acpSession) {
844 // Same contract as emitModeDrift: serialize with switchSessionToolApproval
845 // and rebuilds so a user selection landing between the controller read and
846 // the swap below is never reverted.
847 sess.stateChangeMu.Lock()
848 defer sess.stateChangeMu.Unlock()
849 current := normalizeACPToolApprovalMode(sess.currentCtrl().ToolApprovalMode())
850 if sess.swapToolApprovalMode(current) == current {
851 return
852 }
853 if cfgState, err := s.configStateForSession(ctx, sess); err == nil {
854 sess.sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: cfgState.ConfigOptions})
855 }
856 sess.saveMetaIfPresent()
857 }
858
859 // sessionLoad resumes a previously-saved session by id: it builds a controller
860 // (rooted at the requested cwd), seeds it from the on-disk transcript, replays
861 // the conversation to the client as session/update notifications, and registers
862 // it for subsequent prompts. A session already live in this process is replayed
863 // from memory without rebuilding.
864 func (s *service) sessionLoad(ctx context.Context, raw json.RawMessage) (any, error) {
865 var p SessionLoadParams
866 if err := json.Unmarshal(raw, &p); err != nil {
867 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/load: " + err.Error()}
868 }
869 cfgState, err := s.openExistingSession(ctx, "session/load", p.SessionID, p.Cwd, p.MCPServers, true)
870 if err != nil {
871 return nil, err
872 }
873 return afterResponse{
874 result: SessionLoadResult{Models: cfgState.Models, Modes: s.sessionModesFor(p.SessionID), ConfigOptions: cfgState.ConfigOptions},
875 after: func() { s.sendAvailableCommands(s.session(p.SessionID)) },
876 }, nil
877 }
878
879 // sessionModesFor reports the modes state for a just-opened session. A live
880 // session keeps its current normal/plan/goal selection, so load/resume must not
881 // reset a reconnecting client's mode picker to normal.
882 func (s *service) sessionModesFor(id string) *SessionModeState {
883 if sess := s.session(id); sess != nil {
884 return sessionModesState(sess.currentModeID())
885 }
886 return sessionModesState(sessionModeNormal)
887 }
888
889 // sessionResume restores a previously-saved session without replaying its
890 // conversation history to the client.
891 func (s *service) sessionResume(ctx context.Context, raw json.RawMessage) (any, error) {
892 var p SessionResumeParams
893 if err := json.Unmarshal(raw, &p); err != nil {
894 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/resume: " + err.Error()}
895 }
896 cfgState, err := s.openExistingSession(ctx, "session/resume", p.SessionID, p.Cwd, p.MCPServers, false)
897 if err != nil {
898 return nil, err
899 }
900 return afterResponse{
901 result: SessionResumeResult{Models: cfgState.Models, Modes: s.sessionModesFor(p.SessionID), ConfigOptions: cfgState.ConfigOptions},
902 after: func() { s.sendAvailableCommands(s.session(p.SessionID)) },
903 }, nil
904 }
905
906 func (s *service) openExistingSession(ctx context.Context, method, id, cwdParam string, servers []MCPServerSpec, replay bool) (SessionConfigState, error) {
907 if err := validateSessionID(method, id); err != nil {
908 return SessionConfigState{}, err
909 }
910 cwd, err := s.resolveSessionCwd(cwdParam, id)
911 if err != nil {
912 return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": " + err.Error()}
913 }
914 mcpServers, err := mcpSpecs(servers, cwd)
915 if err != nil {
916 return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": " + err.Error()}
917 }
918
919 if sess := s.session(id); sess != nil {
920 if agent.IsCleanupPending(sess.transcript) {
921 return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
922 }
923 if replay {
924 ctrl := sess.currentCtrl()
925 replaySink := newUpdateSink(s.conn, id)
926 replaySink.bindCwd(sess.cwd)
927 replaySink.replay(ctrl.History())
928 }
929 cfgState, err := s.configStateForSession(ctx, sess)
930 if err != nil {
931 return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
932 }
933 return cfgState, nil
934 }
935
936 var saved acpSessionMeta
937 persistedPath := ""
938 if dir := s.sessionDir(); dir != "" {
939 persistedPath = resolveTranscriptPath(dir, id)
940 if agent.IsCleanupPending(persistedPath) {
941 return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
942 }
943 meta, _, metaErr := loadACPMeta(persistedPath)
944 if metaErr != nil {
945 return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + metaErr.Error()}
946 }
947 saved = meta
948 }
949 cfgParams := SessionConfigStateParams{
950 Cwd: cwd,
951 Model: saved.Model,
952 EffortOverride: cloneStringPtr(saved.EffortOverride),
953 RuntimeProfile: saved.RuntimeProfile,
954 }
955 cfgState, err := s.sessionConfigState(ctx, cfgParams)
956 if err != nil && (strings.TrimSpace(saved.Model) != "" || saved.EffortOverride != nil || strings.TrimSpace(saved.RuntimeProfile) != "") {
957 cfgState, err = s.sessionConfigState(ctx, SessionConfigStateParams{Cwd: cwd})
958 }
959 if err != nil {
960 return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
961 }
962 runtimeState, err := s.sessionRuntimeState(ctx, SessionRuntimeStateParams{
963 Cwd: cwd, Model: cfgState.Model, RuntimeProfile: cfgState.RuntimeProfile,
964 })
965 if err != nil {
966 return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
967 }
968
969 sink := newUpdateSink(s.conn, id)
970 sink.bindCwd(cwd)
971 sink.bindExtensionSurface(s.extensionSurfaceSupported())
972 sessionParams := SessionParams{
973 Cwd: cwd,
974 MCPServers: mcpServers,
975 Sink: sink,
976 Model: cfgState.Model,
977 EffortOverride: cloneStringPtr(cfgState.EffortOverride),
978 RuntimeProfile: cfgState.RuntimeProfile,
979 OnSessionRecovered: s.sessionRecoveredHandler(id),
980 }
981 s.bindClientIO(&sessionParams, id)
982 ctrl, err := s.factory.NewSession(ctx, sessionParams)
983 if err != nil {
984 return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
985 }
986 ctrl.EnableInteractiveApproval()
987 sink.bindApprove(ctrl.Approve)
988 sink.bindAnswer(ctrl.AnswerQuestion)
989
990 dir := ctrl.SessionDir()
991 if dir == "" {
992 ctrl.Close()
993 return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": persistence is disabled"}
994 }
995 path := resolveTranscriptPath(dir, id)
996 if path != persistedPath && agent.IsCleanupPending(path) {
997 ctrl.Close()
998 return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
999 }
1000 // Bind the transcript for writing only if no other runtime (a desktop
1001 // window, the CLI) holds it; the editor should not silently double-write a
1002 // session that is open elsewhere.
1003 lease, leaseErr := agent.TryAcquireSessionLease(path)
1004 if leaseErr != nil {
1005 ctrl.Close()
1006 return SessionConfigState{}, sessionLeaseBindError(method, leaseErr)
1007 }
1008 loaded, err := agent.LoadSession(path)
1009 if err != nil {
1010 lease.Release()
1011 ctrl.Close()
1012 return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": unknown session " + id}
1013 }
1014 ctrl.Resume(loaded, path)
1015 toolApprovalMode := normalizeACPToolApprovalMode(saved.ToolApprovalMode)
1016 ctrl.SetToolApprovalMode(toolApprovalMode)
1017 modeID := normalizeACPCollaborationMode(saved.CollaborationMode)
1018 goalDraftMode := false
1019 switch modeID {
1020 case sessionModePlan:
1021 ctrl.SetPlanMode(true)
1022 case sessionModeGoal:
1023 ctrl.SetPlanMode(false)
1024 goalDraftMode = ctrl.GoalStatus() != control.GoalStatusRunning
1025 default:
1026 if ctrl.GoalStatus() == control.GoalStatusRunning {
1027 modeID = sessionModeGoal
1028 } else {
1029 modeID = sessionModeNormal
1030 ctrl.SetPlanMode(false)
1031 }
1032 }
1033
1034 meta := metadataForLoadedSession(path, id, cwd, ctrl.History())
1035 meta.Model = cfgState.Model
1036 meta.EffortOverride = cloneStringPtr(cfgState.EffortOverride)
1037 meta.RuntimeProfile = cfgState.RuntimeProfile
1038 meta.ToolApprovalMode = toolApprovalMode
1039 meta.CollaborationMode = modeID
1040 cfgState = withToolApprovalConfig(cfgState, toolApprovalMode)
1041 sess := &acpSession{
1042 id: id,
1043 ctrl: ctrl,
1044 sink: sink,
1045 transcript: path,
1046 cwd: meta.Cwd,
1047 mcpServers: clonePluginSpecs(mcpServers),
1048 model: cfgState.Model,
1049 effortOverride: cloneStringPtr(cfgState.EffortOverride),
1050 runtimeProfile: cfgState.RuntimeProfile,
1051 toolApprovalMode: toolApprovalMode,
1052 runtimeState: runtimeState,
1053 status: restoreStatusTelemetry(saved.Status),
1054 modeID: modeID,
1055 goalDraftMode: goalDraftMode,
1056 title: meta.Title,
1057 createdAt: meta.CreatedAt,
1058 updatedAt: meta.UpdatedAt,
1059 lease: lease,
1060 }
1061 s.bindStatusEvents(sess)
1062 if err := saveACPMeta(path, sess.meta()); err != nil {
1063 sess.releaseSessionLease()
1064 ctrl.Close()
1065 return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: method + ": " + err.Error()}
1066 }
1067 s.mu.Lock()
1068 s.sessions[id] = sess
1069 s.mu.Unlock()
1070
1071 if replay {
1072 sink.replay(ctrl.History())
1073 }
1074 return enrichStateWithExtensionModels(cfgState, ctrl.ProviderCatalog()), nil
1075 }
1076
1077 // transcriptPath is where a session's transcript lives — keyed by id so
1078 // session/load can recover it. Distinct from the cli's timestamp-labelled
1079 // chat/run session files (those are addressed by a picker, not by id).
1080 func transcriptPath(dir, id string) string {
1081 return filepath.Join(dir, id+".jsonl")
1082 }
1083
1084 // resolveTranscriptPath returns the transcript file session id currently
1085 // lives in. That is the id-keyed path by default; after a snapshot recovery
1086 // moved the live session onto a recovery branch, the id-keyed sidecar carries
1087 // an ActiveTranscript redirect (written by sessionRecoveredHandler) that
1088 // load/resume/delete/meta lookups must follow, or a restart silently reopens
1089 // the pre-recovery transcript. The redirect is a basename, must stay inside
1090 // dir, and its target must exist and claim the same session id; anything else
1091 // falls back to the id-keyed path.
1092 func resolveTranscriptPath(dir, id string) string {
1093 path := transcriptPath(dir, id)
1094 meta, ok, err := loadACPMeta(path)
1095 if err != nil || !ok {
1096 return path
1097 }
1098 active := strings.TrimSpace(meta.ActiveTranscript)
1099 if active == "" || active == filepath.Base(path) {
1100 return path
1101 }
1102 if filepath.Base(active) != active {
1103 return path
1104 }
1105 resolved := filepath.Join(dir, active)
1106 if !sessionFileExists(resolved) {
1107 return path
1108 }
1109 targetMeta, ok, err := loadACPMeta(resolved)
1110 if err != nil || !ok || targetMeta.SessionID != id {
1111 return path
1112 }
1113 return resolved
1114 }
1115
1116 // sessionPrompt runs one turn. It flattens the prompt blocks to text and runs the
1117 // session's controller synchronously under a per-turn cancelable context (so
1118 // session/cancel can stop it), then reports why the turn ended. The controller
1119 // streams the turn's events to the session's sink as it runs.
1120 func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any, error) {
1121 var p SessionPromptParams
1122 if err := json.Unmarshal(raw, &p); err != nil {
1123 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: " + err.Error()}
1124 }
1125 sess := s.session(p.SessionID)
1126 if sess == nil {
1127 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: unknown session " + p.SessionID}
1128 }
1129 text := FlattenPrompt(p.Prompt)
1130 if text == "" {
1131 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: empty prompt"}
1132 }
1133 text = s.resolveSlashPrompt(ctx, sess, text)
1134
1135 runCtx, cancel, ok := sess.begin(ctx)
1136 if !ok {
1137 return nil, &RPCError{Code: ErrInvalidRequest, Message: "session/prompt: session already has an active prompt"}
1138 }
1139 if sess.status == nil {
1140 sess.status = newStatusTelemetry()
1141 }
1142 sess.status.beginTurn()
1143 s.publishStatus(sess, "phase")
1144 sess.sink.setTurnContext(runCtx)
1145 if sess.takeGoalDraftMode() {
1146 sess.currentCtrl().SetGoal(text)
1147 sess.saveMetaIfPresent()
1148 }
1149 defer func() {
1150 sess.sink.clearTurnContext()
1151 s.finishTurn(ctx, sess)
1152 cancel()
1153 }()
1154 runErr := sess.ctrl.RunTurn(runCtx, text)
1155
1156 statusEvent := sess.status.finishTurn(
1157 runErr,
1158 runCtx.Err() != nil,
1159 sess.currentCtrl().GoalStatus(),
1160 finalAssistantSummary(sess.currentCtrl()),
1161 )
1162 s.publishStatus(sess, statusEvent)
1163 // Persist after status finalization (best-effort) so reconnect recovers both
1164 // the transcript and the same sequence/usage/outcome snapshot.
1165 sess.persistAfterTurn(text)
1166
1167 stop := StopEndTurn
1168 if runErr != nil {
1169 if runCtx.Err() != nil {
1170 stop = StopCancelled
1171 } else {
1172 stop = StopError
1173 }
1174 }
1175 res := SessionPromptResult{StopReason: stop}
1176 if sess.transcript != "" {
1177 res.TranscriptPath = &sess.transcript
1178 }
1179 return res, nil
1180 }
1181
1182 // sessionSteer injects user guidance into an active turn and acknowledges once
1183 // the agent has queued it for the next safe loop boundary.
1184 func (s *service) sessionSteer(_ context.Context, raw json.RawMessage) (any, error) {
1185 var p SessionSteerParams
1186 if err := json.Unmarshal(raw, &p); err != nil {
1187 return nil, &RPCError{Code: ErrInvalidParams, Message: sessionSteerMethod + ": " + err.Error()}
1188 }
1189 sess := s.session(p.SessionID)
1190 if sess == nil {
1191 return nil, &RPCError{Code: ErrInvalidParams, Message: sessionSteerMethod + ": unknown session " + p.SessionID}
1192 }
1193 text := FlattenPrompt(p.Prompt)
1194 if text == "" {
1195 return nil, &RPCError{Code: ErrInvalidParams, Message: sessionSteerMethod + ": empty prompt"}
1196 }
1197 if !sess.currentCtrl().TrySteer(text) {
1198 return nil, &RPCError{Code: ErrInvalidRequest, Message: sessionSteerMethod + ": session has no active prompt"}
1199 }
1200 return SessionSteerResult{}, nil
1201 }
1202
1203 // sessionReloadExtensions rebuilds a session's agent runtime in place —
1204 // tools, skills, commands, hooks, MCP servers, and providers are re-discovered
1205 // — while the session (transcript, approval grants, goal and recovery state)
1206 // carries over via boot.Rebuild. It follows the same contract as a config
1207 // switch: a turn or rebuild in flight coalesces exactly one queued reload,
1208 // drained when the session goes idle; a failure keeps the old controller fully
1209 // usable; the old controller's resources are released only after the swap.
1210 func (s *service) sessionReloadExtensions(ctx context.Context, raw json.RawMessage) (any, error) {
1211 var p SessionReloadExtensionsParams
1212 if err := json.Unmarshal(raw, &p); err != nil {
1213 return nil, &RPCError{Code: ErrInvalidParams, Message: sessionReloadExtensionsMethod + ": " + err.Error()}
1214 }
1215 sess := s.session(p.SessionID)
1216 if sess == nil {
1217 return nil, &RPCError{Code: ErrInvalidParams, Message: sessionReloadExtensionsMethod + ": unknown session " + p.SessionID}
1218 }
1219 return s.reloadSessionExtensions(ctx, sess)
1220 }
1221
1222 func (s *service) reloadSessionExtensions(ctx context.Context, sess *acpSession) (any, error) {
1223 rebuilder, ok := s.factory.(SessionRebuilder)
1224 if !ok {
1225 return nil, &RPCError{Code: ErrInvalidRequest, Message: sessionReloadExtensionsMethod + ": runtime reload is unavailable in this session"}
1226 }
1227 if !sess.stateChangeMu.TryLock() {
1228 // A config switch or reload is in maintenance: coalesce one reload
1229 // behind it; the maintenance owner's post-maintenance drain runs it
1230 // (mirrors the pendingConfig queue contract in rebuildSession).
1231 sess.mu.Lock()
1232 if sess.maintenanceDone != nil && !sess.deleted {
1233 sess.pendingReload = true
1234 sess.mu.Unlock()
1235 return SessionReloadExtensionsResult{Queued: true}, nil
1236 }
1237 sess.mu.Unlock()
1238 sess.stateChangeMu.Lock()
1239 }
1240 didMaintenance := false
1241 res, err := s.reloadSessionExtensionsLocked(ctx, sess, rebuilder, &didMaintenance)
1242 sess.stateChangeMu.Unlock()
1243 if didMaintenance {
1244 s.reportPendingSessionConfigError(ctx, sess, s.applyPendingSessionConfig(ctx, sess), "after maintenance")
1245 s.drainPendingReload(ctx, sess)
1246 }
1247 return res, err
1248 }
1249
1250 // reloadSessionExtensionsLocked is reloadSessionExtensions' body; callers hold
1251 // stateChangeMu. The busy/queue checks and the publish/close ordering mirror
1252 // rebuildSessionLocked, but the build itself goes through the factory's
1253 // boot.Rebuild path instead of NewSession + manual migration.
1254 func (s *service) reloadSessionExtensionsLocked(ctx context.Context, sess *acpSession, rebuilder SessionRebuilder, didMaintenance *bool) (any, error) {
1255 sess.mu.Lock()
1256 if sess.deleted {
1257 sess.mu.Unlock()
1258 return nil, &RPCError{Code: ErrInvalidRequest, Message: sessionReloadExtensionsMethod + ": session is deleted"}
1259 }
1260 status := sess.ctrl.RuntimeStatus()
1261 if status.PendingPrompt {
1262 sess.mu.Unlock()
1263 return nil, sessionConfigActiveWorkError("answer pending prompts before reloading the runtime")
1264 }
1265 if !sess.running && !status.Running && status.BackgroundJobs > 0 {
1266 sess.mu.Unlock()
1267 return nil, sessionConfigActiveWorkError("stop background jobs before reloading the runtime")
1268 }
1269 if sess.running || status.Running || sess.maintenanceDone != nil {
1270 // Busy: coalesce exactly one reload; finishTurn (or the maintenance
1271 // owner's post-maintenance drain) runs it once the session is idle.
1272 sess.pendingReload = true
1273 sess.mu.Unlock()
1274 return SessionReloadExtensionsResult{Queued: true}, nil
1275 }
1276 // Claim the queued reload and raise maintenance in the same critical
1277 // section (mirrors rebuildSessionLocked): begin must never observe an
1278 // idle session between the two.
1279 sess.pendingReload = false
1280 cur := sess.ctrl
1281 sink := sess.sink
1282 mcpServers := clonePluginSpecs(sess.mcpServers)
1283 cwd := sess.cwd
1284 model := sess.model
1285 effortOverride := cloneStringPtr(sess.effortOverride)
1286 runtimeProfile := sess.runtimeProfile
1287 maintenanceDone := make(chan struct{})
1288 sess.maintenanceDone = maintenanceDone
1289 *didMaintenance = true
1290 sess.mu.Unlock()
1291 defer func() {
1292 sess.finishMaintenance(maintenanceDone)
1293 }()
1294
1295 if err := cur.Snapshot(); err != nil {
1296 return nil, &RPCError{Code: ErrInternal, Message: sessionReloadExtensionsMethod + ": snapshot before reload: " + err.Error()}
1297 }
1298 // Read the path only after Snapshot: a conflict can retarget cur to a
1299 // recovery branch, and boot.Rebuild binds the replacement to whatever
1300 // cur reports now (see rebuildSessionLocked). SessionPath is
1301 // controller-locked, so reading it off sess.mu is safe.
1302 prevPath := cur.SessionPath()
1303 old, ok := cur.(*control.Controller)
1304 if !ok {
1305 return nil, &RPCError{Code: ErrInternal, Message: sessionReloadExtensionsMethod + ": session controller does not support rebuild"}
1306 }
1307 rebuildParams := SessionParams{
1308 Cwd: cwd,
1309 MCPServers: mcpServers,
1310 Sink: sink,
1311 Model: model,
1312 EffortOverride: effortOverride,
1313 RuntimeProfile: runtimeProfile,
1314 OnSessionRecovered: s.sessionRecoveredHandler(sess.id),
1315 }
1316 // The rebuilt controller must keep the client-capability wiring (fs
1317 // overlay, host terminal) — mirrors rebuildSessionLocked.
1318 s.bindClientIO(&rebuildParams, sess.id)
1319 newCtrl, err := rebuilder.RebuildSession(ctx, rebuildParams, old)
1320 if err != nil {
1321 return nil, &RPCError{Code: ErrInternal, Message: sessionReloadExtensionsMethod + ": " + err.Error()}
1322 }
1323 newCtrl.EnableInteractiveApproval()
1324 // Config on disk may have changed the effective planner/sandbox posture;
1325 // recompute the status snapshot from the same resolved inputs.
1326 runtimeState, err := s.sessionRuntimeState(ctx, SessionRuntimeStateParams{
1327 Cwd: cwd, Model: model, RuntimeProfile: runtimeProfile,
1328 })
1329 if err != nil {
1330 newCtrl.ReleaseResources()
1331 return nil, &RPCError{Code: ErrInternal, Message: sessionReloadExtensionsMethod + ": runtime state: " + err.Error()}
1332 }
1333 // Persist before publishing the replacement. If this fails, the outgoing
1334 // controller and transcript still agree and remain fully usable (mirrors
1335 // the config switch).
1336 if prevPath != "" {
1337 if err := newCtrl.Snapshot(); err != nil {
1338 newCtrl.ReleaseResources()
1339 return nil, &RPCError{Code: ErrInternal, Message: sessionReloadExtensionsMethod + ": snapshot after reload: " + err.Error()}
1340 }
1341 }
1342
1343 sess.mu.Lock()
1344 if sess.deleted {
1345 sess.mu.Unlock()
1346 newCtrl.ReleaseResources()
1347 return nil, &RPCError{Code: ErrInvalidRequest, Message: sessionReloadExtensionsMethod + ": session is deleted"}
1348 }
1349 if sess.ctrl != cur {
1350 sess.mu.Unlock()
1351 newCtrl.ReleaseResources()
1352 return nil, sessionConfigActiveWorkError("session changed while reloading; retry")
1353 }
1354 sess.ctrl = newCtrl
1355 sess.runtimeState = runtimeState
1356 if sess.transcript != "" && sessionFileExists(sess.transcript) {
1357 _ = saveACPMeta(sess.transcript, sess.metaLocked())
1358 }
1359 sess.mu.Unlock()
1360 sink.bindApprove(newCtrl.Approve)
1361 sink.bindAnswer(newCtrl.AnswerQuestion)
1362
1363 // Release the outgoing controller only after the swap published the
1364 // replacement. ReleaseResources (not Close): the session logically
1365 // continues, so SessionEnd hooks must not fire — mirrors the config
1366 // switch.
1367 cur.ReleaseResources()
1368 // Clients see refreshed plugin commands without waiting for the next turn.
1369 s.sendAvailableCommands(sess)
1370 return SessionReloadExtensionsResult{}, nil
1371 }
1372
1373 // drainPendingReload runs the coalesced reloadExtensions request once the
1374 // session is idle. Called from finishTurn and after a config switch's or a
1375 // reload's own maintenance completes; callers must NOT hold stateChangeMu
1376 // (the reload re-acquires it).
1377 func (s *service) drainPendingReload(ctx context.Context, sess *acpSession) {
1378 if _, ok := s.factory.(SessionRebuilder); !ok {
1379 return
1380 }
1381 sess.mu.Lock()
1382 if !sess.pendingReload || sess.deleted || sess.running || sess.maintenanceDone != nil || len(sess.pendingConfig) > 0 {
1383 sess.mu.Unlock()
1384 return
1385 }
1386 sess.mu.Unlock()
1387 if _, err := s.reloadSessionExtensions(ctx, sess); err != nil {
1388 s.reportPendingSessionConfigError(ctx, sess, err, "after queued reload")
1389 }
1390 }
1391
1392 // finishTurn reconciles controller-side drift and drains any config switch
1393 // queued during the turn. Drift must be reconciled before finish() exposes
1394 // the session as idle: a concurrent config switch races on sess.running, and
1395 // if it wins that race while modeID/toolApprovalMode are still stale (a
1396 // slash command or plan/goal completion changed them inside the turn), it
1397 // rebuilds the replacement controller from the outgoing state instead of the
1398 // one this turn actually ended in.
1399 func (s *service) finishTurn(ctx context.Context, sess *acpSession) {
1400 s.emitModeDrift(sess)
1401 s.emitToolApprovalDrift(ctx, sess)
1402 sess.finish()
1403 s.reportPendingSessionConfigError(ctx, sess, s.applyPendingSessionConfig(ctx, sess), "after turn")
1404 // A reloadExtensions request queued during the turn runs now that the
1405 // session may be idle; the drain re-checks busy state.
1406 s.drainPendingReload(ctx, sess)
1407 // Re-check after a rebuild in case the replacement normalized state.
1408 s.emitModeDrift(sess)
1409 s.emitToolApprovalDrift(ctx, sess)
1410 }
1411
1412 // sessionSetConfigOption applies ACP's generic session-level selectors for
1413 // model, reasoning effort, work mode, and tool approval.
1414 func (s *service) sessionSetConfigOption(ctx context.Context, raw json.RawMessage) (any, error) {
1415 var p SetSessionConfigOptionParams
1416 if err := json.Unmarshal(raw, &p); err != nil {
1417 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: " + err.Error()}
1418 }
1419 sess := s.session(p.SessionID)
1420 if sess == nil {
1421 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: unknown session " + p.SessionID}
1422 }
1423 cfgState, err := s.configStateForSession(ctx, sess)
1424 if err != nil {
1425 return nil, &RPCError{Code: ErrInternal, Message: "session/set_config_option: " + err.Error()}
1426 }
1427 option, ok := findConfigOption(cfgState.ConfigOptions, p.ConfigID)
1428 if !ok {
1429 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: unknown config option " + p.ConfigID}
1430 }
1431 if !configOptionHasValue(option, p.Value) {
1432 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: invalid value " + p.Value + " for " + option.ID}
1433 }
1434
1435 var next SessionConfigState
1436 switch configOptionCategory(option) {
1437 case "model":
1438 next, err = s.switchSessionModel(ctx, sess, p.Value)
1439 case "thought_level":
1440 next, err = s.switchSessionEffort(ctx, sess, p.Value)
1441 case "work_mode":
1442 next, err = s.switchSessionRuntimeProfile(ctx, sess, p.Value)
1443 case "tool_approval":
1444 next, err = s.switchSessionToolApproval(ctx, sess, p.Value)
1445 default:
1446 err = &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: unsupported config option " + option.ID}
1447 }
1448 if err != nil {
1449 return nil, err
1450 }
1451 return SetSessionConfigOptionResult{ConfigOptions: next.ConfigOptions}, nil
1452 }
1453
1454 // sessionSetModel keeps older ACP clients working while configOptions becomes
1455 // the preferred model selector.
1456 func (s *service) sessionSetModel(ctx context.Context, raw json.RawMessage) (any, error) {
1457 var p SetSessionModelParams
1458 if err := json.Unmarshal(raw, &p); err != nil {
1459 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_model: " + err.Error()}
1460 }
1461 sess := s.session(p.SessionID)
1462 if sess == nil {
1463 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_model: unknown session " + p.SessionID}
1464 }
1465 if _, err := s.switchSessionModel(ctx, sess, p.ModelID); err != nil {
1466 return nil, err
1467 }
1468 return SetSessionModelResult{}, nil
1469 }
1470
1471 // sessionConfigDelta names exactly one config axis a caller asked to change
1472 // (tool approval never rebuilds the controller, so it has no delta here).
1473 // rebuildSession queues these — instead of a fully resolved SessionConfigState
1474 // — while a turn or rebuild is in flight, one queue entry per axis, and
1475 // applyPendingSessionConfig re-resolves the queued set against the session's
1476 // live baseline once the session is idle. That way a queued change to one axis
1477 // can never restore a stale value on another axis that changed in the
1478 // meantime, whether that axis rebuilt already or is queued alongside.
1479 type sessionConfigDelta struct {
1480 axis string
1481 model string
1482 effortOverride *string
1483 runtimeProfile string
1484 }
1485
1486 func (d sessionConfigDelta) clone() sessionConfigDelta {
1487 d.effortOverride = cloneStringPtr(d.effortOverride)
1488 return d
1489 }
1490
1491 // mergePendingConfig queues delta with last-write-wins per axis: it replaces a
1492 // queued entry for the same axis and appends otherwise, so a change queued for
1493 // one axis can never drop a change queued for another. Callers hold sess.mu.
1494 func mergePendingConfig(queue []sessionConfigDelta, delta sessionConfigDelta) []sessionConfigDelta {
1495 for i := range queue {
1496 if queue[i].axis == delta.axis {
1497 queue[i] = delta.clone()
1498 return queue
1499 }
1500 }
1501 return append(queue, delta.clone())
1502 }
1503
1504 // removePendingAxes drops the queue entries whose axis a rebuild is applying,
1505 // keeping entries other requests queued in the meantime so the post-maintenance
1506 // drain still applies them. Callers hold sess.mu.
1507 func removePendingAxes(queue, applied []sessionConfigDelta) []sessionConfigDelta {
1508 if len(queue) == 0 {
1509 return nil
1510 }
1511 kept := queue[:0]
1512 for _, q := range queue {
1513 drop := false
1514 for _, d := range applied {
1515 if q.axis == d.axis {
1516 drop = true
1517 break
1518 }
1519 }
1520 if !drop {
1521 kept = append(kept, q)
1522 }
1523 }
1524 if len(kept) == 0 {
1525 return nil
1526 }
1527 return kept
1528 }
1529
1530 func clonePendingConfig(queue []sessionConfigDelta) []sessionConfigDelta {
1531 if len(queue) == 0 {
1532 return nil
1533 }
1534 out := make([]sessionConfigDelta, len(queue))
1535 for i := range queue {
1536 out[i] = queue[i].clone()
1537 }
1538 return out
1539 }
1540
1541 func (d sessionConfigDelta) applyTo(p *SessionConfigStateParams) {
1542 switch d.axis {
1543 case "model":
1544 p.Model = d.model
1545 case "thought_level":
1546 p.EffortOverride = cloneStringPtr(d.effortOverride)
1547 case "work_mode":
1548 p.RuntimeProfile = d.runtimeProfile
1549 }
1550 }
1551
1552 // resolveSessionConfigDeltas resolves deltas against the session's current
1553 // config baseline. Calling this fresh at every apply — instead of reusing a
1554 // snapshot taken when a change was first requested — is what keeps a queued
1555 // delta for one axis from clobbering another axis that rebuilt in between.
1556 func (s *service) resolveSessionConfigDeltas(ctx context.Context, sess *acpSession, deltas []sessionConfigDelta) (SessionConfigState, error) {
1557 params := sess.configStateParams()
1558 for _, delta := range deltas {
1559 delta.applyTo(&params)
1560 }
1561 cfgState, err := s.sessionConfigState(ctx, params)
1562 if err != nil {
1563 return SessionConfigState{}, err
1564 }
1565 return withToolApprovalConfig(cfgState, sess.currentToolApprovalMode()), nil
1566 }
1567
1568 func (s *service) switchSessionModel(ctx context.Context, sess *acpSession, modelID string) (SessionConfigState, error) {
1569 deltas := []sessionConfigDelta{{axis: "model", model: modelID}}
1570 return s.switchSessionConfig(ctx, sess, deltas)
1571 }
1572
1573 func (s *service) switchSessionEffort(ctx context.Context, sess *acpSession, effort string) (SessionConfigState, error) {
1574 level := strings.TrimSpace(effort)
1575 if level == "auto" {
1576 level = ""
1577 }
1578 deltas := []sessionConfigDelta{{axis: "thought_level", effortOverride: &level}}
1579 return s.switchSessionConfig(ctx, sess, deltas)
1580 }
1581
1582 func (s *service) switchSessionRuntimeProfile(ctx context.Context, sess *acpSession, profile string) (SessionConfigState, error) {
1583 deltas := []sessionConfigDelta{{axis: "work_mode", runtimeProfile: profile}}
1584 return s.switchSessionConfig(ctx, sess, deltas)
1585 }
1586
1587 // switchSessionConfig resolves and applies one explicit config request without
1588 // letting its full config snapshot roll back another axis. Resolution must be
1589 // repeated after stateChangeMu is acquired: a different-axis rebuild may finish
1590 // while this request is resolving or waiting for the lock, making the earlier
1591 // baseline stale even though this request's own delta is still current.
1592 func (s *service) switchSessionConfig(ctx context.Context, sess *acpSession, deltas []sessionConfigDelta) (SessionConfigState, error) {
1593 resolve := func() (SessionConfigState, error) {
1594 cfgState, err := s.resolveSessionConfigDeltas(ctx, sess, deltas)
1595 if err != nil {
1596 method := "session/set_config_option"
1597 if len(deltas) == 1 && deltas[0].axis == "model" {
1598 method = "session/set_model"
1599 }
1600 return SessionConfigState{}, &RPCError{Code: ErrInvalidParams, Message: method + ": " + err.Error()}
1601 }
1602 if len(deltas) == 1 && deltas[0].axis == "model" && cfgState.Model == "" {
1603 return SessionConfigState{}, &RPCError{Code: ErrInvalidRequest, Message: "session/set_model: model switching is unavailable in this session"}
1604 }
1605 return cfgState, nil
1606 }
1607
1608 if !sess.stateChangeMu.TryLock() {
1609 // Preserve the non-blocking queue contract while a rebuild is already in
1610 // maintenance. Resolve once for validation and the immediate client update;
1611 // the drain resolves the queued deltas again against live state.
1612 cfgState, err := resolve()
1613 if err != nil {
1614 return SessionConfigState{}, err
1615 }
1616 sess.mu.Lock()
1617 if sess.maintenanceDone != nil && !sess.deleted {
1618 for _, delta := range deltas {
1619 sess.pendingConfig = mergePendingConfig(sess.pendingConfig, delta)
1620 }
1621 sess.mu.Unlock()
1622 sess.sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: cfgState.ConfigOptions})
1623 return cfgState, nil
1624 }
1625 sess.mu.Unlock()
1626 sess.stateChangeMu.Lock()
1627 }
1628
1629 // Always resolve inside the serialization domain. Even a successful TryLock
1630 // can follow a concurrent rebuild that completed after this request began.
1631 cfgState, err := resolve()
1632 if err != nil {
1633 sess.stateChangeMu.Unlock()
1634 return SessionConfigState{}, err
1635 }
1636 didMaintenance := false
1637 err = s.rebuildSessionLocked(ctx, sess, cfgState, deltas, &didMaintenance)
1638 sess.stateChangeMu.Unlock()
1639 if didMaintenance {
1640 pendingErr := s.applyPendingSessionConfig(ctx, sess)
1641 s.reportPendingSessionConfigError(ctx, sess, pendingErr, "after maintenance")
1642 // A reloadExtensions request queued behind this maintenance runs next.
1643 s.drainPendingReload(ctx, sess)
1644 // The pending drain completes before this request returns. Refresh the RPC
1645 // result so an older response cannot overwrite the newer config_option_update
1646 // with the pre-drain full snapshot on the client.
1647 if current, stateErr := s.configStateForSession(ctx, sess); stateErr == nil {
1648 cfgState = current
1649 }
1650 }
1651 if err != nil {
1652 return SessionConfigState{}, err
1653 }
1654 return cfgState, nil
1655 }
1656
1657 func (s *service) switchSessionToolApproval(ctx context.Context, sess *acpSession, mode string) (SessionConfigState, error) {
1658 sess.stateChangeMu.Lock()
1659 defer sess.stateChangeMu.Unlock()
1660 mode = normalizeACPToolApprovalMode(mode)
1661 ctrl := sess.currentCtrl()
1662 ctrl.SetToolApprovalMode(mode)
1663 sess.setToolApprovalMode(mode)
1664 sess.saveMetaIfPresent()
1665 cfgState, err := s.configStateForSession(ctx, sess)
1666 if err != nil {
1667 return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: "session/set_config_option: " + err.Error()}
1668 }
1669 sess.sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: cfgState.ConfigOptions})
1670 return cfgState, nil
1671 }
1672
1673 func (s *service) rebuildSession(ctx context.Context, sess *acpSession, cfgState SessionConfigState, deltas []sessionConfigDelta) error {
1674 if !sess.stateChangeMu.TryLock() {
1675 // Preserve the existing queue contract: a config change arriving during
1676 // a controller build returns immediately and is applied after that
1677 // build. The queue keeps one delta per axis (last-write-wins within an
1678 // axis), so changes queued for different axes never clobber each other.
1679 // Collaboration/approval changes do not use this queue; they wait for
1680 // the swap and then update the replacement controller.
1681 sess.mu.Lock()
1682 if sess.maintenanceDone != nil && !sess.deleted {
1683 for _, delta := range deltas {
1684 sess.pendingConfig = mergePendingConfig(sess.pendingConfig, delta)
1685 }
1686 sess.mu.Unlock()
1687 sess.sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: cfgState.ConfigOptions})
1688 return nil
1689 }
1690 sess.mu.Unlock()
1691 sess.stateChangeMu.Lock()
1692 }
1693 didMaintenance := false
1694 err := s.rebuildSessionLocked(ctx, sess, cfgState, deltas, &didMaintenance)
1695 sess.stateChangeMu.Unlock()
1696 if didMaintenance {
1697 pendingErr := s.applyPendingSessionConfig(ctx, sess)
1698 s.reportPendingSessionConfigError(ctx, sess, pendingErr, "after maintenance")
1699 // A reloadExtensions request queued behind this maintenance runs next.
1700 s.drainPendingReload(ctx, sess)
1701 }
1702 return err
1703 }
1704
1705 func (s *service) rebuildSessionLocked(ctx context.Context, sess *acpSession, cfgState SessionConfigState, deltas []sessionConfigDelta, didMaintenance *bool) (retErr error) {
1706 sess.mu.Lock()
1707 if sess.deleted {
1708 sess.mu.Unlock()
1709 return &RPCError{Code: ErrInvalidRequest, Message: "session config: session is deleted"}
1710 }
1711 status := sess.ctrl.RuntimeStatus()
1712 if status.PendingPrompt {
1713 sess.mu.Unlock()
1714 return sessionConfigActiveWorkError("answer pending prompts before switching config")
1715 }
1716 if !sess.running && !status.Running && status.BackgroundJobs > 0 {
1717 sess.mu.Unlock()
1718 return sessionConfigActiveWorkError("stop background jobs before switching config")
1719 }
1720 if sess.running || status.Running || sess.maintenanceDone != nil {
1721 for _, delta := range deltas {
1722 sess.pendingConfig = mergePendingConfig(sess.pendingConfig, delta)
1723 }
1724 sess.mu.Unlock()
1725 sess.sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: cfgState.ConfigOptions})
1726 return nil
1727 }
1728 // Claim this rebuild's axes from the queue in the same critical section
1729 // that raises maintenanceDone below: begin must never observe an idle
1730 // session between the two. Axes queued by other requests stay queued and
1731 // are drained by the post-maintenance apply.
1732 sess.pendingConfig = removePendingAxes(sess.pendingConfig, deltas)
1733
1734 cur := sess.ctrl
1735 sink := sess.sink
1736 mcpServers := clonePluginSpecs(sess.mcpServers)
1737 cwd := sess.cwd
1738 modeID := normalizeACPCollaborationMode(sess.modeID)
1739 goalDraftMode := sess.goalDraftMode
1740 toolApprovalMode := normalizeACPToolApprovalMode(sess.toolApprovalMode)
1741 if strings.TrimSpace(cfgState.RuntimeProfile) == "" {
1742 cfgState.RuntimeProfile = sess.runtimeProfile
1743 }
1744 maintenanceDone := make(chan struct{})
1745 sess.maintenanceDone = maintenanceDone
1746 *didMaintenance = true
1747 sess.mu.Unlock()
1748 defer func() {
1749 sess.finishMaintenance(maintenanceDone)
1750 }()
1751
1752 if err := cur.Snapshot(); err != nil {
1753 return &RPCError{Code: ErrInternal, Message: "session config: snapshot before switch: " + err.Error()}
1754 }
1755 // Capture the adopt path and history only after Snapshot: a snapshot
1756 // conflict can retarget cur to a recovery branch (or adopt the newer disk
1757 // transcript), and a pre-snapshot capture would bind the rebuilt controller
1758 // back to the original file, re-conflicting on every later save. When that
1759 // recovery fired, sessionRecoveredHandler already moved sess.transcript
1760 // and the session lease to the recovery file, so prevPath, the session
1761 // bookkeeping, and the controller agree on one path here.
1762 // SessionPath is controller-locked, so reading it off sess.mu is safe.
1763 prevPath := cur.SessionPath()
1764 carried := cur.History()
1765 carriedGoal := ""
1766 if cur.GoalStatus() == control.GoalStatusRunning {
1767 carriedGoal = cur.Goal()
1768 }
1769
1770 rebuildParams := SessionParams{
1771 Cwd: cwd,
1772 MCPServers: mcpServers,
1773 Sink: sink,
1774 Model: cfgState.Model,
1775 EffortOverride: cloneStringPtr(cfgState.EffortOverride),
1776 RuntimeProfile: cfgState.RuntimeProfile,
1777 OnSessionRecovered: s.sessionRecoveredHandler(sess.id),
1778 }
1779 // The rebuilt controller must keep the client-capability wiring (fs
1780 // overlay, host terminal) a model/effort switch would otherwise drop.
1781 s.bindClientIO(&rebuildParams, sess.id)
1782 newCtrl, err := s.factory.NewSession(ctx, rebuildParams)
1783 if err != nil {
1784 return &RPCError{Code: ErrInternal, Message: "session config: " + err.Error()}
1785 }
1786 newCtrl.EnableInteractiveApproval()
1787 runtimeState, err := s.sessionRuntimeState(ctx, SessionRuntimeStateParams{
1788 Cwd: cwd, Model: cfgState.Model, RuntimeProfile: cfgState.RuntimeProfile,
1789 })
1790 if err != nil {
1791 newCtrl.ReleaseResources()
1792 return &RPCError{Code: ErrInternal, Message: "session config: runtime state: " + err.Error()}
1793 }
1794 // The freshly built controller's own leading system message carries the
1795 // target profile's contract (see boot/token_profile.go); AdoptHistory below
1796 // replaces the whole history with carried, so splice that message in first
1797 // or the model keeps seeing the outgoing profile's contract after every
1798 // switch.
1799 if fresh := newCtrl.History(); len(fresh) > 0 && fresh[0].Role == provider.RoleSystem {
1800 if len(carried) > 0 && carried[0].Role == provider.RoleSystem {
1801 carried[0] = fresh[0]
1802 } else {
1803 carried = append([]provider.Message{fresh[0]}, carried...)
1804 }
1805 }
1806 newCtrl.AdoptHistory(carried, prevPath)
1807 // Re-apply all three independent session axes. A controller rebuild must not
1808 // turn Plan into tool approval, drop a running Goal, or reset Ask/Auto/Yolo.
1809 newCtrl.SetToolApprovalMode(toolApprovalMode)
1810 switch modeID {
1811 case sessionModePlan:
1812 newCtrl.SetPlanMode(true)
1813 case sessionModeGoal:
1814 newCtrl.SetPlanMode(false)
1815 if carriedGoal != "" {
1816 newCtrl.SetGoal(carriedGoal)
1817 }
1818 default:
1819 newCtrl.SetPlanMode(false)
1820 }
1821 // InheritLifecycleFrom wires two concrete controllers' turn/hook state; it's a
1822 // construction concern, not part of the driving port. cur is always the
1823 // *control.Controller the factory built for this session, so this is safe.
1824 if prev, ok := cur.(*control.Controller); ok {
1825 newCtrl.InheritLifecycleFrom(prev)
1826 // A rebuild must not force the user to re-approve tools already granted
1827 // for this session, or re-trust Plan-mode read-only commands already
1828 // trusted this session.
1829 newCtrl.RestoreSessionAuthorizations(prev.SessionAuthorizations())
1830 }
1831 // Persist before publishing the replacement. If this fails, the outgoing
1832 // controller and transcript still agree and remain fully usable; publishing
1833 // first would report a successful switch whose refreshed profile contract
1834 // disappears on restart. AdoptHistory preserves the loaded CAS baseline, so
1835 // this compatible leading-system rewrite is safe to snapshot here.
1836 if prevPath != "" {
1837 if err := newCtrl.Snapshot(); err != nil {
1838 newCtrl.ReleaseResources()
1839 return &RPCError{Code: ErrInternal, Message: "session config: snapshot after switch: " + err.Error()}
1840 }
1841 }
1842
1843 sess.mu.Lock()
1844 if sess.deleted {
1845 sess.mu.Unlock()
1846 newCtrl.ReleaseResources()
1847 return &RPCError{Code: ErrInvalidRequest, Message: "session config: session is deleted"}
1848 }
1849 if sess.ctrl != cur {
1850 sess.mu.Unlock()
1851 newCtrl.ReleaseResources()
1852 return sessionConfigActiveWorkError("session changed while switching config; retry")
1853 }
1854 sess.ctrl = newCtrl
1855 sess.model = cfgState.Model
1856 sess.effortOverride = cloneStringPtr(cfgState.EffortOverride)
1857 sess.runtimeProfile = cfgState.RuntimeProfile
1858 sess.toolApprovalMode = toolApprovalMode
1859 sess.runtimeState = runtimeState
1860 sess.modeID = modeID
1861 sess.goalDraftMode = goalDraftMode
1862 if sess.transcript != "" && sessionFileExists(sess.transcript) {
1863 _ = saveACPMeta(sess.transcript, sess.metaLocked())
1864 }
1865 sess.mu.Unlock()
1866 sink.bindApprove(newCtrl.Approve)
1867 sink.bindAnswer(newCtrl.AnswerQuestion)
1868
1869 cur.ReleaseResources()
1870 s.sendAvailableCommands(sess)
1871 sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: cfgState.ConfigOptions})
1872 return nil
1873 }
1874
1875 func (s *service) applyPendingSessionConfig(ctx context.Context, sess *acpSession) error {
1876 var firstErr error
1877 for {
1878 if s.session(sess.id) != sess {
1879 return firstErr
1880 }
1881 // Claim the queue in the same serialization domain as explicit config
1882 // switches. Without this lock, a newer same-axis request can rebuild after
1883 // the clone below but before this apply starts, then the stale cloned delta
1884 // queues behind it and wins last instead of preserving request order.
1885 sess.stateChangeMu.Lock()
1886 didMaintenance := false
1887 sess.mu.Lock()
1888 if sess.deleted || len(sess.pendingConfig) == 0 {
1889 sess.mu.Unlock()
1890 sess.stateChangeMu.Unlock()
1891 return firstErr
1892 }
1893 deltas := clonePendingConfig(sess.pendingConfig)
1894 // Keep pendingConfig set while rebuilding: begin refuses new turns until
1895 // rebuildSession claims it together with raising maintenanceDone, so no
1896 // promptable instant is visible in between.
1897 sess.mu.Unlock()
1898
1899 // Re-resolve against the session's current state rather than reusing
1900 // whatever baseline existed when each delta queued: another axis may have
1901 // finished rebuilding in the meantime, and replaying its old value here
1902 // would silently roll it back. All queued axes resolve into one state so a
1903 // single rebuild applies them together.
1904 cfgState, err := s.resolveSessionConfigDeltas(ctx, sess, deltas)
1905 if err != nil {
1906 sess.mu.Lock()
1907 if !sess.deleted && !sess.running && sess.maintenanceDone == nil {
1908 sess.pendingConfig = removePendingAxes(sess.pendingConfig, deltas)
1909 }
1910 sess.mu.Unlock()
1911 sess.stateChangeMu.Unlock()
1912 if firstErr != nil {
1913 s.reportPendingSessionConfigError(ctx, sess, err, "after failed maintenance")
1914 return firstErr
1915 }
1916 return err
1917 }
1918
1919 err = s.rebuildSessionLocked(ctx, sess, cfgState, deltas, &didMaintenance)
1920 if err != nil && !didMaintenance {
1921 // Once this attempt failed nothing in flight is left to retry the
1922 // claimed axes, and begin refuses new turns while any are queued — drop
1923 // them so the session stays promptable. Once maintenance started, those
1924 // axes were already removed; anything queued now is a newer request and
1925 // must survive this failure.
1926 sess.mu.Lock()
1927 if !sess.deleted && !sess.running && sess.maintenanceDone == nil {
1928 sess.pendingConfig = removePendingAxes(sess.pendingConfig, deltas)
1929 }
1930 sess.mu.Unlock()
1931 }
1932 sess.stateChangeMu.Unlock()
1933
1934 if err != nil {
1935 if firstErr == nil {
1936 firstErr = err
1937 } else {
1938 s.reportPendingSessionConfigError(ctx, sess, err, "after failed maintenance")
1939 }
1940 if !didMaintenance {
1941 return firstErr
1942 }
1943 }
1944 if !didMaintenance {
1945 return firstErr
1946 }
1947 // Requests can queue while NewSession/Snapshot runs. Iterate even when this
1948 // rebuild failed so their already-successful RPCs cannot leave the session
1949 // blocked. A loop keeps sustained config traffic from growing the call stack.
1950 }
1951 }
1952
1953 func (s *service) reportPendingSessionConfigError(ctx context.Context, sess *acpSession, err error, when string) {
1954 if err == nil || sess == nil || sess.sink == nil {
1955 return
1956 }
1957 sess.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "session config switch failed " + when + ": " + err.Error()})
1958 // A queued request already announced its desired config to the client. Every
1959 // apply failure leaves the outgoing controller/config active, so always send
1960 // the live state back; otherwise snapshot/build/resolve failures leave the
1961 // picker claiming a switch that never happened.
1962 if current, stateErr := s.configStateForSession(ctx, sess); stateErr == nil {
1963 sess.sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: current.ConfigOptions})
1964 }
1965 }
1966
1967 type activeSessionConfigWorkError struct {
1968 *RPCError
1969 }
1970
1971 func (e *activeSessionConfigWorkError) Unwrap() error {
1972 return e.RPCError
1973 }
1974
1975 func sessionConfigActiveWorkError(message string) error {
1976 return &activeSessionConfigWorkError{
1977 RPCError: &RPCError{Code: ErrInvalidRequest, Message: "session config: " + message},
1978 }
1979 }
1980
1981 // sessionClose releases an active session. Unknown sessions are accepted as a
1982 // no-op because closing is an idempotent resource cleanup request.
1983 func (s *service) sessionClose(_ context.Context, raw json.RawMessage) (any, error) {
1984 var p SessionCloseParams
1985 if err := json.Unmarshal(raw, &p); err != nil {
1986 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/close: " + err.Error()}
1987 }
1988 if err := validateSessionID("session/close", p.SessionID); err != nil {
1989 return nil, err
1990 }
1991 if sess := s.takeSession(p.SessionID); sess != nil {
1992 sess.abortAndWait()
1993 sess.ctrl.Close()
1994 sess.releaseSessionLease()
1995 }
1996 return SessionCloseResult{}, nil
1997 }
1998
1999 // sessionList returns ACP sessions known to this process or persisted as ACP
2000 // sidecars. It deliberately ignores ordinary CLI timestamp sessions.
2001 func (s *service) sessionList(_ context.Context, raw json.RawMessage) (any, error) {
2002 var p SessionListParams
2003 if len(raw) > 0 {
2004 if err := json.Unmarshal(raw, &p); err != nil {
2005 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/list: " + err.Error()}
2006 }
2007 }
2008 filterCwd := strings.TrimSpace(p.Cwd)
2009 if filterCwd != "" && !filepath.IsAbs(filterCwd) {
2010 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/list: cwd must be an absolute path"}
2011 }
2012 if strings.TrimSpace(p.Cursor) != "" {
2013 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/list: unsupported cursor"}
2014 }
2015
2016 byID := map[string]SessionInfo{}
2017 if dir := s.sessionDir(); dir != "" {
2018 metas, err := listACPMetas(dir)
2019 if err != nil {
2020 return nil, &RPCError{Code: ErrInternal, Message: "session/list: " + err.Error()}
2021 }
2022 // A recovered session has two sidecars claiming the same id: the
2023 // active recovery transcript's own meta and the id-keyed redirect.
2024 // Reduce to one representative per id before filtering, so the entry
2025 // shown never carries the stale pre-recovery title/timestamps.
2026 best := map[string]acpSessionMeta{}
2027 for _, meta := range metas {
2028 cur, ok := best[meta.SessionID]
2029 if !ok || listMetaBeats(meta, cur) {
2030 best[meta.SessionID] = meta
2031 }
2032 }
2033 for _, meta := range best {
2034 info := meta.info(nil)
2035 if sessionInfoMatchesCwd(info, filterCwd) {
2036 byID[info.SessionID] = info
2037 }
2038 }
2039 }
2040 for _, sess := range s.liveSessions() {
2041 info := sess.info()
2042 if sessionInfoMatchesCwd(info, filterCwd) {
2043 byID[info.SessionID] = info
2044 }
2045 }
2046
2047 sessions := make([]SessionInfo, 0, len(byID))
2048 for _, info := range byID {
2049 sessions = append(sessions, info)
2050 }
2051 sort.Slice(sessions, func(i, j int) bool {
2052 ti := parseSessionUpdatedAt(sessions[i].UpdatedAt)
2053 tj := parseSessionUpdatedAt(sessions[j].UpdatedAt)
2054 if ti.Equal(tj) {
2055 return sessions[i].SessionID < sessions[j].SessionID
2056 }
2057 return ti.After(tj)
2058 })
2059 return SessionListResult{Sessions: sessions}, nil
2060 }
2061
2062 // sessionDelete removes a session from future list results. Deleting a missing
2063 // session succeeds silently, matching ACP's idempotent delete guidance.
2064 func (s *service) sessionDelete(_ context.Context, raw json.RawMessage) (any, error) {
2065 var p SessionDeleteParams
2066 if err := json.Unmarshal(raw, &p); err != nil {
2067 return nil, &RPCError{Code: ErrInvalidParams, Message: "session/delete: " + err.Error()}
2068 }
2069 if err := validateSessionID("session/delete", p.SessionID); err != nil {
2070 return nil, err
2071 }
2072
2073 path := ""
2074 var destroy control.SessionDestroyHandle
2075 var delayed bool
2076 if sess := s.takeSession(p.SessionID); sess != nil {
2077 sess.deleteAndWait()
2078 // The session is going away; drop its lease before removing files so
2079 // the lease sidecars retire with the release (they are not in
2080 // SessionSidecarFiles and would otherwise linger).
2081 sess.releaseSessionLease()
2082 path = sess.transcript
2083 destroy = sess.ctrl.BeginDestroySession(path)
2084 if result := destroy.Wait(); result.HasTimedOut() {
2085 if err := agent.MarkCleanupPending(path, "delete"); err != nil {
2086 go delayedDeleteSessionFiles(path, destroy)
2087 sess.ctrl.CloseAfterDestroy()
2088 return nil, &RPCError{Code: ErrInternal, Message: "session/delete: " + err.Error()}
2089 }
2090 go delayedDeleteSessionFiles(path, destroy)
2091 delayed = true
2092 }
2093 sess.ctrl.CloseAfterDestroy()
2094 }
2095 if path == "" {
2096 if dir := s.sessionDir(); dir != "" {
2097 path = resolveTranscriptPath(dir, p.SessionID)
2098 }
2099 }
2100 if path != "" && !delayed {
2101 if err := deleteSessionFiles(path); err != nil {
2102 return nil, &RPCError{Code: ErrInternal, Message: "session/delete: " + err.Error()}
2103 }
2104 if destroy.Finish != nil {
2105 destroy.Finish()
2106 }
2107 }
2108 // A recovered session lives in two files: the recovery transcript (deleted
2109 // above) and the id-keyed original holding the redirect. Remove the twin
2110 // too, or it resurfaces in session/list as a ghost that delete-by-id can
2111 // never reach again.
2112 if dir := s.sessionDir(); dir != "" {
2113 if idPath := transcriptPath(dir, p.SessionID); idPath != path {
2114 if err := deleteSessionFiles(idPath); err != nil {
2115 return nil, &RPCError{Code: ErrInternal, Message: "session/delete: " + err.Error()}
2116 }
2117 }
2118 }
2119 return SessionDeleteResult{}, nil
2120 }
2121
2122 // sessionCancel aborts a session's in-flight turn, if any. It is a notification:
2123 // no reply, and an unknown session is silently ignored.
2124 func (s *service) sessionCancel(_ context.Context, raw json.RawMessage) {
2125 var p SessionCancelParams
2126 if err := json.Unmarshal(raw, &p); err != nil {
2127 return
2128 }
2129 if sess := s.session(p.SessionID); sess != nil {
2130 sess.abort()
2131 }
2132 }
2133
2134 func (s *service) session(id string) *acpSession {
2135 s.mu.Lock()
2136 defer s.mu.Unlock()
2137 return s.sessions[id]
2138 }
2139
2140 func (s *service) takeSession(id string) *acpSession {
2141 s.mu.Lock()
2142 defer s.mu.Unlock()
2143 sess := s.sessions[id]
2144 delete(s.sessions, id)
2145 return sess
2146 }
2147
2148 func (s *service) liveSessions() []*acpSession {
2149 s.mu.Lock()
2150 defer s.mu.Unlock()
2151 out := make([]*acpSession, 0, len(s.sessions))
2152 for _, sess := range s.sessions {
2153 out = append(out, sess)
2154 }
2155 return out
2156 }
2157
2158 func (s *service) sessionDir() string {
2159 if p, ok := s.factory.(SessionDirProvider); ok {
2160 if dir := strings.TrimSpace(p.SessionDir()); dir != "" {
2161 return dir
2162 }
2163 }
2164 s.mu.Lock()
2165 defer s.mu.Unlock()
2166 for _, sess := range s.sessions {
2167 if dir := sess.currentCtrl().SessionDir(); dir != "" {
2168 return dir
2169 }
2170 }
2171 return ""
2172 }
2173
2174 func (s *service) sessionConfigState(ctx context.Context, p SessionConfigStateParams) (SessionConfigState, error) {
2175 if provider, ok := s.factory.(SessionConfigStateProvider); ok {
2176 return provider.SessionConfigState(ctx, p)
2177 }
2178 return SessionConfigState{}, nil
2179 }
2180
2181 func (s *service) configStateForSession(ctx context.Context, sess *acpSession) (SessionConfigState, error) {
2182 state, err := s.sessionConfigState(ctx, sess.configStateParams())
2183 if err != nil {
2184 return SessionConfigState{}, err
2185 }
2186 // Fold in the live controller's extension catalog so plugin/... models
2187 // are discoverable on every config-state read, not only when current.
2188 state = enrichStateWithExtensionModels(state, sess.currentCtrl().ProviderCatalog())
2189 return withToolApprovalConfig(state, sess.currentToolApprovalMode()), nil
2190 }
2191
2192 func (s *acpSession) configStateParams() SessionConfigStateParams {
2193 s.mu.Lock()
2194 defer s.mu.Unlock()
2195 return SessionConfigStateParams{
2196 Cwd: s.cwd,
2197 Model: s.model,
2198 EffortOverride: cloneStringPtr(s.effortOverride),
2199 RuntimeProfile: s.runtimeProfile,
2200 }
2201 }
2202
2203 func (s *acpSession) currentToolApprovalMode() string {
2204 s.mu.Lock()
2205 defer s.mu.Unlock()
2206 return normalizeACPToolApprovalMode(s.toolApprovalMode)
2207 }
2208
2209 func normalizeACPToolApprovalMode(mode string) string {
2210 switch strings.ToLower(strings.TrimSpace(mode)) {
2211 case control.ToolApprovalAuto:
2212 return control.ToolApprovalAuto
2213 case control.ToolApprovalYolo:
2214 return control.ToolApprovalYolo
2215 default:
2216 return control.ToolApprovalAsk
2217 }
2218 }
2219
2220 func normalizeACPCollaborationMode(mode string) string {
2221 switch strings.ToLower(strings.TrimSpace(mode)) {
2222 case sessionModePlan:
2223 return sessionModePlan
2224 case sessionModeGoal:
2225 return sessionModeGoal
2226 default:
2227 return sessionModeNormal
2228 }
2229 }
2230
2231 func withToolApprovalConfig(state SessionConfigState, mode string) SessionConfigState {
2232 mode = normalizeACPToolApprovalMode(mode)
2233 option := SessionConfigOption{
2234 ID: "tool_approval",
2235 Name: "Tool Approval",
2236 Category: "tool_approval",
2237 Type: "select",
2238 CurrentValue: mode,
2239 Options: []SessionConfigSelectOption{
2240 {Value: control.ToolApprovalAsk, Name: "Ask", Description: "Ask before permission-gated tool calls"},
2241 {Value: control.ToolApprovalAuto, Name: "Auto", Description: "Follow configured permission rules without fallback prompts"},
2242 {Value: control.ToolApprovalYolo, Name: "Yolo", Description: "Approve tool calls except protected decisions"},
2243 },
2244 }
2245 for i := range state.ConfigOptions {
2246 if normalizeConfigID(state.ConfigOptions[i].ID) == option.ID {
2247 state.ConfigOptions[i] = option
2248 return state
2249 }
2250 }
2251 state.ConfigOptions = append(state.ConfigOptions, option)
2252 return state
2253 }
2254
2255 func findConfigOption(options []SessionConfigOption, id string) (SessionConfigOption, bool) {
2256 id = normalizeConfigID(id)
2257 for _, opt := range options {
2258 if normalizeConfigID(opt.ID) == id {
2259 return opt, true
2260 }
2261 }
2262 return SessionConfigOption{}, false
2263 }
2264
2265 func normalizeConfigID(id string) string {
2266 switch strings.TrimSpace(id) {
2267 case "models":
2268 return "model"
2269 case "reasoning_effort", "thought_level":
2270 return "effort"
2271 case "profile", "runtime_profile", "token_mode":
2272 return "work_mode"
2273 case "approval", "approval_mode", "tool_approval_mode":
2274 return "tool_approval"
2275 default:
2276 return strings.TrimSpace(id)
2277 }
2278 }
2279
2280 func configOptionHasValue(option SessionConfigOption, value string) bool {
2281 for _, opt := range option.Options {
2282 if opt.Value == value {
2283 return true
2284 }
2285 }
2286 return false
2287 }
2288
2289 func configOptionCategory(option SessionConfigOption) string {
2290 if option.Category != "" {
2291 return option.Category
2292 }
2293 switch normalizeConfigID(option.ID) {
2294 case "model":
2295 return "model"
2296 case "effort":
2297 return "thought_level"
2298 case "work_mode":
2299 return "work_mode"
2300 case "tool_approval":
2301 return "tool_approval"
2302 default:
2303 return ""
2304 }
2305 }
2306
2307 func cloneStringPtr(p *string) *string {
2308 if p == nil {
2309 return nil
2310 }
2311 cp := *p
2312 return &cp
2313 }
2314
2315 func clonePluginSpecs(in []plugin.Spec) []plugin.Spec {
2316 if len(in) == 0 {
2317 return nil
2318 }
2319 out := make([]plugin.Spec, len(in))
2320 copy(out, in)
2321 return out
2322 }
2323
2324 func (s *service) resolveSessionCwd(cwd, sessionID string) (string, error) {
2325 cwd = strings.TrimSpace(cwd)
2326 if cwd != "" {
2327 if !filepath.IsAbs(cwd) {
2328 return "", fmt.Errorf("cwd must be an absolute path")
2329 }
2330 return filepath.Clean(cwd), nil
2331 }
2332 if sessionID != "" {
2333 if meta, ok := s.loadMeta(sessionID); ok && meta.Cwd != "" {
2334 if !filepath.IsAbs(meta.Cwd) {
2335 return "", fmt.Errorf("stored cwd must be an absolute path")
2336 }
2337 return filepath.Clean(meta.Cwd), nil
2338 }
2339 }
2340 wd, err := os.Getwd()
2341 if err != nil {
2342 return "", fmt.Errorf("resolve cwd: %w", err)
2343 }
2344 return wd, nil
2345 }
2346
2347 func (s *service) loadMeta(id string) (acpSessionMeta, bool) {
2348 dir := s.sessionDir()
2349 if dir == "" {
2350 return acpSessionMeta{}, false
2351 }
2352 meta, ok, err := loadACPMeta(resolveTranscriptPath(dir, id))
2353 if err != nil {
2354 return acpSessionMeta{}, false
2355 }
2356 return meta, ok
2357 }
2358
2359 // closeAll tears down every open session (aborting any in-flight turn and
2360 // stopping its MCP subprocesses) when the connection ends.
2361 func (s *service) closeAll() {
2362 s.mu.Lock()
2363 sessions := s.sessions
2364 s.sessions = make(map[string]*acpSession)
2365 s.mu.Unlock()
2366 for _, sess := range sessions {
2367 sess.abortAndWait()
2368 sess.currentCtrl().Close()
2369 sess.releaseSessionLease()
2370 }
2371 }
2372
2373 func (s *acpSession) persistAfterTurn(prompt string) {
2374 s.mu.Lock()
2375 if s.deleted {
2376 s.mu.Unlock()
2377 return
2378 }
2379 ctrl := s.ctrl
2380 s.mu.Unlock()
2381
2382 _ = ctrl.Snapshot()
2383
2384 s.mu.Lock()
2385 defer s.mu.Unlock()
2386 if s.deleted || s.ctrl != ctrl {
2387 return
2388 }
2389 if s.title == "" {
2390 s.title = previewTitle(prompt)
2391 }
2392 s.updatedAt = time.Now().UTC()
2393 if s.createdAt.IsZero() {
2394 s.createdAt = s.updatedAt
2395 }
2396 if s.transcript != "" && sessionFileExists(s.transcript) {
2397 _ = saveACPMeta(s.transcript, s.metaLocked())
2398 }
2399 }
2400
2401 func (s *acpSession) meta() acpSessionMeta {
2402 s.mu.Lock()
2403 defer s.mu.Unlock()
2404 return s.metaLocked()
2405 }
2406
2407 func (s *acpSession) metaLocked() acpSessionMeta {
2408 return acpSessionMeta{
2409 SessionID: s.id,
2410 Cwd: s.cwd,
2411 Model: s.model,
2412 EffortOverride: cloneStringPtr(s.effortOverride),
2413 RuntimeProfile: s.runtimeProfile,
2414 ToolApprovalMode: normalizeACPToolApprovalMode(s.toolApprovalMode),
2415 CollaborationMode: normalizeACPCollaborationMode(s.modeID),
2416 Title: s.title,
2417 CreatedAt: s.createdAt,
2418 UpdatedAt: s.updatedAt,
2419 Status: s.status.persisted(),
2420 }
2421 }
2422
2423 func (s *acpSession) info() SessionInfo {
2424 meta := s.meta()
2425 ctrl := s.currentCtrl()
2426 extra := map[string]any{}
2427 if n := len(ctrl.History()); n > 0 {
2428 extra["messageCount"] = n
2429 }
2430 if len(extra) == 0 {
2431 extra = nil
2432 }
2433 return meta.info(extra)
2434 }
2435
2436 func (s *service) sendAvailableCommands(sess *acpSession) {
2437 if sess == nil {
2438 return
2439 }
2440 ctrl := sess.currentCtrl()
2441 if ctrl == nil {
2442 return
2443 }
2444 cmds := availableCommandsFor(ctrl)
2445 if len(cmds) == 0 {
2446 return
2447 }
2448 sess.sink.send(availableCommandsUpdate{
2449 SessionUpdate: "available_commands_update",
2450 AvailableCommands: cmds,
2451 })
2452 }
2453
2454 func availableCommandsFor(ctrl acpController) []AvailableCommand {
2455 if ctrl == nil {
2456 return nil
2457 }
2458 byName := map[string]AvailableCommand{}
2459 for _, cmd := range ctrl.Commands() {
2460 if cmd.Hidden {
2461 continue
2462 }
2463 name := strings.TrimSpace(cmd.Name)
2464 if name == "" {
2465 continue
2466 }
2467 desc := strings.TrimSpace(cmd.Description)
2468 if desc == "" {
2469 desc = "Run the " + name + " command"
2470 }
2471 ac := AvailableCommand{Name: name, Description: desc}
2472 if hint := strings.TrimSpace(cmd.ArgHint); hint != "" {
2473 ac.Input = &AvailableCommandInput{Hint: hint}
2474 }
2475 byName[name] = ac
2476 }
2477 for _, sk := range ctrl.SlashSkills() {
2478 name := strings.TrimSpace(sk.SlashName())
2479 if name == "" {
2480 continue
2481 }
2482 if _, exists := byName[name]; exists {
2483 continue
2484 }
2485 desc := strings.TrimSpace(sk.Description)
2486 if desc == "" {
2487 desc = "Run the " + name + " skill"
2488 }
2489 byName[name] = AvailableCommand{
2490 Name: name,
2491 Description: desc,
2492 Input: &AvailableCommandInput{Hint: "instructions"},
2493 }
2494 }
2495 if host := ctrl.Host(); host != nil {
2496 for _, prompt := range host.Prompts() {
2497 name := strings.TrimSpace(prompt.Name)
2498 if name == "" {
2499 continue
2500 }
2501 desc := strings.TrimSpace(prompt.Description)
2502 if desc == "" {
2503 desc = "Run the " + name + " MCP prompt"
2504 }
2505 ac := AvailableCommand{Name: name, Description: desc}
2506 if len(prompt.Args) > 0 {
2507 ac.Input = &AvailableCommandInput{Hint: "arguments"}
2508 }
2509 byName[name] = ac
2510 }
2511 }
2512 // Extension actions surface as "<plugin>:<action>" commands so ACP clients
2513 // can discover them in the slash menu alongside commands/skills/prompts.
2514 for _, action := range ctrl.ExtensionActions() {
2515 name := strings.TrimPrefix(strings.TrimSpace(action.Slash), "/")
2516 if name == "" {
2517 continue
2518 }
2519 if _, exists := byName[name]; exists {
2520 continue
2521 }
2522 desc := strings.TrimSpace(action.Label)
2523 if desc == "" {
2524 desc = "Run the " + name + " extension action"
2525 }
2526 byName[name] = AvailableCommand{
2527 Name: name,
2528 Description: desc,
2529 Input: &AvailableCommandInput{Hint: "arguments"},
2530 }
2531 }
2532 out := make([]AvailableCommand, 0, len(byName))
2533 for _, cmd := range byName {
2534 out = append(out, cmd)
2535 }
2536 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
2537 return out
2538 }
2539
2540 func (s *service) resolveSlashPrompt(ctx context.Context, sess *acpSession, text string) string {
2541 line := strings.TrimSpace(text)
2542 if sess == nil || !strings.HasPrefix(line, "/") {
2543 return text
2544 }
2545 ctrl := sess.currentCtrl()
2546 if ctrl == nil {
2547 return text
2548 }
2549 if sent, ok := ctrl.CustomCommand(line); ok {
2550 return sent
2551 }
2552 if sent, ok := ctrl.RunSkill(line); ok {
2553 return sent
2554 }
2555 if sent, ok, err := ctrl.MCPPrompt(ctx, line); err == nil && ok {
2556 return sent
2557 }
2558 if sent, ok := invokeExtensionAction(ctx, ctrl, line); ok {
2559 return sent
2560 }
2561 return text
2562 }
2563
2564 // invokeExtensionAction resolves a "/<plugin>:<action> args…" line against the
2565 // handshake-declared extension actions and invokes it — the last resolution
2566 // step in resolveSlashPrompt, after custom commands, skills, and MCP prompts.
2567 // The extension's result message becomes the prompt text. A parse miss, an
2568 // undeclared action, an invocation error, or an empty result all leave the
2569 // line untouched (ok=false), matching how unknown slash commands fall through.
2570 func invokeExtensionAction(ctx context.Context, ctrl acpController, line string) (string, bool) {
2571 fields := strings.Fields(line)
2572 if len(fields) == 0 {
2573 return "", false
2574 }
2575 pluginID, actionID, ok := uihub.ParseSlashName(fields[0])
2576 if !ok {
2577 return "", false
2578 }
2579 declared := false
2580 for _, action := range ctrl.ExtensionActions() {
2581 if action.PluginID == pluginID && action.ActionID == actionID {
2582 declared = true
2583 break
2584 }
2585 }
2586 if !declared {
2587 return "", false
2588 }
2589 message, err := ctrl.InvokeExtensionAction(ctx, fields[0], control.ParseExtensionActionArgs(fields[1:]))
2590 if err != nil || strings.TrimSpace(message) == "" {
2591 return "", false
2592 }
2593 return message, true
2594 }
2595
2596 type acpSessionMeta struct {
2597 SessionID string `json:"sessionId"`
2598 Cwd string `json:"cwd"`
2599 Model string `json:"model,omitempty"`
2600 EffortOverride *string `json:"effortOverride,omitempty"`
2601 RuntimeProfile string `json:"runtimeProfile,omitempty"`
2602 ToolApprovalMode string `json:"toolApprovalMode,omitempty"`
2603 CollaborationMode string `json:"collaborationMode,omitempty"`
2604 Title string `json:"title,omitempty"`
2605 CreatedAt time.Time `json:"createdAt"`
2606 UpdatedAt time.Time `json:"updatedAt"`
2607 Status *persistedStatusTelemetry `json:"status,omitempty"`
2608 // ActiveTranscript, when set on the id-keyed sidecar, is the basename of
2609 // the transcript this session currently lives in: a snapshot recovery
2610 // moved the live session onto a recovery branch and left this redirect
2611 // behind so restart-time lookups (resolveTranscriptPath) follow the
2612 // session instead of reopening the pre-recovery file.
2613 ActiveTranscript string `json:"activeTranscript,omitempty"`
2614 }
2615
2616 func (m acpSessionMeta) info(extra map[string]any) SessionInfo {
2617 updatedAt := ""
2618 if !m.UpdatedAt.IsZero() {
2619 updatedAt = m.UpdatedAt.Format(time.RFC3339Nano)
2620 }
2621 return SessionInfo{
2622 SessionID: m.SessionID,
2623 Cwd: m.Cwd,
2624 Title: m.Title,
2625 UpdatedAt: updatedAt,
2626 Meta: extra,
2627 }
2628 }
2629
2630 func metadataForLoadedSession(path, id, cwd string, history []provider.Message) acpSessionMeta {
2631 now := time.Now().UTC()
2632 meta, ok, err := loadACPMeta(path)
2633 if err != nil || !ok {
2634 meta = acpSessionMeta{
2635 SessionID: id,
2636 Cwd: cwd,
2637 Title: titleFromHistory(history),
2638 CreatedAt: now,
2639 UpdatedAt: now,
2640 }
2641 if info, statErr := os.Stat(path); statErr == nil {
2642 meta.CreatedAt = info.ModTime().UTC()
2643 meta.UpdatedAt = info.ModTime().UTC()
2644 }
2645 }
2646 if meta.SessionID == "" {
2647 meta.SessionID = id
2648 }
2649 if cwd != "" {
2650 meta.Cwd = cwd
2651 }
2652 if meta.Title == "" {
2653 meta.Title = titleFromHistory(history)
2654 }
2655 if meta.CreatedAt.IsZero() {
2656 meta.CreatedAt = now
2657 }
2658 if meta.UpdatedAt.IsZero() {
2659 meta.UpdatedAt = meta.CreatedAt
2660 }
2661 return meta
2662 }
2663
2664 func loadACPMeta(sessionPath string) (acpSessionMeta, bool, error) {
2665 path := acpMetaPath(sessionPath)
2666 if path == "" {
2667 return acpSessionMeta{}, false, nil
2668 }
2669 b, err := fileencoding.ReadFileUTF8(path)
2670 if err != nil {
2671 if os.IsNotExist(err) {
2672 return acpSessionMeta{}, false, nil
2673 }
2674 return acpSessionMeta{}, false, err
2675 }
2676 var meta acpSessionMeta
2677 if err := json.Unmarshal(b, &meta); err != nil {
2678 return acpSessionMeta{}, false, fmt.Errorf("decode ACP session metadata %s: %w", path, err)
2679 }
2680 return meta, true, nil
2681 }
2682
2683 func saveACPMeta(sessionPath string, meta acpSessionMeta) error {
2684 path := acpMetaPath(sessionPath)
2685 if path == "" {
2686 return nil
2687 }
2688 now := time.Now().UTC()
2689 if meta.SessionID == "" {
2690 meta.SessionID = sessionIDFromTranscript(sessionPath)
2691 }
2692 if meta.CreatedAt.IsZero() {
2693 meta.CreatedAt = now
2694 }
2695 if meta.UpdatedAt.IsZero() {
2696 meta.UpdatedAt = meta.CreatedAt
2697 }
2698 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
2699 return err
2700 }
2701 b, err := json.MarshalIndent(meta, "", " ")
2702 if err != nil {
2703 return err
2704 }
2705 b = append(b, '\n')
2706 tmp, err := os.CreateTemp(filepath.Dir(path), ".acp-session.*.tmp")
2707 if err != nil {
2708 return err
2709 }
2710 tmpPath := tmp.Name()
2711 if _, err := tmp.Write(b); err != nil {
2712 tmp.Close()
2713 os.Remove(tmpPath)
2714 return err
2715 }
2716 if err := tmp.Close(); err != nil {
2717 os.Remove(tmpPath)
2718 return err
2719 }
2720 return fileutil.ReplaceFile(tmpPath, path)
2721 }
2722
2723 func listACPMetas(dir string) ([]acpSessionMeta, error) {
2724 entries, err := os.ReadDir(dir)
2725 if err != nil {
2726 if os.IsNotExist(err) {
2727 return nil, nil
2728 }
2729 return nil, err
2730 }
2731 out := []acpSessionMeta{}
2732 for _, e := range entries {
2733 if e.IsDir() || !strings.HasSuffix(e.Name(), ".acp.json") {
2734 continue
2735 }
2736 id := strings.TrimSuffix(e.Name(), ".acp.json")
2737 sessionPath := transcriptPath(dir, id)
2738 if agent.IsCleanupPending(sessionPath) {
2739 continue
2740 }
2741 if !sessionFileExists(sessionPath) {
2742 continue
2743 }
2744 meta, ok, err := loadACPMeta(sessionPath)
2745 if err != nil || !ok {
2746 continue
2747 }
2748 if meta.SessionID == "" {
2749 meta.SessionID = id
2750 }
2751 if meta.Cwd == "" {
2752 continue
2753 }
2754 out = append(out, meta)
2755 }
2756 return out, nil
2757 }
2758
2759 func sessionFileExists(path string) bool {
2760 info, err := os.Stat(path)
2761 return err == nil && !info.IsDir()
2762 }
2763
2764 func acpMetaPath(sessionPath string) string {
2765 if sessionPath == "" {
2766 return ""
2767 }
2768 return strings.TrimSuffix(sessionPath, filepath.Ext(sessionPath)) + ".acp.json"
2769 }
2770
2771 func sessionIDFromTranscript(path string) string {
2772 base := filepath.Base(path)
2773 if ext := filepath.Ext(base); ext != "" {
2774 base = strings.TrimSuffix(base, ext)
2775 }
2776 return base
2777 }
2778
2779 // listMetaBeats reports whether a should represent its session id in
2780 // session/list over b. A meta without an ActiveTranscript redirect is the
2781 // session's live transcript and always beats a redirect sidecar; between two
2782 // of the same kind the later UpdatedAt wins.
2783 func listMetaBeats(a, b acpSessionMeta) bool {
2784 aRedirect := strings.TrimSpace(a.ActiveTranscript) != ""
2785 bRedirect := strings.TrimSpace(b.ActiveTranscript) != ""
2786 if aRedirect != bRedirect {
2787 return !aRedirect
2788 }
2789 return a.UpdatedAt.After(b.UpdatedAt)
2790 }
2791
2792 func sessionInfoMatchesCwd(info SessionInfo, filter string) bool {
2793 if filter == "" {
2794 return true
2795 }
2796 return filepath.Clean(info.Cwd) == filepath.Clean(filter)
2797 }
2798
2799 func titleFromHistory(history []provider.Message) string {
2800 for _, m := range history {
2801 if m.Role == provider.RoleUser {
2802 if title := previewTitle(m.Content); title != "" {
2803 return title
2804 }
2805 }
2806 }
2807 return ""
2808 }
2809
2810 func previewTitle(text string) string {
2811 text = strings.Join(strings.Fields(text), " ")
2812 if len([]rune(text)) <= 80 {
2813 return text
2814 }
2815 runes := []rune(text)
2816 return string(runes[:77]) + "..."
2817 }
2818
2819 func validateSessionID(method, id string) error {
2820 trimmed := strings.TrimSpace(id)
2821 if trimmed == "" {
2822 return &RPCError{Code: ErrInvalidParams, Message: method + ": missing sessionId"}
2823 }
2824 if trimmed != id || trimmed == "." || trimmed == ".." || !isSafeSessionID(trimmed) {
2825 return &RPCError{Code: ErrInvalidParams, Message: method + ": invalid sessionId"}
2826 }
2827 return nil
2828 }
2829
2830 func isSafeSessionID(id string) bool {
2831 for _, r := range id {
2832 if r >= 'a' && r <= 'z' {
2833 continue
2834 }
2835 if r >= 'A' && r <= 'Z' {
2836 continue
2837 }
2838 if r >= '0' && r <= '9' {
2839 continue
2840 }
2841 if r == '-' || r == '_' || r == '.' {
2842 continue
2843 }
2844 return false
2845 }
2846 return true
2847 }
2848
2849 func parseSessionUpdatedAt(s string) time.Time {
2850 t, err := time.Parse(time.RFC3339Nano, s)
2851 if err != nil {
2852 return time.Time{}
2853 }
2854 return t
2855 }
2856
2857 func deleteSessionFiles(sessionPath string) error {
2858 paths := []string{
2859 sessionPath,
2860 acpMetaPath(sessionPath),
2861 }
2862 paths = append(paths, store.SessionSidecarFiles(sessionPath)...)
2863 for _, path := range paths {
2864 if path == "" {
2865 continue
2866 }
2867 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
2868 return err
2869 }
2870 }
2871 if dir := checkpointPath(sessionPath); dir != "" {
2872 if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
2873 return err
2874 }
2875 }
2876 if err := agent.DeleteSubagentsByParent(filepath.Dir(sessionPath), agent.BranchID(sessionPath)); err != nil {
2877 return err
2878 }
2879 if err := jobs.RemoveArtifacts(sessionPath); err != nil {
2880 return err
2881 }
2882 return agent.ClearCleanupPending(sessionPath)
2883 }
2884
2885 // ReconcileCleanupPending retries delayed ACP session cleanup left by a previous
2886 // process, including ACP's own metadata sidecar.
2887 func ReconcileCleanupPending(dir string) error {
2888 return agent.ReconcileCleanupPending(dir, func(item agent.CleanupPendingInfo) error {
2889 return deleteSessionFiles(item.SessionPath)
2890 })
2891 }
2892
2893 func delayedDeleteSessionFiles(sessionPath string, destroy control.SessionDestroyHandle) {
2894 if destroy.WaitAll != nil {
2895 destroy.WaitAll()
2896 }
2897 if err := deleteSessionFiles(sessionPath); err != nil {
2898 slog.Warn("acp: delayed session delete failed", "path", sessionPath, "err", err)
2899 }
2900 if destroy.Finish != nil {
2901 destroy.Finish()
2902 }
2903 }
2904
2905 func checkpointPath(sessionPath string) string {
2906 return store.SessionCheckpointDir(sessionPath)
2907 }
2908
2909 // mcpSpecs converts ACP MCP server declarations to plugin.Spec.
2910 func mcpSpecs(in []MCPServerSpec, cwd string) ([]plugin.Spec, error) {
2911 if len(in) == 0 {
2912 return nil, nil
2913 }
2914 out := make([]plugin.Spec, 0, len(in))
2915 for _, m := range in {
2916 typ := strings.ToLower(strings.TrimSpace(m.Type))
2917 if typ == "" {
2918 typ = "stdio"
2919 }
2920 if strings.TrimSpace(m.Name) == "" {
2921 return nil, fmt.Errorf("MCP server name is required")
2922 }
2923 switch typ {
2924 case "stdio":
2925 if strings.TrimSpace(m.Command) == "" {
2926 return nil, fmt.Errorf("MCP server %q command is required", m.Name)
2927 }
2928 case "http", "streamable-http", "streamable_http", "sse":
2929 if strings.TrimSpace(m.URL) == "" {
2930 return nil, fmt.Errorf("MCP server %q url is required", m.Name)
2931 }
2932 if typ != "sse" {
2933 typ = "http"
2934 }
2935 default:
2936 return nil, fmt.Errorf("MCP server %q uses unsupported transport %q", m.Name, m.Type)
2937 }
2938 out = append(out, plugin.Spec{
2939 Name: strings.TrimSpace(m.Name),
2940 Type: typ,
2941 Command: strings.TrimSpace(m.Command),
2942 Args: append([]string(nil), m.Args...),
2943 Env: mapString(m.Env),
2944 URL: strings.TrimSpace(m.URL),
2945 Headers: mapString(m.Headers),
2946 Dir: cwd,
2947 WorkspaceRoot: cwd,
2948 })
2949 }
2950 return out, nil
2951 }
2952
2953 func mapString(in map[string]string) map[string]string {
2954 if len(in) == 0 {
2955 return nil
2956 }
2957 out := make(map[string]string, len(in))
2958 for k, v := range in {
2959 out[k] = v
2960 }
2961 return out
2962 }
2963
2964 // newSessionID returns a random RFC 4122 v4 UUID string used to address a session.
2965 func newSessionID() (string, error) {
2966 var b [16]byte
2967 if _, err := rand.Read(b[:]); err != nil {
2968 return "", err
2969 }
2970 b[6] = (b[6] & 0x0f) | 0x40
2971 b[8] = (b[8] & 0x3f) | 0x80
2972 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
2973 }
2974
2974 lines GO