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