1package agent
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "os/signal"
12 "path/filepath"
13 "slices"
14 "strings"
15 "sync"
16 "syscall"
17 "time"
18
19 "github.com/bnema/zut/packages/agent/extensions"
20 "github.com/bnema/zut/packages/agent/extproto"
21 "github.com/bnema/zut/packages/agent/modes"
22 "github.com/bnema/zut/packages/agent/skills"
23 "github.com/bnema/zut/packages/agent/subagents"
24 "github.com/bnema/zut/packages/agent/tools"
25 "github.com/bnema/zut/packages/core"
26 "github.com/bnema/zut/packages/provider"
27 "github.com/bnema/zut/packages/provider/auth"
28 "github.com/bnema/zut/packages/tui"
29)
30
31// interactiveExtHooks is a tiny adapter that lets the extension
32// manager call back into the Interactive instance built later in
33// runInteractive. Alerts are buffered until the instance is attached so
34// an extension that signals attention during startup is not silently lost.
35// Keep the startup buffer bounded because extensions can flood their host.
36const maxBufferedInteractiveAlerts = 64
37
38type interactiveExtHooks struct {
39 mu sync.Mutex
40 interactive *modes.Interactive
41 pendingAlerts []interactiveAlert
42 pendingStatuses map[string]interactiveStatus
43 pendingWidgets map[string]interactiveWidget
44}
45
46type interactiveAlert struct {
47 extName string
48 alert extproto.AlertRequest
49}
50
51type interactiveStatus struct {
52 extName string
53 key string
54 level string
55 text string
56}
57
58type interactiveWidget struct {
59 extName string
60 id string
61 position string
62 title string
63 lines []string
64}
65
66func (h *interactiveExtHooks) iv() *modes.Interactive {
67 if h == nil {
68 return nil
69 }
70 h.mu.Lock()
71 defer h.mu.Unlock()
72 return h.interactive
73}
74
75func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) {
76 if h == nil || iv == nil {
77 return
78 }
79 h.mu.Lock()
80 h.interactive = iv
81 pendingAlerts := h.pendingAlerts
82 pendingStatuses := h.pendingStatuses
83 pendingWidgets := h.pendingWidgets
84 h.pendingAlerts = nil
85 h.pendingStatuses = nil
86 h.pendingWidgets = nil
87 h.mu.Unlock()
88 for _, item := range pendingAlerts {
89 iv.Alert(item.extName, item.alert)
90 }
91 for _, item := range pendingStatuses {
92 iv.SetStatus(item.extName, item.key, item.level, item.text)
93 }
94 for _, item := range pendingWidgets {
95 iv.SetWidget(item.extName, item.id, item.position, item.title, item.lines)
96 }
97}
98
99func (h *interactiveExtHooks) Notify(extName, level, message string) {
100 if iv := h.iv(); iv != nil {
101 iv.Notify(extName, level, message)
102 }
103}
104func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest) {
105 if h == nil {
106 return
107 }
108 h.mu.Lock()
109 if h.interactive == nil {
110 if len(h.pendingAlerts) < maxBufferedInteractiveAlerts {
111 h.pendingAlerts = append(h.pendingAlerts, interactiveAlert{extName: extName, alert: alert})
112 }
113 h.mu.Unlock()
114 return
115 }
116 iv := h.interactive
117 h.mu.Unlock()
118 iv.Alert(extName, alert)
119}
120func (h *interactiveExtHooks) Submit(text string) {
121 if iv := h.iv(); iv != nil {
122 iv.SubmitOrQueue(text, nil)
123 }
124}
125func (h *interactiveExtHooks) SubmitSlash(text string) {
126 if iv := h.iv(); iv != nil {
127 iv.SubmitSlash(text)
128 }
129}
130func (h *interactiveExtHooks) Insert(text string) {
131 if iv := h.iv(); iv != nil {
132 iv.Insert(text)
133 }
134}
135func (h *interactiveExtHooks) Display(extName, text string) {
136 if iv := h.iv(); iv != nil {
137 iv.Display(extName, text)
138 }
139}
140func (h *interactiveExtHooks) ClearNotes(extName string) {
141 if iv := h.iv(); iv != nil {
142 iv.ClearNotes(extName)
143 }
144}
145func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
146 if iv := h.iv(); iv != nil {
147 iv.OpenPanel(extName, spec)
148 }
149}
150func (h *interactiveExtHooks) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
151 if iv := h.iv(); iv != nil {
152 iv.UpdatePanel(extName, panelID, title, lines, footer)
153 }
154}
155func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
156 if iv := h.iv(); iv != nil {
157 iv.ClosePanel(extName, panelID)
158 }
159}
160func pendingChromeKey(extName, key string) string { return extName + "\x00" + key }
161
162func (h *interactiveExtHooks) SetStatus(extName, key, level, text string) {
163 if h == nil {
164 return
165 }
166 h.mu.Lock()
167 if h.interactive == nil {
168 if h.pendingStatuses == nil {
169 h.pendingStatuses = map[string]interactiveStatus{}
170 }
171 pendingKey := pendingChromeKey(extName, key)
172 if strings.TrimSpace(text) == "" {
173 delete(h.pendingStatuses, pendingKey)
174 } else {
175 h.pendingStatuses[pendingKey] = interactiveStatus{extName: extName, key: key, level: level, text: text}
176 }
177 h.mu.Unlock()
178 return
179 }
180 iv := h.interactive
181 h.mu.Unlock()
182 iv.SetStatus(extName, key, level, text)
183}
184func (h *interactiveExtHooks) SetWidget(extName, id, position, title string, lines []string) {
185 if h == nil {
186 return
187 }
188 h.mu.Lock()
189 if h.interactive == nil {
190 if h.pendingWidgets == nil {
191 h.pendingWidgets = map[string]interactiveWidget{}
192 }
193 h.pendingWidgets[pendingChromeKey(extName, id)] = interactiveWidget{
194 extName: extName, id: id, position: position, title: title, lines: append([]string(nil), lines...),
195 }
196 h.mu.Unlock()
197 return
198 }
199 iv := h.interactive
200 h.mu.Unlock()
201 iv.SetWidget(extName, id, position, title, lines)
202}
203func (h *interactiveExtHooks) ClearWidget(extName, id string) {
204 if h == nil {
205 return
206 }
207 h.mu.Lock()
208 if h.interactive == nil {
209 if h.pendingWidgets != nil {
210 delete(h.pendingWidgets, pendingChromeKey(extName, id))
211 }
212 h.mu.Unlock()
213 return
214 }
215 iv := h.interactive
216 h.mu.Unlock()
217 iv.ClearWidget(extName, id)
218}
219
220func (h *interactiveExtHooks) ClearExtensionChrome(extName string) {
221 if h == nil {
222 return
223 }
224 h.mu.Lock()
225 if h.interactive == nil {
226 prefix := extName + "\x00"
227 for key := range h.pendingStatuses {
228 if strings.HasPrefix(key, prefix) {
229 delete(h.pendingStatuses, key)
230 }
231 }
232 for key := range h.pendingWidgets {
233 if strings.HasPrefix(key, prefix) {
234 delete(h.pendingWidgets, key)
235 }
236 }
237 h.mu.Unlock()
238 return
239 }
240 iv := h.interactive
241 h.mu.Unlock()
242 iv.ClearExtensionChrome(extName)
243}
244
245// extToolAdapter bridges *extensions.Manager to the
246// ExtensionToolSource interface declared in build.go (kept narrow to
247// avoid a build->extensions import cycle). One adapter instance per
248// run; used at every Resolve point so re-built agents pick up the
249// same set of extension tools.
250type extToolAdapter struct {
251 mgr *extensions.Manager
252}
253
254var _ ExtensionToolSource = (*extToolAdapter)(nil)
255var _ ExtensionSkillSource = (*extToolAdapter)(nil)
256
257func (a *extToolAdapter) Skills() []*skills.Skill {
258 if a == nil || a.mgr == nil {
259 return nil
260 }
261 return a.mgr.Skills()
262}
263
264func (a *extToolAdapter) Tools() []ExtensionToolInfo {
265 infos := a.mgr.Tools()
266 out := make([]ExtensionToolInfo, len(infos))
267 for i, t := range infos {
268 out[i] = ExtensionToolInfo{
269 Extension: t.Extension,
270 Name: t.Name,
271 Description: t.Description,
272 Schema: t.Schema,
273 Deferred: t.Deferred,
274 }
275 }
276 return out
277}
278
279func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
280 return extensions.NewTool(a.mgr, extensions.ToolInfo{
281 Extension: info.Extension,
282 Name: info.Name,
283 Description: info.Description,
284 Schema: info.Schema,
285 Deferred: info.Deferred,
286 })
287}
288
289// liveInteractiveAgent returns the agent currently owned by the TUI. The
290// fallback is only used before the Interactive has been constructed.
291//
292// Agent rebuilds, such as cross-provider model switches, replace the TUI's
293// agent without replacing the startup pointer held by runInteractive. Session
294// operations must therefore resolve the live agent at the time of the action.
295func liveInteractiveAgent(iv *modes.Interactive, fallback *core.Agent) *core.Agent {
296 if iv != nil {
297 return iv.Agent()
298 }
299 return fallback
300}
301
302func newSessionTransition(mu *sync.RWMutex) func(func()) {
303 return func(fn func()) {
304 mu.Lock()
305 defer mu.Unlock()
306 fn()
307 }
308}
309
310// newPersistModelCallback returns the callback used by the TUI while its
311// session transition write lock is held. It must not acquire that lock again
312// because sync.RWMutex is not reentrant.
313func newPersistModelCallback(persistMu *sync.Mutex, sess **core.Session, activeProvider, activeModel *string, notify func(string)) func(string, string) {
314 return func(providerName, model string) {
315 providerName = strings.TrimSpace(providerName)
316 model = strings.TrimSpace(model)
317
318 persistMu.Lock()
319 var sessionErr error
320 if *sess != nil {
321 sessionErr = (*sess).UpdateModel(providerName, model)
322 }
323 *activeProvider = providerName
324 *activeModel = model
325 persistMu.Unlock()
326 if sessionErr != nil {
327 if notify != nil {
328 notify("model change was not persisted: " + sessionErr.Error())
329 }
330 return
331 }
332
333 cfg, err := LoadConfig()
334 if err != nil {
335 if notify != nil {
336 notify("model changed for this run, but config could not be read: " + err.Error())
337 }
338 return
339 }
340 cfg.Provider = providerName
341 cfg.Model = model
342 if err := SaveConfig(cfg); err != nil && notify != nil {
343 notify("model changed for this run, but config could not be saved: " + err.Error())
344 }
345 }
346}
347
348func closeAgentLSP(ag *core.Agent) {
349 if ag != nil {
350 _ = tools.CloseLSPManagers(ag.ToolsSnapshot())
351 }
352}
353
354func joinSessionCloseError(err error, sess *core.Session) error {
355 if sess == nil {
356 return err
357 }
358 if closeErr := sess.Close(); closeErr != nil {
359 return errors.Join(err, fmt.Errorf("close session: %w", closeErr))
360 }
361 return err
362}
363
364func closeResolvedLSP(r Resolved) {
365 _ = tools.CloseLSPManagers(r.ToolRegistry)
366}
367
368func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
369 if keepTail <= 0 || len(msgs) <= keepTail {
370 return provider.RepairOrphanedToolResults(msgs)
371 }
372 var out []provider.Message
373 start := len(msgs) - keepTail
374 var carriedToolNames []string
375 addToolNames := func(names []string) {
376 for _, name := range names {
377 if name == "" || slices.Contains(carriedToolNames, name) {
378 continue
379 }
380 carriedToolNames = append(carriedToolNames, name)
381 }
382 }
383 // Activation markers in the discarded prefix still affect the retained
384 // provider request. Carry them onto its first message rather than dropping
385 // deferred-tool availability when the resume tail is shortened.
386 for _, msg := range msgs[:start] {
387 addToolNames(msg.AddedToolNames)
388 }
389 // Preserve the synthetic compaction summary when present so an
390 // already-compacted session stays compacted after resume. Count it
391 // within the tail budget instead of dropping it at the exact boundary.
392 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 0 {
393 out = append(out, msgs[0])
394 start++
395 }
396 // Avoid hydrating a tail that starts with orphan tool_result rows;
397 // provider APIs require those to be paired with an earlier tool_use.
398 for start < len(msgs) && msgs[start].Role == provider.RoleTool {
399 start++
400 }
401 out = append(out, msgs[start:]...)
402 if len(carriedToolNames) > 0 && len(out) > 0 {
403 first := out[0]
404 for _, name := range first.AddedToolNames {
405 addToolNames([]string{name})
406 }
407 first.AddedToolNames = append([]string(nil), carriedToolNames...)
408 out[0] = first
409 }
410 return provider.RepairOrphanedToolResults(out)
411}
412
413// sessionResumeCandidate contains all state needed to resume a session.
414// It is deliberately assembled before the caller changes any live state:
415// opening the transcript, reading usage, and rebuilding a provider/model
416// must all succeed before the candidate can replace the current session.
417type sessionResumeCandidate struct {
418 session *core.Session
419 agent *core.Agent
420 messages []provider.Message
421 fullMessageCount int
422 cumulative provider.Usage
423 lastTurn provider.Usage
424 provider string
425 model string
426 rebuilt bool
427}
428
429// prepareSessionResume opens and validates a selected session without
430// touching the current agent. Missing provider/model metadata is treated as
431// legacy metadata: the current agent is reused, preserving compatibility
432// with sessions written before those fields were persisted.
433//
434// The returned candidate owns session until its caller commits it. On any
435// error this function closes the candidate session and leaves the current
436// agent untouched.
437func prepareSessionResume(path string, current *core.Agent, currentProvider, currentModel string, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) {
438 return prepareSessionResumeWithOptions(path, current, currentProvider, currentModel, false, false, buildAgentFor)
439}
440
441// prepareSessionResumeWithOptions is the provider/model-aware implementation.
442// An explicit CLI provider or model keeps that field authoritative; an empty
443// field falls back to the selected session's metadata when it is complete.
444func prepareSessionResumeWithOptions(path string, current *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (candidate sessionResumeCandidate, err error) {
445 if current == nil {
446 return candidate, fmt.Errorf("no agent running; log in first")
447 }
448
449 sess, msgs, err := core.OpenSession(path)
450 if err != nil {
451 return candidate, err
452 }
453 keepSession := false
454 defer func() {
455 if !keepSession {
456 err = joinSessionCloseError(err, sess)
457 }
458 }()
459
460 cumulative, lastTurn, err := core.SessionUsageDetail(path)
461 if err != nil {
462 return candidate, fmt.Errorf("read session usage: %w", err)
463 }
464
465 fullMessageCount := len(msgs)
466 resumedMessages := trimMessagesForResume(msgs, 100)
467 storedProvider := strings.TrimSpace(sess.Meta.Provider)
468 storedModel := strings.TrimSpace(sess.Meta.Model)
469 currentProvider = strings.TrimSpace(currentProvider)
470 currentModel = strings.TrimSpace(currentModel)
471
472 candidate = sessionResumeCandidate{
473 session: sess,
474 agent: current,
475 messages: resumedMessages,
476 fullMessageCount: fullMessageCount,
477 cumulative: cumulative,
478 lastTurn: lastTurn,
479 provider: currentProvider,
480 model: currentModel,
481 }
482
483 // Old sessions may have either field absent. Only a complete stored
484 // selection is actionable; otherwise resume exactly as older zut did.
485 if storedProvider == "" || storedModel == "" {
486 keepSession = true
487 return candidate, nil
488 }
489
490 resumeProvider := storedProvider
491 if explicitProvider {
492 resumeProvider = currentProvider
493 }
494 resumeModel := storedModel
495 if explicitModel {
496 resumeModel = currentModel
497 }
498 if resumeProvider == currentProvider && resumeModel == currentModel {
499 keepSession = true
500 return candidate, nil
501 }
502 if buildAgentFor == nil {
503 return sessionResumeCandidate{}, fmt.Errorf("cannot resume session with provider %q and model %q: no agent builder configured", resumeProvider, resumeModel)
504 }
505
506 rebuilt, resolvedProvider, resolvedModel, err := buildAgentFor(resumeProvider, resumeModel)
507 if err != nil {
508 return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q: %w", resumeProvider, resumeModel, err)
509 }
510 if rebuilt == nil {
511 return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q returned no agent", resumeProvider, resumeModel)
512 }
513 resolvedProvider = strings.TrimSpace(resolvedProvider)
514 resolvedModel = strings.TrimSpace(resolvedModel)
515 if resolvedProvider == "" {
516 resolvedProvider = storedProvider
517 }
518 if resolvedModel == "" {
519 resolvedModel = storedModel
520 }
521
522 // Hydrate and seed the replacement while it is still private. The live
523 // agent is not modified until the caller commits the complete candidate.
524 rebuilt.SetMessages(resumedMessages)
525 rebuilt.SeedCost(cumulative)
526 rebuilt.SeedLastTurnUsage(lastTurn)
527 candidate.agent = rebuilt
528 candidate.provider = resolvedProvider
529 candidate.model = resolvedModel
530 candidate.rebuilt = true
531 keepSession = true
532 return candidate, nil
533}
534
535func buildNonInteractiveSessionAgentWithRegistry(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string, prepareRegistry func(core.Registry) core.Registry) (*core.Agent, string, string, error) {
536 next := args
537 next.Provider = providerName
538 next.Model = model
539 resolved, err := Resolve(next, true)
540 if err != nil {
541 return nil, "", "", err
542 }
543 resolved.UseSandbox(base.Sandbox)
544 if extMgr != nil {
545 resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
546 }
547 if prepareRegistry != nil {
548 prepareRegistry(resolved.ToolRegistry)
549 }
550 ag := resolved.NewAgent()
551 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
552 return ag, resolved.Provider, resolved.Model, nil
553}
554
555// applySessionResume shares the startup gate, candidate hydration, and
556// commit order used by interactive and non-interactive session opening. The
557// old append handle is closed only after the candidate has been prepared and
558// seeded successfully.
559func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) {
560 candidate := sessionResumeCandidate{
561 session: sess,
562 agent: ag,
563 fullMessageCount: 0,
564 provider: currentProvider,
565 model: currentModel,
566 }
567 if ag != nil {
568 candidate.fullMessageCount = len(ag.Messages())
569 }
570 if sess == nil || ag == nil {
571 return candidate, nil
572 }
573 ag.SetSessionTimeContext(sess.Meta.Started, sess.Meta.Timezone, sess.Meta.TimezoneOffset)
574 // A newly created meta-only session is already on the active pair. Avoid
575 // reopening and closing it through the resume path: Close intentionally
576 // removes fresh empty sessions, and a second append handle would otherwise
577 // keep an unlinked file alive.
578 if len(ag.Messages()) == 0 &&
579 strings.TrimSpace(sess.Meta.Provider) == strings.TrimSpace(currentProvider) &&
580 strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) {
581 return candidate, nil
582 }
583 candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, buildAgentFor)
584 if err != nil {
585 return sessionResumeCandidate{}, joinSessionCloseError(err, sess)
586 }
587 if !candidate.rebuilt {
588 candidate.agent.SetMessages(candidate.messages)
589 candidate.agent.SeedCost(candidate.cumulative)
590 candidate.agent.SeedLastTurnUsage(candidate.lastTurn)
591 }
592 if closeErr := sess.Close(); closeErr != nil {
593 return sessionResumeCandidate{}, errors.Join(fmt.Errorf("close session: %w", closeErr), joinSessionCloseError(nil, candidate.session))
594 }
595 candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
596 return candidate, nil
597}
598
599// applyInitialSessionResume applies the same provider/model-aware candidate
600// contract used by interactive session switching to non-interactive startup
601// modes. The returned session owns the replacement append handle.
602func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent) (*core.Session, *core.Agent, string, string, error) {
603 return applyInitialSessionResumeWithRegistry(ctx, args, base, extMgr, sess, ag, nil)
604}
605
606func applyInitialSessionResumeWithRegistry(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent, prepareRegistry func(core.Registry) core.Registry) (*core.Session, *core.Agent, string, string, error) {
607 candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
608 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {
609 return buildNonInteractiveSessionAgentWithRegistry(ctx, args, base, extMgr, providerName, model, prepareRegistry)
610 })
611 if err != nil {
612 return nil, ag, "", "", err
613 }
614 return candidate.session, candidate.agent, candidate.provider, candidate.model, nil
615}
616
617func applyInitialSessionResumeWithRuntime(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent, runtime *subagentRuntime) (*core.Session, *core.Agent, string, string, error) {
618 candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
619 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {
620 next := args
621 next.Provider = providerName
622 next.Model = model
623 resolved, resolveErr := Resolve(next, true)
624 if resolveErr != nil {
625 return nil, "", "", resolveErr
626 }
627 resolved.UseSandbox(base.Sandbox)
628 if extMgr != nil {
629 resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
630 }
631 if runtime != nil {
632 runtime.SetProvider(resolved.Provider)
633 runtime.SetModel(resolved.Model)
634 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
635 runtime.SetFastMode(resolved.FastMode)
636 runtime.PrepareResolvedRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
637 }
638 rebuilt := resolved.NewAgent()
639 wireNonInteractiveAgentExtHooks(ctx, rebuilt, extMgr)
640 return rebuilt, resolved.Provider, resolved.Model, nil
641 })
642 if err != nil {
643 return nil, ag, "", "", err
644 }
645 return candidate.session, candidate.agent, candidate.provider, candidate.model, nil
646}
647
648// fanoutAgentEvent translates a core.AgentEvent into the wire-format
649// EventFromHost and pushes it through the extension manager. Only
650// the events that have a clear extension-facing meaning are
651// forwarded; internal-only ones (text_delta, tool_progress) are
652// dropped to keep the per-extension stream sane.
653func sessionContext(sess *core.Session) *extproto.SessionContext {
654 if sess == nil {
655 return nil
656 }
657 return &extproto.SessionContext{
658 ID: sess.Meta.ID,
659 ParentID: sess.Meta.Parent,
660 Path: sess.Path,
661 CWD: sess.Meta.CWD,
662 ForkPoint: sess.Meta.ForkPoint,
663 }
664}
665
666func announceSession(mgr *extensions.Manager, sess *core.Session) {
667 if mgr == nil {
668 return
669 }
670 var states map[string]json.RawMessage
671 if sess != nil {
672 states = copyExtensionStates(sess.ExtensionState)
673 }
674 mgr.EmitSessionEvent("session_start", sessionContext(sess), states)
675 if sess != nil {
676 mgr.EmitSessionEvent("session_opened", sessionContext(sess), states)
677 }
678}
679
680func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawMessage {
681 if len(states) == 0 {
682 return nil
683 }
684 out := make(map[string]json.RawMessage, len(states))
685 for name, state := range states {
686 out[name] = append(json.RawMessage(nil), state...)
687 }
688 return out
689}
690
691func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) error {
692 if sess == nil {
693 return nil
694 }
695 if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 {
696 if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {
697 return fmt.Errorf("extension state: %w", err)
698 }
699 if mgr != nil {
700 mgr.UpdateSessionState(details.Extension, details.State)
701 }
702 }
703 if err := persistGoalToolResult(sess, result); err != nil {
704 return fmt.Errorf("goal state: %w", err)
705 }
706 return nil
707}
708
709func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
710 if sess == nil || sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
711 return nil
712 }
713 update, ok := tools.GoalUpdateFromResult(result)
714 if !ok {
715 return nil
716 }
717 goal := *sess.Meta.Goal
718 goal.Status = update.Status
719 goal.Reason = update.Reason
720 return sess.UpdateGoal(&goal)
721}
722
723func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
724 if mgr == nil {
725 return
726 }
727 switch e := ev.(type) {
728 case core.EvTurnStart:
729 mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
730 case core.EvToolCall:
731 mgr.EmitEvent(extproto.EventFromHost{
732 Event: "tool_call", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args,
733 })
734 case core.EvAssistantMessage:
735 // Concat the visible text portions of the message; binary
736 // blocks (tool_use, etc.) are skipped because subscribers
737 // usually want a string they can grep / display.
738 var text string
739 for _, c := range e.Message.Content {
740 if tb, ok := c.(provider.TextBlock); ok {
741 text += tb.Text
742 }
743 }
744 mgr.EmitEvent(extproto.EventFromHost{Event: "assistant_message", Text: text})
745 case core.EvTurnEnd:
746 ev := extproto.EventFromHost{Event: "turn_end", Stop: string(e.Stop)}
747 if e.Err != nil {
748 ev.Error = e.Err.Error()
749 }
750 mgr.EmitEvent(ev)
751 }
752}
753
754// Run is the top-level entrypoint for the zut binary.
755func Run(rawArgs []string, version string) error {
756 // Extension installs can invoke external build and clone processes.
757 // Give those processes a cancellation context without changing signal
758 // handling for the interactive modes below.
759 extCtx := context.Background()
760 stopExtContext := func() {}
761 if len(rawArgs) > 0 && rawArgs[0] == "ext" {
762 extCtx, stopExtContext = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
763 }
764 defer stopExtContext()
765
766 // Subcommand router: `zut bot ...` is handled separately so the
767 // generic flag parser doesn't reject "bot" as a positional arg.
768 if handled, err := runBotCommand(rawArgs, version); handled {
769 return err
770 }
771 if handled, err := runExtCommand(extCtx, rawArgs, version); handled {
772 return err
773 }
774 if handled, err := runUpdateCommand(rawArgs, version); handled {
775 return err
776 }
777 if handled, err := runZutfileCommand(rawArgs, version); handled {
778 return err
779 }
780 return runWithArgsRaw(rawArgs, version)
781}
782
783func runWithArgsRaw(rawArgs []string, version string) error {
784 // `zut rpc` is shorthand for `zut --rpc` so third-party apps can
785 // spawn the binary with a clean argv. Strip the leading 'rpc'
786 // token and let the rest flow through the normal arg parser.
787 if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
788 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
789 }
790
791 args, err := ParseArgs(rawArgs)
792 if err != nil {
793 PrintHelp(version)
794 return err
795 }
796 if args.Help {
797 PrintHelp(version)
798 return nil
799 }
800 if args.Version {
801 fmt.Println("zut", version)
802 return nil
803 }
804 // Reject invalid orchestration combinations before catalog repair or model
805 // refresh can mutate local state or launch background work.
806 if err := validateOrchestrationArgs(args); err != nil {
807 return err
808 }
809 // Model catalog: load any cached discovery data before we inspect
810 // the model list (list-models, print/json, interactive).
811 prepareRuntimeCatalog()
812
813 if args.ListModels {
814 printModels()
815 return nil
816 }
817
818 return runWithArgs(args, version)
819}
820
821func runWithArgs(args Args, version string) error {
822 if err := validateOrchestrationArgs(args); err != nil {
823 return err
824 }
825 ctx := context.Background()
826 switch args.Mode {
827 case ModePrint:
828 return runPrintMode(ctx, args, version)
829 case ModeStream:
830 return runStreamMode(ctx, args, version)
831 case ModeJSON:
832 return runJSONMode(ctx, args, version)
833 case ModeRPC:
834 return runRPCMode(ctx, args, version)
835 case ModeSubagentWorker:
836 return runSubagentWorkerMode(ctx, args, version)
837 default:
838 return runInteractive(ctx, args, version)
839 }
840}
841
842// ---- print / json modes: require credentials, run single-shot ----
843
844// nonInteractiveExtHooks is the HostHooks impl used by print / json
845// modes. They have no TUI, so notify / display go to stderr and
846// submit / insert are no-ops (the extension can't steer a
847// single-shot run once it's in flight anyway).
848type nonInteractiveExtHooks struct{}
849
850func (nonInteractiveExtHooks) Notify(ext, level, message string) {
851 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
852}
853func (nonInteractiveExtHooks) Alert(string, extproto.AlertRequest) {}
854func (nonInteractiveExtHooks) Submit(string) {}
855func (nonInteractiveExtHooks) SubmitSlash(string) {}
856func (nonInteractiveExtHooks) Insert(string) {}
857func (nonInteractiveExtHooks) Display(string, string) {}
858func (nonInteractiveExtHooks) ClearNotes(string) {}
859func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
860func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
861func (nonInteractiveExtHooks) ClosePanel(string, string) {}
862func (nonInteractiveExtHooks) SetStatus(string, string, string, string) {}
863func (nonInteractiveExtHooks) SetWidget(string, string, string, string, []string) {}
864func (nonInteractiveExtHooks) ClearWidget(string, string) {}
865
866// setupNonInteractiveExtensions loads --ext paths and (unless
867// --no-ext) runs discovery. Returns the manager so the caller can
868// wire tools into the resolved registry, and a cleanup closure to
869// defer. Mirrors the interactive-mode setup minus the TUI hooks.
870func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) {
871 extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{})
872 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
873 fmt.Fprintln(os.Stderr, "extension load:", e)
874 }
875 if !args.NoExt {
876 for _, e := range extMgr.Discover(ctx) {
877 fmt.Fprintln(os.Stderr, "extension load:", e)
878 }
879 }
880 extMgr.WaitForReady(3 * time.Second)
881 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
882 return extMgr, func() { extMgr.Stop(2 * time.Second) }
883}
884
885// wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
886// / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
887// interactive path wires up, so extensions get their normal
888// event-intercept surface in print / json / rpc flows too.
889func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) {
890 if ag == nil || extMgr == nil {
891 return
892 }
893 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
894 res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
895 if res.Block {
896 return false, res.Reason, nil
897 }
898 return true, "", res.ModifiedArgs
899 }
900 ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
901 res := extMgr.InterceptTurnStart(turnCtx, step)
902 return !res.Block, res.Reason, res.Context
903 }
904 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
905 res := extMgr.InterceptAssistantMessage(ctx, text)
906 if res.Block {
907 return false, res.Reason, ""
908 }
909 return true, "", res.ReplaceText
910 }
911 ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
912}
913
914type printStats struct {
915 Provider string `json:"provider"`
916 Model string `json:"model"`
917 PromptTokens int `json:"prompt_tokens"`
918 ReasoningTokens *int `json:"reasoning_tokens"`
919 GeneratedOutputTokens int `json:"generated_output_tokens"`
920 ElapsedMS int64 `json:"elapsed_ms"`
921}
922
923func writePrintStats(path, providerID, model string, usage provider.Usage, elapsed time.Duration) error {
924 var reasoning *int
925 generated := usage.OutputTokens
926 if usage.ReasoningTokensKnown {
927 count := usage.ReasoningTokens
928 reasoning = &count
929 generated -= count
930 if generated < 0 {
931 generated = 0
932 }
933 }
934 stats := printStats{
935 Provider: providerID,
936 Model: model,
937 PromptTokens: usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens,
938 ReasoningTokens: reasoning,
939 GeneratedOutputTokens: generated,
940 ElapsedMS: elapsed.Milliseconds(),
941 }
942 data, err := json.MarshalIndent(stats, "", " ")
943 if err != nil {
944 return fmt.Errorf("encode stats: %w", err)
945 }
946 data = append(data, '\n')
947 if err := os.WriteFile(path, data, 0o644); err != nil {
948 return fmt.Errorf("write stats %q: %w", path, err)
949 }
950 return nil
951}
952
953func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {
954 if args.Orchestrate {
955 return runOrchestratedPrintMode(ctx, args, version)
956 }
957 if args.NoYolo {
958 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
959 }
960 r, err := Resolve(args, true)
961 if err != nil {
962 return err
963 }
964 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
965 defer stopExt()
966
967 ag := r.NewAgent()
968 initialAg := ag
969 defer func() {
970 closeAgentLSP(ag)
971 if ag != initialAg {
972 closeAgentLSP(initialAg)
973 }
974 }()
975 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
976 sess, err := openOrCreateSession(ctx, args, r, ag, version)
977 if err != nil {
978 return err
979 }
980 if sess != nil {
981 var providerName, model string
982 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
983 if err != nil {
984 return err
985 }
986 r.Provider, r.Model = providerName, model
987 ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
988 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
989 }
990 announceSession(extMgr, sess)
991
992 prompt := args.Prompt
993 if prompt == "" {
994 piped, _ := readAllStdin()
995 prompt = strings.TrimSpace(piped)
996 }
997 if prompt == "" {
998 return fmt.Errorf("print mode requires a prompt (arg or stdin)")
999 }
1000
1001 start := len(ag.Messages())
1002 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {
1003 return err
1004 }
1005 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1006 return err
1007 }
1008 var persistCompaction func([]provider.Message) error
1009 if sess != nil {
1010 persistCompaction = sess.AppendCompaction
1011 }
1012 started := time.Now()
1013 usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
1014 elapsed := time.Since(started)
1015 transcriptStart := start
1016 if recovery.Compacted {
1017 transcriptStart = recovery.OutputStart
1018 }
1019 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1020 if err != nil {
1021 return errors.Join(err, persistErr)
1022 }
1023 return persistErr
1024 }
1025 if err != nil {
1026 return err
1027 }
1028 if args.StatsPath != "" {
1029 return writePrintStats(args.StatsPath, r.Provider, r.Model, usage, elapsed)
1030 }
1031 return nil
1032}
1033
1034func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {
1035 if args.Orchestrate {
1036 return runOrchestratedStreamMode(ctx, args, version)
1037 }
1038 if args.NoYolo {
1039 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
1040 }
1041 r, err := Resolve(args, true)
1042 if err != nil {
1043 return err
1044 }
1045 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
1046 defer stopExt()
1047
1048 ag := r.NewAgent()
1049 initialAg := ag
1050 defer func() {
1051 closeAgentLSP(ag)
1052 if ag != initialAg {
1053 closeAgentLSP(initialAg)
1054 }
1055 }()
1056 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1057 sess, err := openOrCreateSession(ctx, args, r, ag, version)
1058 if err != nil {
1059 return err
1060 }
1061 if sess != nil {
1062 var providerName, model string
1063 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1064 if err != nil {
1065 return err
1066 }
1067 r.Provider, r.Model = providerName, model
1068 ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1069 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1070 }
1071 announceSession(extMgr, sess)
1072
1073 prompt := args.Prompt
1074 if prompt == "" {
1075 piped, _ := readAllStdin()
1076 prompt = strings.TrimSpace(piped)
1077 }
1078 if prompt == "" {
1079 return fmt.Errorf("stream mode requires a prompt (arg or stdin)")
1080 }
1081
1082 start := len(ag.Messages())
1083 preSink, finishPre := newStreamTextSink(os.Stdout)
1084 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
1085 finishPre()
1086 return err
1087 }
1088 finishPre()
1089 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1090 return err
1091 }
1092 var persistCompaction func([]provider.Message) error
1093 if sess != nil {
1094 persistCompaction = sess.AppendCompaction
1095 }
1096 recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction)
1097 transcriptStart := start
1098 if recovery.Compacted {
1099 transcriptStart = recovery.OutputStart
1100 }
1101 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1102 if err != nil {
1103 return errors.Join(err, persistErr)
1104 }
1105 return persistErr
1106 }
1107 return err
1108}
1109
1110func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
1111 var streamed, wroteText, lastWasNL bool
1112 writeText := func(text string) {
1113 if text == "" {
1114 return
1115 }
1116 _, _ = fmt.Fprint(out, text)
1117 if syncer, ok := out.(interface{ Sync() error }); ok {
1118 _ = syncer.Sync()
1119 }
1120 wroteText = true
1121 lastWasNL = strings.HasSuffix(text, "\n")
1122 }
1123 sink := func(ev core.AgentEvent) {
1124 switch e := ev.(type) {
1125 case core.EvAssistantStart:
1126 streamed = false
1127 case core.EvTextDelta:
1128 streamed = true
1129 writeText(e.Delta)
1130 case core.EvAssistantMessage:
1131 if streamed {
1132 return
1133 }
1134 var text strings.Builder
1135 for _, content := range e.Message.Content {
1136 if block, ok := content.(provider.TextBlock); ok {
1137 if text.Len() > 0 {
1138 text.WriteString("\n")
1139 }
1140 text.WriteString(block.Text)
1141 }
1142 }
1143 writeText(text.String())
1144 }
1145 }
1146 finish := func() {
1147 if wroteText && !lastWasNL {
1148 writeText("\n")
1149 }
1150 }
1151 return sink, finish
1152}
1153
1154func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {
1155 if args.Orchestrate {
1156 return runOrchestratedJSONMode(ctx, args, version)
1157 }
1158 if args.NoYolo {
1159 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
1160 }
1161 r, err := Resolve(args, true)
1162 if err != nil {
1163 return err
1164 }
1165 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
1166 defer stopExt()
1167
1168 ag := r.NewAgent()
1169 initialAg := ag
1170 defer func() {
1171 closeAgentLSP(ag)
1172 if ag != initialAg {
1173 closeAgentLSP(initialAg)
1174 }
1175 }()
1176 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1177 sess, err := openOrCreateSession(ctx, args, r, ag, version)
1178 if err != nil {
1179 return err
1180 }
1181 if sess != nil {
1182 var providerName, model string
1183 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1184 if err != nil {
1185 return err
1186 }
1187 r.Provider, r.Model = providerName, model
1188 ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1189 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1190 }
1191 announceSession(extMgr, sess)
1192
1193 prompt := args.Prompt
1194 if prompt == "" {
1195 piped, _ := readAllStdin()
1196 prompt = strings.TrimSpace(piped)
1197 }
1198 if prompt == "" {
1199 return fmt.Errorf("json mode requires a prompt (arg or stdin)")
1200 }
1201
1202 start := len(ag.Messages())
1203 enc := json.NewEncoder(os.Stdout)
1204 preSink := func(ev core.AgentEvent) {
1205 _ = enc.Encode(modes.EventToJSON(ev))
1206 }
1207 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
1208 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1209 return err
1210 }
1211 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1212 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1213 return err
1214 }
1215 var persistCompaction func([]provider.Message) error
1216 if sess != nil {
1217 persistCompaction = sess.AppendCompaction
1218 }
1219 recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
1220 transcriptStart := start
1221 if recovery.Compacted {
1222 transcriptStart = recovery.OutputStart
1223 }
1224 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1225 finalErr := persistErr
1226 if err != nil {
1227 finalErr = errors.Join(err, persistErr)
1228 }
1229 _ = enc.Encode(map[string]any{"type": "error", "message": finalErr.Error()})
1230 return finalErr
1231 }
1232 return err
1233}
1234
1235// runZutfileStartupPre runs entry.pre before the main non-interactive prompt.
1236// "!command" values execute via BashTool; other values are sent as an agent turn.
1237// shellOut receives live shell chunks (typically os.Stderr); sink receives
1238// agent events for plain-text pre (stream mode wires a live text sink).
1239func runZutfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error {
1240 pre = strings.TrimSpace(pre)
1241 if pre == "" {
1242 return nil
1243 }
1244 if cmd, ok := modes.ShellEscapeCommand(pre); ok {
1245 raw, err := json.Marshal(map[string]any{"command": cmd})
1246 if err != nil {
1247 return err
1248 }
1249 if shellOut != nil {
1250 fmt.Fprintf(shellOut, "$ %s\n", cmd)
1251 if f, ok := shellOut.(interface{ Sync() error }); ok {
1252 _ = f.Sync()
1253 }
1254 }
1255 progress := func(chunk string) {
1256 if shellOut == nil || chunk == "" {
1257 return
1258 }
1259 fmt.Fprint(shellOut, chunk)
1260 if f, ok := shellOut.(interface{ Sync() error }); ok {
1261 _ = f.Sync()
1262 }
1263 }
1264 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
1265 res, err := bash.Execute(ctx, raw, progress)
1266 if err != nil {
1267 return fmt.Errorf("zutfile entry.pre: %w", err)
1268 }
1269 if res.IsError {
1270 var sb strings.Builder
1271 for _, c := range res.Content {
1272 if tb, ok := c.(provider.TextBlock); ok {
1273 sb.WriteString(tb.Text)
1274 }
1275 }
1276 msg := strings.TrimSpace(sb.String())
1277 if msg == "" {
1278 msg = "command failed"
1279 }
1280 return fmt.Errorf("zutfile entry.pre: %s", msg)
1281 }
1282 return nil
1283 }
1284 if ag == nil {
1285 return fmt.Errorf("zutfile entry.pre requires an agent for non-shell prompts")
1286 }
1287 if sink == nil {
1288 sink = func(core.AgentEvent) {}
1289 }
1290 return ag.Prompt(ctx, pre, nil, sink)
1291}
1292
1293var errInteractiveAgentChanged = errors.New("interactive agent or web-search policy changed during prompt refresh")
1294
1295// refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
1296// skills and currently loaded extension tools) and updates the live
1297// agent's registry and system prompt. Used after /reload-ext and after
1298// zutfile entry.pre installs new skills or extensions.
1299// mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn).
1300// interactive, when non-nil, serializes the final commit with agent replacement.
1301func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) (subagents.WebSearchPolicy, error) {
1302 if ag == nil {
1303 return subagents.WebSearchDeny, nil
1304 }
1305 var webSearchPolicyGeneration uint64
1306 if interactive != nil {
1307 // Capture before Resolve: the final commit must fail if settings,
1308 // Telegram attachment, or a newer registry commit changes policy while
1309 // filesystem/config/extension discovery is in flight.
1310 webSearchPolicyGeneration = interactive.WebSearchPolicyGeneration()
1311 }
1312 resolved, err := Resolve(args, true)
1313 if err != nil {
1314 return subagents.WebSearchDeny, fmt.Errorf("refresh agent prompt and tools: %w", err)
1315 }
1316 if sharedSandbox != nil {
1317 resolved.UseSandbox(sharedSandbox)
1318 }
1319 if extToolAdapter != nil {
1320 resolved.MergeExtensionTools(extToolAdapter)
1321 }
1322 reg := resolved.ToolRegistry
1323 if resolved.WebSearchPolicy != subagents.WebSearchAllow {
1324 delete(reg, "web_search")
1325 }
1326 if mutateRegistry != nil {
1327 reg = mutateRegistry(reg)
1328 }
1329 if interactive != nil {
1330 oldTools, applied := interactive.ApplyAgentPromptConfigAtWebSearchGeneration(ag, resolved.SystemPrompt, reg, webSearchPolicyGeneration)
1331 if !applied {
1332 _ = tools.CloseLSPManagers(reg)
1333 return subagents.WebSearchDeny, errInteractiveAgentChanged
1334 }
1335 interactive.DeferUntilIdle(func() {
1336 _ = tools.CloseLSPManagers(oldTools)
1337 })
1338 return webSearchPolicyForRegistry(resolved.WebSearchPolicy, ag.ToolsSnapshot()), nil
1339 }
1340 oldTools := ag.SetPromptConfig(resolved.SystemPrompt, reg)
1341 _ = tools.CloseLSPManagers(oldTools)
1342 return webSearchPolicyForRegistry(resolved.WebSearchPolicy, reg), nil
1343}
1344
1345func webSearchPolicyForRegistry(policy subagents.WebSearchPolicy, reg core.Registry) subagents.WebSearchPolicy {
1346 if policy == subagents.WebSearchAllow {
1347 if _, ok := reg["web_search"]; ok {
1348 return subagents.WebSearchAllow
1349 }
1350 }
1351 return subagents.WebSearchDeny
1352}
1353
1354func webSearchToolAllowedForSession(args Args) bool {
1355 if args.NoTools || args.PermissionSet != nil {
1356 return false
1357 }
1358 if args.ToolsSet || len(args.Tools) > 0 {
1359 return toolListContains(args.Tools, "web_search")
1360 }
1361 return true
1362}
1363
1364func webSearchExplicitlyEnabledForSession(args Args) bool {
1365 return !args.NoTools && args.PermissionSet == nil && (args.ToolsSet || len(args.Tools) > 0) && toolListContains(args.Tools, "web_search")
1366}
1367
1368// reloadResourcesAfterStartupPre reloads extensions (if any) and
1369// refreshes the agent tool registry + system prompt so skills/extensions
1370// installed by entry.pre are visible to the following turn.
1371func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {
1372 _, err := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, sharedSandbox, ag, nil)
1373 return err
1374}
1375
1376func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) (subagents.WebSearchPolicy, error) {
1377 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1378 return subagents.WebSearchDeny, nil
1379 }
1380 adapter := &extToolAdapter{mgr: extMgr}
1381 if extMgr != nil {
1382 _ = extMgr.Reload(ctx, 2*time.Second)
1383 }
1384 return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, mutateRegistry, nil)
1385}
1386
1387// ---- interactive mode: opens the TUI even without credentials ----
1388
1389func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
1390 initialCfg, _ := LoadConfig()
1391 // Resolve WITHOUT requiring credentials.
1392 r, err := Resolve(args, false)
1393 if err != nil {
1394 return err
1395 }
1396
1397 authStore := AuthStoreFor()
1398 mgr := auth.NewManager(authStore)
1399 defer mgr.Close()
1400
1401 // Keep the sandbox pointer stable across agent rebuilds (login / model
1402 // switch). The Interactive UI toggles the lock via this pointer, and
1403 // rebuilt tool instances must share the same one so the lock sticks.
1404 sharedSandbox := r.Sandbox
1405
1406 // persistMu guards the active session and selection across the agent
1407 // loop, TUI callbacks, and session swaps. Declare it before tool
1408 // construction because subagent defaults must read the live selection too.
1409 var persistMu sync.Mutex
1410 // Serialize session transitions with persistence callbacks so a candidate
1411 // is read only after the old agent is flushed and cannot be overwritten by
1412 // an in-flight append before commit.
1413 var sessionTransitionMu sync.RWMutex
1414 activeProvider := r.Provider
1415 activeModel := r.Model
1416
1417 // Build the extension manager BEFORE the agent so we can fold
1418 // extension-defined tools into the registry. Attach the interactive
1419 // host after constructing it below so startup alerts can be buffered.
1420 var iv *modes.Interactive
1421 extHooks := &interactiveExtHooks{}
1422 extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, extHooks)
1423 var startupExtensionErrors []string
1424 // --ext paths first so they win against installed extensions of
1425 // the same name (loadOne's first-write-wins semantics).
1426 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
1427 startupExtensionErrors = append(startupExtensionErrors, e.Error())
1428 }
1429 // --no-ext skips the global + project-local discovery scan;
1430 // explicit --ext paths above are still honoured so you can run
1431 // "only this extension" with --no-ext --ext ./x.
1432 if !args.NoExt {
1433 for _, e := range extMgr.Discover(ctx) {
1434 startupExtensionErrors = append(startupExtensionErrors, e.Error())
1435 }
1436 }
1437 // Wait briefly for extensions to flush their initial register_tool
1438 // frames before we build the agent's tool registry. Half a second
1439 // is plenty for any extension that's actually well-behaved; ones
1440 // that don't send a ready frame eat the full grace and proceed.
1441 // 3s is the per-extension grace period for the ready frame.
1442 // Native binaries are instant; runtimes like `npx tsx` take ~1.5s
1443 // from cold cache. The wait is tight only for extensions that
1444 // haven't sent ready by then; ones that signalled earlier release
1445 // the wait immediately.
1446 extMgr.WaitForReady(3 * time.Second)
1447 defer extMgr.Stop(2 * time.Second)
1448
1449 extToolAdapter := &extToolAdapter{mgr: extMgr}
1450 r.MergeExtensionTools(extToolAdapter)
1451 webSearchGuard := &webSearchSessionGuard{}
1452
1453 // Keep the shared subagent runtime in the outer scope: session reloads,
1454 // dashboard updates, agent rebuilds, and shutdown all use the same owner.
1455 var runtime *subagentRuntime
1456 var subagentsMgr *subagents.Supervisor
1457 // These callbacks deliberately resolve iv lazily because the Interactive
1458 // instance is constructed after the agent and its registry.
1459 onSpawnedSupervisor := func(a *subagents.Agent, task string, required bool) {
1460 if iv != nil {
1461 iv.TrackSubagentWorker(a, task, required)
1462 }
1463 }
1464 onResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) {
1465 if iv != nil {
1466 iv.TrackResumedSubagentWorker(a, prompt, required)
1467 }
1468 }
1469 onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) func() {
1470 if iv != nil {
1471 return iv.PrepareResumedSubagentWorker(a, prompt, required)
1472 }
1473 return nil
1474 }
1475 onStopRequestedSupervisor := func(a *subagents.Agent) {
1476 if iv != nil {
1477 iv.TrackStoppedSubagentWorker(a)
1478 }
1479 }
1480 activeProviderForSubagents := func() string {
1481 persistMu.Lock()
1482 defer persistMu.Unlock()
1483 return activeProvider
1484 }
1485 activeModelForSubagents := func() string {
1486 persistMu.Lock()
1487 defer persistMu.Unlock()
1488 return activeModel
1489 }
1490
1491 runtime = newSubagentRuntime(subagentRuntimeConfig{
1492 Context: ctx,
1493 Args: args,
1494 Root: filepath.Join(ZutHome(), "subagents"),
1495 RepoRoot: r.CWD,
1496 Provider: r.Provider,
1497 Model: r.Model,
1498 Reasoning: r.Reasoning,
1499 BaseURL: r.BaseURL,
1500 InsecureTLS: r.InsecureTLS,
1501 FastMode: r.FastMode,
1502 APIKey: args.APIKey,
1503 Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1504 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1505 WebSearchGuard: webSearchGuard,
1506 ActiveProvider: activeProviderForSubagents,
1507 ActiveModel: activeModelForSubagents,
1508 OnSpawned: onSpawnedSupervisor,
1509 OnResumed: onResumedSupervisor,
1510 BeforeResumed: onBeforeResumedSupervisor,
1511 OnStopRequested: onStopRequestedSupervisor,
1512 })
1513 subagentsMgr = runtime.Supervisor()
1514 reloadDone := make(chan struct{})
1515 var reloadErrs []error
1516 runtime.SetRequiredWorkerReady(reloadDone, func() error {
1517 return errors.Join(reloadErrs...)
1518 })
1519 if subagentsMgr != nil {
1520 // Replaying historical event logs can take seconds. Populate the
1521 // detached-agent dashboard without blocking the first interactive paint.
1522 go func() {
1523 _, reloadErrs = subagentsMgr.Reload()
1524 close(reloadDone)
1525 }()
1526 } else {
1527 close(reloadDone)
1528 }
1529 defer func() {
1530 // Reload may attach detached workers, so let it finish before shutdown
1531 // snapshots the supervisor's worker set.
1532 <-reloadDone
1533 _ = closeSubagentRuntimeFresh(runtime)
1534 }()
1535
1536 prepareInteractiveRegistry := runtime.PrepareRegistry
1537 prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry
1538 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
1539 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
1540 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
1541 runtime.SetWebSearchPolicy(initialWebSearchPolicy)
1542 resolveSubagent := runtime.resolveSubagent
1543
1544 // Confirmation gate: when --no-yolo is on, the agent must ask
1545 // the user before every tool call. In interactive mode the TUI
1546 // provides the Confirmer; in print/json/rpc modes there's no
1547 // way to prompt, so the gate is constructed with a nil inner
1548 // which auto-refuses every call with a helpful reason.
1549 var confirmGate *core.ConfirmGate
1550 if args.NoYolo {
1551 confirmGate = core.NewConfirmGate(nil) // set below for interactive
1552 }
1553
1554 // Capture current args in a closure so BuildAgent can re-resolve
1555 // after a successful login (picks up the newly stored credential).
1556 wireAgentExt := func(a *core.Agent) *core.Agent {
1557 if a == nil {
1558 return a
1559 }
1560 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
1561 // Guards run before confirmation because they may rewrite the
1562 // arguments. The user must approve the effective call that will
1563 // actually execute, not the model's original arguments.
1564 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
1565 if r.Block {
1566 return false, r.Reason, nil
1567 }
1568 effectiveArgs := call.Arguments
1569 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
1570 effectiveArgs = r.ModifiedArgs
1571 }
1572 if confirmGate != nil {
1573 var content strings.Builder
1574 _, currentTools := a.PromptConfig()
1575 if tool, err := currentTools.Get(call.Name); err == nil {
1576 if previewer, ok := tool.(core.ToolPreviewer); ok {
1577 preview, err := previewer.Preview(ctx, effectiveArgs)
1578 if err != nil {
1579 return false, err.Error(), nil
1580 }
1581 for _, block := range preview.Content {
1582 if text, ok := block.(provider.TextBlock); ok {
1583 if content.Len() > 0 {
1584 content.WriteString("\n")
1585 }
1586 content.WriteString(text.Text)
1587 }
1588 }
1589 }
1590 }
1591 ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
1592 ID: call.ID,
1593 Name: call.Name,
1594 Summary: core.BuildPreview(effectiveArgs, 120),
1595 Content: content.String(),
1596 Origin: call.Origin,
1597 })
1598 if !ok {
1599 return false, reason, nil
1600 }
1601 }
1602 return true, "", r.ModifiedArgs
1603 }
1604 a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1605 r := extMgr.InterceptTurnStart(turnCtx, step)
1606 return !r.Block, r.Reason, r.Context
1607 }
1608 a.BeforeAssistantMessage = func(text string) (bool, string, string) {
1609 r := extMgr.InterceptAssistantMessage(ctx, text)
1610 if r.Block {
1611 return false, r.Reason, ""
1612 }
1613 return true, "", r.ReplaceText
1614 }
1615 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
1616 runtime.WireRequiredWorkerGate(a)
1617 return a
1618 }
1619
1620 var ag *core.Agent
1621 buildAgent := func() (*core.Agent, string, string, error) {
1622 resolved, err := Resolve(args, true)
1623 if err != nil {
1624 return nil, "", "", err
1625 }
1626 resolved.UseSandbox(sharedSandbox)
1627 runtime.SetProvider(resolved.Provider)
1628 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1629 runtime.SetFastMode(resolved.FastMode)
1630 resolved.MergeExtensionTools(extToolAdapter)
1631 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1632 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1633 }
1634
1635 // Rebuild agent with an explicit provider/model override.
1636 buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1637 next := args
1638 if providerOverride != "" {
1639 next.Provider = providerOverride
1640 }
1641 if modelOverride != "" {
1642 next.Model = modelOverride
1643 }
1644 resolved, err := Resolve(next, true)
1645 if err != nil {
1646 return nil, "", "", err
1647 }
1648 resolved.UseSandbox(sharedSandbox)
1649 runtime.SetProvider(resolved.Provider)
1650 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1651 runtime.SetFastMode(resolved.FastMode)
1652 resolved.MergeExtensionTools(extToolAdapter)
1653 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1654 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1655 }
1656
1657 // Rebuild agent for the rescue picker after a recoverable failure.
1658 // Unlike buildAgentFor, this drops launch-time --api-key and
1659 // --base-url overrides because those are typically the cause of the
1660 // rescue (a bad key, a typo'd base URL, or a corporate gateway that
1661 // only the originally-picked provider needed). Re-resolving without
1662 // them lets the rescue retry use env vars / auth.json / provider
1663 // defaults the way zut would have without the overrides.
1664 buildAgentForRescue := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1665 next := args
1666 next.APIKey = ""
1667 next.BaseURL = ""
1668 if providerOverride != "" {
1669 next.Provider = providerOverride
1670 }
1671 if modelOverride != "" {
1672 next.Model = modelOverride
1673 }
1674 resolved, err := Resolve(next, true)
1675 if err != nil {
1676 return nil, "", "", err
1677 }
1678 resolved.UseSandbox(sharedSandbox)
1679 runtime.SetProvider(resolved.Provider)
1680 runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1681 runtime.SetFastMode(resolved.FastMode)
1682 resolved.MergeExtensionTools(extToolAdapter)
1683 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1684 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1685 }
1686
1687 if r.HasCredential() {
1688 ag = wireAgentExt(r.NewAgent())
1689 }
1690
1691 // /reload-ext callback: after the manager has respawned every
1692 // extension, re-resolve the tool registry (built-ins + freshly-
1693 // registered extension tools) and swap it onto the current
1694 // agent in-place. The current agent may have been replaced by a
1695 // /model swap since spawn, so re-read the live `ag` on each
1696 // invocation.
1697 extMgr.SetOnReload(func() {
1698 current := liveInteractiveAgent(iv, ag)
1699 if current == nil {
1700 return
1701 }
1702 if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil && iv != nil {
1703 iv.ReportError(err)
1704 }
1705 })
1706
1707 var sess *core.Session
1708 var sessBaselineMsgs int // messages already on disk when current session opened
1709 var sessionTitlePending bool
1710 if !args.NoSess && ag != nil {
1711 var sessErr error
1712 sess, sessErr = openOrCreateSession(ctx, args, r, ag, version)
1713 if sessErr != nil {
1714 return sessErr
1715 }
1716 sessBaselineMsgs = len(ag.Messages())
1717 if sess != nil {
1718 // Startup resume uses the same provider/model-aware preparation as
1719 // the interactive session picker. This matters for --continue,
1720 // --resume, and explicit --session paths whose latest meta row was
1721 // written by another provider/model.
1722 candidate, resumeErr := applySessionResume(sess, ag, r.Provider, r.Model,
1723 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", buildAgentFor)
1724 if resumeErr != nil {
1725 return resumeErr
1726 }
1727 sess = candidate.session
1728 ag = candidate.agent
1729 sessBaselineMsgs = candidate.fullMessageCount
1730 activeProvider = candidate.provider
1731 activeModel = candidate.model
1732 r.Provider = candidate.provider
1733 r.Model = candidate.model
1734 }
1735 if sess != nil && ag != nil {
1736 sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
1737 }
1738 }
1739 announceSession(extMgr, sess)
1740 // Print the hint after the session close defer below and all terminal/TUI
1741 // cleanup defers. Close removes a fresh empty session, so only a session
1742 // that remains discoverable through the hinted command gets a hint.
1743 defer func() {
1744 if !iv.ExitedViaCtrlC() {
1745 return
1746 }
1747 persistMu.Lock()
1748 current := sess
1749 persistMu.Unlock()
1750 if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" {
1751 fmt.Print(resumeSessionHint(resumeID))
1752 }
1753 }()
1754 defer func() {
1755 persistMu.Lock()
1756 defer persistMu.Unlock()
1757 runErr = joinSessionCloseError(runErr, sess)
1758 }()
1759
1760 // persistMessage is the per-message hook bound to the agent. It
1761 // appends each new transcript message to the live session as soon
1762 // as it lands, so a kill / closed terminal / OS crash costs at
1763 // most the in-flight turn instead of the whole session. The
1764 // baseline counter advances in lock-step so the exit-time flush
1765 // doesn't double-write rows already on disk.
1766 persistMessage := func(m provider.Message) {
1767 sessionTransitionMu.RLock()
1768 defer sessionTransitionMu.RUnlock()
1769 persistMu.Lock()
1770 defer persistMu.Unlock()
1771 if sess == nil {
1772 return
1773 }
1774 if err := sess.AppendMessage(m); err == nil {
1775 sessBaselineMsgs++
1776 }
1777 }
1778 persistUsage := func(cum provider.Usage) {
1779 sessionTransitionMu.RLock()
1780 defer sessionTransitionMu.RUnlock()
1781 persistMu.Lock()
1782 defer persistMu.Unlock()
1783 if sess == nil {
1784 return
1785 }
1786 _ = sess.AppendUsage(cum, cum)
1787 }
1788 persistCompaction := func(messages []provider.Message) {
1789 sessionTransitionMu.RLock()
1790 var session *extproto.SessionContext
1791 var states map[string]json.RawMessage
1792 persistMu.Lock()
1793 if sess != nil {
1794 if err := sess.AppendCompaction(messages); err == nil {
1795 sessBaselineMsgs = len(messages)
1796 session = sessionContext(sess)
1797 states = copyExtensionStates(sess.ExtensionState)
1798 }
1799 }
1800 persistMu.Unlock()
1801 sessionTransitionMu.RUnlock()
1802 if session != nil {
1803 extMgr.EmitSessionEvent("session_compacted", session, states)
1804 }
1805 }
1806 persistCompactHandoff := func(state json.RawMessage) error {
1807 sessionTransitionMu.RLock()
1808 defer sessionTransitionMu.RUnlock()
1809 persistMu.Lock()
1810 defer persistMu.Unlock()
1811 if sess == nil {
1812 return nil
1813 }
1814 return sess.UpdateCompactHandoff(state)
1815 }
1816 currentCompactHandoff := func() json.RawMessage {
1817 persistMu.Lock()
1818 defer persistMu.Unlock()
1819 if sess == nil {
1820 return nil
1821 }
1822 return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
1823 }
1824 persistGoal := func(goal *core.SessionGoal) error {
1825 sessionTransitionMu.RLock()
1826 defer sessionTransitionMu.RUnlock()
1827 persistMu.Lock()
1828 defer persistMu.Unlock()
1829 if sess == nil {
1830 return errors.New("session persistence is disabled")
1831 }
1832 return sess.UpdateGoal(goal)
1833 }
1834 currentGoal := func() *core.SessionGoal {
1835 persistMu.Lock()
1836 defer persistMu.Unlock()
1837 if sess == nil || sess.Meta.Goal == nil {
1838 return nil
1839 }
1840 goal := *sess.Meta.Goal
1841 return &goal
1842 }
1843 bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) {
1844 if a == nil {
1845 return
1846 }
1847 a.CommitToolResult = func(_ string, result core.ToolResult) error {
1848 persistMu.Lock()
1849 defer persistMu.Unlock()
1850 return persistToolResultState(extMgr, boundSession, result)
1851 }
1852 }
1853 wireAgentPersist := func(a *core.Agent) *core.Agent {
1854 if a == nil {
1855 return a
1856 }
1857 persistMu.Lock()
1858 currentSession := sess
1859 persistMu.Unlock()
1860 if currentSession != nil {
1861 a.SetSessionTimeContext(currentSession.Meta.Started, currentSession.Meta.Timezone, currentSession.Meta.TimezoneOffset)
1862 }
1863 a.OnMessageAppended = persistMessage
1864 a.OnUsage = persistUsage
1865 a.OnTranscriptCompacted = persistCompaction
1866 bindAgentToolResultSession(a, currentSession)
1867 return a
1868 }
1869 wireAgentPersist(ag)
1870 defer func() {
1871 closeAgentLSP(liveInteractiveAgent(iv, ag))
1872 closeResolvedLSP(r)
1873 }()
1874
1875 // Re-wrap the build closures so any agent constructed by the TUI
1876 // (login, /model swap to a different provider) also gets the
1877 // persistence hooks. Without this, switching provider would
1878 // silently revert to the old in-memory-only behaviour. Selection state
1879 // is published by the successful transition itself, not by preparation.
1880 baseBuildAgent := buildAgent
1881 buildAgent = func() (*core.Agent, string, string, error) {
1882 a, p, m, err := baseBuildAgent()
1883 return wireAgentPersist(a), p, m, err
1884 }
1885 baseBuildAgentFor := buildAgentFor
1886 buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1887 a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
1888 return wireAgentPersist(a), p, m, err
1889 }
1890 baseBuildAgentForRescue := buildAgentForRescue
1891 buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1892 a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
1893 return wireAgentPersist(a), p, m, err
1894 }
1895
1896 // loadSession replaces the current session with the one at path and
1897 // hands its messages to the agent. Used by the /sessions picker.
1898 loadSession := func(path string) (loadErr error) {
1899 // Hold the transition lock from the pre-flush through the commit.
1900 // Persistence callbacks take the read side, so an active session
1901 // cannot be snapshotted before its lazy writes land or overwritten by
1902 // a callback while the candidate is being installed.
1903 sessionTransitionMu.Lock()
1904 defer sessionTransitionMu.Unlock()
1905
1906 currentAg := liveInteractiveAgent(iv, ag)
1907 if currentAg == nil {
1908 return fmt.Errorf("no agent running; log in first")
1909 }
1910
1911 persistMu.Lock()
1912 currentProvider := activeProvider
1913 currentModel := activeModel
1914 oldSess := sess
1915 // Flush before reading the candidate. Use the locked form rather
1916 // than the public callback, which would acquire the read lock held
1917 // by this transition.
1918 if oldSess != nil {
1919 next, flushErr := writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1920 sessBaselineMsgs = next
1921 if flushErr != nil {
1922 persistMu.Unlock()
1923 return fmt.Errorf("flush current session: %w", flushErr)
1924 }
1925 }
1926 currentMessageCount := len(currentAg.Messages())
1927 currentCost := currentAg.Cost()
1928 persistMu.Unlock()
1929
1930 // A candidate build must not publish its provider/model as the live
1931 // selection before the candidate commits. The builder also stages
1932 // provider settings on the shared runtime, so restore those settings on
1933 // every failed path, including errors returned during preparation.
1934 runtimeConfig := runtime.snapshotConfiguration()
1935 committed := false
1936 var candidate sessionResumeCandidate
1937 defer func() {
1938 if !committed {
1939 runtime.restoreConfiguration(runtimeConfig)
1940 loadErr = joinSessionCloseError(loadErr, candidate.session)
1941 }
1942 }()
1943 candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1944 if err != nil {
1945 // prepareSessionResume closes its append handle on failure;
1946 // no live agent or session has been changed.
1947 return err
1948 }
1949
1950 // Wire the private candidate before it can become visible. This is
1951 // also required for legacy same-provider resumes, where the current
1952 // agent is reused and only its transcript/usage are refreshed.
1953 wireAgentPersist(candidate.agent)
1954
1955 persistMu.Lock()
1956 // A provider rebuild ran outside persistMu. Refuse to install it if
1957 // the live agent, session, selection, transcript, or cost changed in
1958 // the meantime; otherwise a slow resume could discard a newer turn or
1959 // model swap.
1960 live := liveInteractiveAgent(iv, ag)
1961 changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg
1962 if !changed && live != nil {
1963 changed = len(live.Messages()) != currentMessageCount || live.Cost() != currentCost
1964 }
1965 if changed {
1966 persistMu.Unlock()
1967 return fmt.Errorf("session changed while loading; please try again")
1968 }
1969 newStates := copyExtensionStates(candidate.session.ExtensionState)
1970 var oldCloseErr error
1971 if oldSess != nil {
1972 oldCloseErr = oldSess.Close()
1973 }
1974 sess = candidate.session
1975 bindAgentToolResultSession(candidate.agent, candidate.session)
1976 candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
1977 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
1978 // The live agent only receives a compact resume window, but the
1979 // session file remains intact. Keep the persistence baseline at
1980 // the original on-disk message count so future turns append after
1981 // the full session instead of duplicating the hydrated tail.
1982 sessBaselineMsgs = candidate.fullMessageCount
1983
1984 if candidate.rebuilt {
1985 // Commit the TUI agent while persistMu still protects the session
1986 // writer, so no new message can land on the old file between the
1987 // two state changes.
1988 if iv != nil {
1989 iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)
1990 }
1991 ag = candidate.agent
1992 } else {
1993 currentAg.SetMessages(candidate.messages)
1994 currentAg.SeedCost(candidate.cumulative)
1995 currentAg.SeedLastTurnUsage(candidate.lastTurn)
1996 ag = currentAg
1997 }
1998 activeProvider = candidate.provider
1999 activeModel = candidate.model
2000 if subagentsMgr != nil && candidate.session != nil {
2001 // Keep the dashboard scope in the same commit as the session,
2002 // agent, usage, and persistence baseline.
2003 if iv != nil {
2004 iv.SetSubagentSessionScope(candidate.session.ID)
2005 } else {
2006 runtime.SetActiveSession(candidate.session.ID)
2007 }
2008 }
2009 committed = true
2010 persistMu.Unlock()
2011 if iv != nil {
2012 iv.RefreshGoal()
2013 }
2014 if candidate.session.Meta.Parent != "" {
2015 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
2016 }
2017 extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
2018 if oldCloseErr != nil {
2019 return fmt.Errorf("close current session: %w", oldCloseErr)
2020 }
2021 return nil
2022 }
2023
2024 // changeCWD switches the running session to a new working directory.
2025 // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
2026 // /cd slash command (which itself is only fired by the workspaces
2027 // extension's panel-key Enter handler today; the user can type /cd
2028 // directly but it's not in autocomplete / help / the README).
2029 //
2030 // Steps, in order:
2031 // 1. resolve + validate the new path (~ expansion, abs/rel)
2032 // 2. stage the candidate cwd, sandbox, and subagent state
2033 // 3. rebuild the agent and allocate its new session
2034 // 4. commit all new state together, closing the old session last
2035 // 5. push the committed state into the running Interactive
2036 //
2037 // The /jail state is preserved verbatim: if the sandbox was locked
2038 // to the old cwd, it stays locked, just re-pointed at the new one.
2039 changeCWD := func(path string) error {
2040 sessionTransitionMu.Lock()
2041 defer sessionTransitionMu.Unlock()
2042 if path == "" {
2043 return fmt.Errorf("empty path")
2044 }
2045 // ~ expansion.
2046 if path == "~" || strings.HasPrefix(path, "~/") {
2047 home, herr := os.UserHomeDir()
2048 if herr != nil || home == "" {
2049 return fmt.Errorf("cannot expand ~: %v", herr)
2050 }
2051 if path == "~" {
2052 path = home
2053 } else {
2054 path = filepath.Join(home, path[2:])
2055 }
2056 }
2057 if !filepath.IsAbs(path) {
2058 path = filepath.Join(args.CWD, path)
2059 }
2060 absPath, err := filepath.Abs(path)
2061 if err != nil {
2062 return err
2063 }
2064 info, err := os.Stat(absPath)
2065 if err != nil {
2066 return err
2067 }
2068 if !info.IsDir() {
2069 return fmt.Errorf("not a directory: %s", absPath)
2070 }
2071
2072 currentAg := liveInteractiveAgent(iv, ag)
2073 if currentAg == nil {
2074 return fmt.Errorf("no agent running; log in first")
2075 }
2076
2077 // Stage the candidate state while retaining a complete rollback
2078 // snapshot. A failed rebuild or session allocation must leave the
2079 // current agent, session, sandbox, and subagent root usable.
2080 oldCWD := args.CWD
2081 oldResolvedCWD := r.CWD
2082 oldPermissionSet := args.PermissionSet
2083 persistMu.Lock()
2084 oldActiveProvider := activeProvider
2085 oldActiveModel := activeModel
2086 persistMu.Unlock()
2087 runtimeConfig := runtime.snapshotConfiguration()
2088 oldSandboxRoot := ""
2089 oldSandboxLocked := false
2090 if sharedSandbox != nil {
2091 oldSandboxRoot = sharedSandbox.Root
2092 oldSandboxLocked = sharedSandbox.Locked()
2093 }
2094 rollback := func() {
2095 args.CWD = oldCWD
2096 r.CWD = oldResolvedCWD
2097 args.PermissionSet = oldPermissionSet
2098 persistMu.Lock()
2099 activeProvider = oldActiveProvider
2100 activeModel = oldActiveModel
2101 persistMu.Unlock()
2102 runtime.restoreConfiguration(runtimeConfig)
2103 if sharedSandbox != nil {
2104 sharedSandbox.Root = oldSandboxRoot
2105 if oldSandboxLocked {
2106 sharedSandbox.Lock()
2107 } else {
2108 sharedSandbox.Unlock()
2109 }
2110 }
2111 }
2112
2113 args.CWD = absPath
2114 r.CWD = absPath
2115 runtime.SetRepoRoot(absPath)
2116 if args.PermissionSet != nil {
2117 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
2118 args.PermissionSet = &expanded
2119 }
2120 if sharedSandbox != nil {
2121 sharedSandbox.Root = absPath
2122 if oldSandboxLocked {
2123 sharedSandbox.Lock()
2124 } else {
2125 sharedSandbox.Unlock()
2126 }
2127 }
2128
2129 // Rebuild the agent so tools / AGENTS.md / system prompt
2130 // re-bind to the new cwd. buildAgent() reads from args + r.
2131 newAg, newProvider, newModel, berr := buildAgent()
2132 if berr != nil {
2133 rollback()
2134 return fmt.Errorf("rebuild agent: %w", berr)
2135 }
2136
2137 // Fresh session in the new cwd's bucket. We bypass
2138 // openOrCreateSession's --continue / --resume branches
2139 // because /cd's semantics are "start fresh here", matching
2140 // what relaunching `zut --cwd <path>` would do today.
2141 var newSess *core.Session
2142 if !args.NoSess {
2143 newRoot := agentSessionsRoot(ZutHome(), args)
2144 core.PruneEmptySessions(newRoot, absPath)
2145 var serr error
2146 newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
2147 if serr != nil {
2148 rollback()
2149 return fmt.Errorf("open session in %s: %w", absPath, serr)
2150 }
2151 }
2152
2153 // Invalidate any title request before swapping the host-owned session
2154 // pointer. The interactive state reset below happens after the swap,
2155 // so this guard closes the window where a late result could otherwise
2156 // be written to the new session.
2157 if iv != nil {
2158 iv.CancelPendingSessionTitle()
2159 }
2160
2161 // Commit only after every fallible operation has succeeded. Close
2162 // the old session last so rollback never has to resurrect it.
2163 var newStates map[string]json.RawMessage
2164 var oldCloseErr error
2165 persistMu.Lock()
2166 if sess != nil {
2167 next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2168 sessBaselineMsgs = next
2169 if flushErr != nil {
2170 persistMu.Unlock()
2171 rollbackErr := joinSessionCloseError(fmt.Errorf("flush current session: %w", flushErr), newSess)
2172 rollback()
2173 return rollbackErr
2174 }
2175 oldCloseErr = sess.Close()
2176 }
2177 sess = newSess
2178 bindAgentToolResultSession(newAg, newSess)
2179 if newSess != nil {
2180 newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset)
2181 newStates = copyExtensionStates(newSess.ExtensionState)
2182 }
2183 sessBaselineMsgs = 0
2184 sessionTitlePending = false
2185 activeProvider = newProvider
2186 activeModel = newModel
2187 persistMu.Unlock()
2188 ag = newAg
2189 closeAgentLSP(currentAg)
2190
2191 // Push the new state into the running Interactive.
2192 if iv != nil {
2193 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
2194 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
2195 iv.RefreshGoal()
2196 }
2197
2198 // Re-scope the subagent dashboard to the new session.
2199 if newSess != nil {
2200 if iv != nil {
2201 iv.SetSubagentSessionScope(newSess.ID)
2202 } else {
2203 runtime.SetActiveSession(newSess.ID)
2204 }
2205 }
2206 if newSess != nil {
2207 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
2208 }
2209 if oldCloseErr != nil {
2210 return fmt.Errorf("close current session: %w", oldCloseErr)
2211 }
2212 return nil
2213 }
2214
2215 devBuild := isDevVersion(version)
2216 if devBuild {
2217 fmt.Fprintln(os.Stderr, "zut:", devVersionNotice)
2218 }
2219 term := tui.NewProcTerm()
2220
2221 var updateCh chan modes.UpdateInfo
2222 var changelogCh chan modes.ChangelogPayload
2223 if !devBuild {
2224 // Kick off the async update check so the banner can appear when the
2225 // http response eventually arrives (usually <1s on cached DNS). Map
2226 // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
2227 updateCh = make(chan modes.UpdateInfo, 1)
2228 go func() {
2229 defer close(updateCh)
2230 src := <-CheckForUpdateAsync(ZutHome(), version)
2231 updateCh <- modes.UpdateInfo{
2232 Current: src.Current,
2233 Latest: src.Latest,
2234 Available: src.Available,
2235 URL: src.URL,
2236 }
2237 }()
2238
2239 // Changelog: when the running version differs from the last
2240 // version whose release notes the user dismissed, fetch the
2241 // release body from GitHub and have the TUI show it once. On
2242 // first-ever launch (no prior LastChangelogShown), seed the
2243 // stored version silently — don't dump release notes at someone
2244 // who just installed.
2245 changelogCh = make(chan modes.ChangelogPayload, 1)
2246 go func() {
2247 defer close(changelogCh)
2248 cfg, _ := LoadConfig()
2249 if cfg.LastChangelogShown == "" {
2250 SeedChangelogVersion(version)
2251 return
2252 }
2253 if !ShouldShowChangelog(version, cfg) {
2254 return
2255 }
2256 info := <-FetchChangelogAsync(version)
2257 if info.Body == "" {
2258 return
2259 }
2260 changelogCh <- modes.ChangelogPayload{
2261 Version: info.Version,
2262 Body: info.Body,
2263 URL: info.URL,
2264 }
2265 }()
2266 }
2267
2268 fastMode := r.FastMode
2269 quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
2270 for idx, s := range initialCfg.QuickModelShortcuts {
2271 quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
2272 }
2273 theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), initialCfg.Theme, 80*time.Millisecond)
2274 if themeErr != nil {
2275 fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2276 if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {
2277 initialCfg.Theme = ""
2278 _ = SaveConfig(initialCfg)
2279 }
2280 }
2281
2282 // Scope the dashboard to the active host session. The runtime owns
2283 // shutdown separately and closes the supervisor exactly once.
2284 if sess != nil {
2285 runtime.SetActiveSession(sess.ID)
2286 }
2287
2288 var startupSkills []*skills.Skill
2289 if r.SkillTool != nil {
2290 startupSkills = r.SkillTool.Skills()
2291 }
2292 homeDir, _ := os.UserHomeDir()
2293 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
2294 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2295 autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
2296 autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
2297 autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args)
2298 autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args)
2299 webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)
2300 webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
2301 setWebSearchAvailable := func(available bool) {
2302 webSearchGuard.setAvailable(available)
2303 if subagentsMgr == nil {
2304 return
2305 }
2306 policy := subagents.WebSearchDeny
2307 if available {
2308 policy = subagents.WebSearchAllow
2309 }
2310 runtime.SetWebSearchPolicy(policy)
2311 }
2312
2313 iv = modes.NewInteractive(modes.InteractiveConfig{
2314 Terminal: term,
2315 Theme: theme,
2316 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
2317 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2318 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2319 AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2320 PonytailEnabled: initialCfg.PonytailEnabled,
2321 WebSearchEnabled: initialCfg.WebSearchEnabled,
2322 WebSearchToolAllowed: &webSearchToolAllowedForInvocation,
2323 WebSearchInvocationOverride: webSearchInvocationOverride,
2324 AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2325 AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2326 AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession,
2327 AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,
2328 FastMode: &fastMode,
2329 LSPEnabled: initialCfg.LSPEnabled,
2330 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2331 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2332 JailByDefault: initialCfg.JailByDefault,
2333 QuickModelShortcuts: quickModelShortcuts,
2334 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
2335 RespectGitignore: initialCfg.RespectGitignore,
2336 CompactMode: initialCfg.CompactMode,
2337 TUIInputStyle: initialCfg.TUIInputStyle,
2338 TUIStatusPosition: initialCfg.TUIStatusPosition,
2339 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2340 TUISubagentPosition: initialCfg.TUISubagentPosition,
2341 ThemeName: initialCfg.Theme,
2342 FlatTools: initialCfg.FlatToolRender(),
2343 CompactUser: initialCfg.CompactUserInput(),
2344 ExtensionThemes: extMgr.ThemeOptions,
2345 AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(
2346 autoSubagentsToolAllowedForSession,
2347 autoSubagentsStopToolAllowedForSession,
2348 autoSubagentsResumeToolAllowedForSession,
2349 ),
2350 OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,
2351 SubagentsSystemAddendum: subagentsAddendum,
2352 SettingsStore: configSettingsStore{},
2353 Model: r.Model,
2354 Provider: r.Provider,
2355 AuthMethod: r.AuthMethod,
2356 BaseURL: r.BaseURL,
2357 Reasoning: r.Reasoning,
2358 OnReasoningChanged: runtime.SetReasoning,
2359 SystemPrompt: r.SystemPrompt,
2360 Tools: r.ToolRegistry,
2361 MaxSteps: r.MaxSteps,
2362 CWD: r.CWD,
2363 StartupAgentName: args.AgentName,
2364 StartupContextPaths: instructionContextPaths(r.ContextFiles),
2365 StartupExtensionNames: startupExtensionNames(extMgr.All()),
2366 StartupExtensionErrors: startupExtensionErrors,
2367 StartupSkillNames: startupSkillNames(startupSkills),
2368 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2369 ZutHome: ZutHome(),
2370 SessionsRoot: agentSessionsRoot(ZutHome(), args),
2371 Version: version,
2372 UpdateInfoChan: updateCh,
2373 Sandbox: sharedSandbox,
2374 Agent: ag,
2375 InitialCompactHandoff: currentCompactHandoff(),
2376 InitialSessionTitle: func() string {
2377 if sess == nil || sessionTitlePending {
2378 return ""
2379 }
2380 return sess.Title
2381 }(),
2382 InitialSessionTitlePending: sessionTitlePending,
2383 InitialInput: args.Prompt,
2384 StartupPre: args.StartupPre,
2385 OnStartupPreDone: func() {
2386 // entry.pre often installs skills/extensions; rediscover them
2387 // before the user starts a model turn.
2388 current := liveInteractiveAgent(iv, ag)
2389 if extMgr != nil {
2390 _ = extMgr.Reload(context.Background(), 2*time.Second)
2391 }
2392 if current != nil {
2393 if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil {
2394 iv.ReportError(err)
2395 }
2396 }
2397 },
2398 RefreshTools: func() error {
2399 current := liveInteractiveAgent(iv, ag)
2400 webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2401 if err != nil {
2402 if !errors.Is(err, errInteractiveAgentChanged) {
2403 setWebSearchAvailable(false)
2404 }
2405 return err
2406 }
2407 setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2408 return nil
2409 },
2410 SetWebSearchAvailable: setWebSearchAvailable,
2411 RefreshPrompt: func() error {
2412 current := liveInteractiveAgent(iv, ag)
2413 webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2414 if err != nil {
2415 return err
2416 }
2417 setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2418 return nil
2419 },
2420 AuthManager: mgr,
2421 LlamaCPPConfig: ResolveLlamaCPPConfig,
2422 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
2423 BuildAgent: buildAgent,
2424 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
2425 BuildAgentFor: buildAgentFor,
2426 BuildAgentForRescue: buildAgentForRescue,
2427 LoggedInProviders: func() []string {
2428 var out []string
2429 seen := map[string]bool{}
2430 for _, p := range knownProviders {
2431 if CredentialAvailable(p) && !seen[p] {
2432 out = append(out, p)
2433 seen[p] = true
2434 }
2435 }
2436 // Include custom providers that have credentials stored.
2437 for p := range provider.CustomProviders() {
2438 if CredentialAvailable(p) && !seen[p] {
2439 out = append(out, p)
2440 seen[p] = true
2441 }
2442 }
2443 // Ollama models are always available (no auth needed).
2444 if !seen["ollama"] {
2445 out = append(out, "ollama")
2446 }
2447 return out
2448 },
2449 LoadSession: loadSession,
2450 ChangeCWD: changeCWD,
2451 PersistCompactHandoff: persistCompactHandoff,
2452 CurrentCompactHandoff: currentCompactHandoff,
2453 PersistGoal: persistGoal,
2454 CurrentGoal: currentGoal,
2455 CurrentSessionPath: func() string {
2456 persistMu.Lock()
2457 defer persistMu.Unlock()
2458 if sess == nil {
2459 return ""
2460 }
2461 return sess.Path
2462 },
2463 FlushSession: func() {
2464 sessionTransitionMu.RLock()
2465 defer sessionTransitionMu.RUnlock()
2466 // Append any not-yet-persisted agent messages to the
2467 // current session file, then advance the baseline so
2468 // the final WriteNewTranscript at exit doesn't write
2469 // duplicates. Per-message persistence keeps the on-
2470 // disk file current already, so this is mostly a
2471 // defensive flush — still needed for /session export
2472 // to guarantee the exported bytes include the very
2473 // last in-flight turn.
2474 currentAg := iv.Agent()
2475 if currentAg == nil {
2476 return
2477 }
2478 persistMu.Lock()
2479 defer persistMu.Unlock()
2480 if sess == nil {
2481 return
2482 }
2483 next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2484 sessBaselineMsgs = next
2485 if flushErr != nil {
2486 iv.ReportError(fmt.Errorf("flush session: %w", flushErr))
2487 return
2488 }
2489 },
2490 SessionTransition: newSessionTransition(&sessionTransitionMu),
2491 Extensions: extMgr,
2492 Supervisor: subagentsMgr,
2493 ResolveSubagent: resolveSubagent,
2494 ChangelogChan: changelogCh,
2495 OnChangelogDismiss: func() {
2496 // For development builds, store the actual release version
2497 // so the same changelog doesn't show again next launch.
2498 // For real builds, store the binary version.
2499 v := version
2500 if devBuild {
2501 if iv != nil && iv.ChangelogVersion() != "" {
2502 v = iv.ChangelogVersion()
2503 }
2504 }
2505 _ = MarkChangelogShown(v)
2506 },
2507 SkillSnapshot: func() []*skills.Skill {
2508 if args.NoSkill {
2509 // --no-skill: nothing for the picker to show.
2510 return nil
2511 }
2512 // Re-discover so the picker reflects edits made during
2513 // the session. Cheap; SKILL.md files are small. Filter
2514 // out built-in skills — they're hidden from user-facing
2515 // surfaces because they're implementation detail; the
2516 // model still sees them through the system-prompt
2517 // manifest and the skill tool.
2518 userHome, _ := os.UserHomeDir()
2519 list, _ := skills.Discover(ZutHome(), r.CWD, userHome, args.WithSkills)
2520 list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills())
2521 return skills.VisibleSkills(list)
2522 },
2523 NoYolo: args.NoYolo,
2524 ConfirmGate: confirmGate,
2525 PersistModel: newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, func(message string) {
2526 if iv != nil {
2527 iv.Notify("session", "error", message)
2528 }
2529 }),
2530 CurrentSessionTitle: func() string {
2531 sessionTransitionMu.RLock()
2532 defer sessionTransitionMu.RUnlock()
2533 persistMu.Lock()
2534 defer persistMu.Unlock()
2535 if sess == nil || sessionTitlePending {
2536 return ""
2537 }
2538 return sess.Title
2539 },
2540 CurrentSessionTitlePending: func() bool {
2541 sessionTransitionMu.RLock()
2542 defer sessionTransitionMu.RUnlock()
2543 persistMu.Lock()
2544 defer persistMu.Unlock()
2545 return sessionTitlePending
2546 },
2547 PersistTitle: func(title string) error {
2548 persistMu.Lock()
2549 defer persistMu.Unlock()
2550 if sess == nil {
2551 return nil
2552 }
2553 if err := sess.UpdateTitle(title); err != nil {
2554 return err
2555 }
2556 sessionTitlePending = false
2557 return nil
2558 },
2559 OnSessionTitleChanged: func(title string) {
2560 sessionTransitionMu.RLock()
2561 defer sessionTransitionMu.RUnlock()
2562 persistMu.Lock()
2563 defer persistMu.Unlock()
2564 if sess != nil {
2565 sess.Title = title
2566 sessionTitlePending = false
2567 }
2568 },
2569 })
2570 extHooks.attachInteractive(iv)
2571 go func() {
2572 <-reloadDone
2573 for _, reloadErr := range reloadErrs {
2574 if reloadErr != nil {
2575 iv.ReportError(fmt.Errorf("reload subagents: %w", reloadErr))
2576 }
2577 }
2578 }()
2579
2580 // Bind the interactive TUI as the Confirmer. We deferred this
2581 // until now because the gate is constructed before the TUI
2582 // (the BeforeToolExecute closure captures it). SetConfirmer
2583 // is mutex-guarded on the gate so this is safe.
2584 if confirmGate != nil {
2585 confirmGate.SetConfirmer(&confirmationEventConfirmer{
2586 inner: iv,
2587 emit: extMgr.EmitEvent,
2588 })
2589 }
2590
2591 // Signal-driven flush: a SIGTERM / SIGHUP to the zut process
2592 // (closed terminal window, system shutdown, kill) used to lose
2593 // the entire in-memory transcript because the deferred post-Run
2594 // flush below never ran. Per-message persistence above covers
2595 // most of it; this handler writes any in-flight remainder and
2596 // then exits the process so we don't double-paint over a
2597 // broken terminal that the TUI's restore deferreds can no
2598 // longer fix from a signal context.
2599 //
2600 // SIGINT is intentionally NOT handled here — the TUI consumes
2601 // Ctrl+C as a regular key event for cancel/clear semantics, and
2602 // installing a SIGINT notifier here would swallow it.
2603 sigCh := make(chan os.Signal, 1)
2604 signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
2605 defer signal.Stop(sigCh)
2606 go func() {
2607 _, ok := <-sigCh
2608 if !ok {
2609 return
2610 }
2611 finalAg := iv.Agent()
2612 if finalAg != nil {
2613 persistMu.Lock()
2614 if sess != nil {
2615 next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2616 sessBaselineMsgs = next
2617 if flushErr != nil {
2618 fmt.Fprintf(os.Stderr, "flush session: %v\n", flushErr)
2619 }
2620 if closeErr := sess.Close(); closeErr != nil {
2621 fmt.Fprintf(os.Stderr, "close session: %v\n", closeErr)
2622 }
2623 sess = nil
2624 }
2625 persistMu.Unlock()
2626 }
2627 closeAgentLSP(finalAg)
2628 closeResolvedLSP(r)
2629 // Stop supervised workers before exiting. os.Exit skips deferred
2630 // cleanup, so wait for reload and close the shared runtime explicitly.
2631 <-reloadDone
2632 _ = closeSubagentRuntimeFresh(runtime)
2633 // Exit cleanly. Re-raising the signal would skip os.Exit's
2634 // at-exit hooks; explicit exit is fine because we've already
2635 // flushed the only at-risk state (the session file) and stopped
2636 // supervised workers.
2637 os.Exit(0)
2638 }()
2639
2640 runErr = iv.Run(ctx)
2641
2642 // Flush final transcript to session (only if we had / ended up with an agent).
2643 if finalAg := iv.Agent(); finalAg != nil {
2644 persistMu.Lock()
2645 if sess != nil {
2646 next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2647 sessBaselineMsgs = next
2648 if flushErr != nil {
2649 runErr = errors.Join(runErr, fmt.Errorf("flush session: %w", flushErr))
2650 }
2651 }
2652 persistMu.Unlock()
2653 }
2654 return runErr
2655}
2656
2657func resumableSessionID(root string, sess *core.Session) string {
2658 if sess == nil || sess.ID == "" || sess.Path == "" {
2659 return ""
2660 }
2661 path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID)
2662 if err != nil || !sameSessionPath(path, sess.Path) {
2663 return ""
2664 }
2665 snapshot, err := core.ReadSessionSnapshot(path)
2666 if err != nil || len(snapshot.Messages) == 0 {
2667 return ""
2668 }
2669 return sess.ID
2670}
2671
2672func sameSessionPath(left, right string) bool {
2673 leftAbs, leftErr := filepath.Abs(left)
2674 rightAbs, rightErr := filepath.Abs(right)
2675 if leftErr == nil && rightErr == nil {
2676 return filepath.Clean(leftAbs) == filepath.Clean(rightAbs)
2677 }
2678 return filepath.Clean(left) == filepath.Clean(right)
2679}
2680
2681func resumeSessionHint(id string) string {
2682 return fmt.Sprintf("Resume this session with: zut --resume %s\n", id)
2683}
2684
2685func agentSessionsRoot(root string, args Args) string {
2686 if args.AgentName == "" {
2687 return root
2688 }
2689 return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName))
2690}
2691
2692// openOrCreateSession returns a session for the run. sess may be nil
2693// with a nil error if session persistence is disabled.
2694func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
2695 if args.NoSess {
2696 return nil, nil
2697 }
2698 // Sweep meta-only files left over from older zut versions (and from
2699 // any session that crashed before its first AppendMessage) for the
2700 // cwd-scoped picker paths. Explicit UUID lookup stays strict so it
2701 // can report metadata failures instead of turning them into misses.
2702 sessionsRoot := agentSessionsRoot(ZutHome(), args)
2703 if args.ResumeSessionID == "" {
2704 core.PruneEmptySessions(sessionsRoot, args.CWD)
2705 }
2706 var (
2707 s *core.Session
2708 msgs []provider.Message
2709 err error
2710 )
2711 switch {
2712 case args.Session != "":
2713 s, msgs, err = core.OpenSession(args.Session)
2714 // The subagent-worker child passes a fixed --session path that
2715 // may not exist yet on first Spawn. Treat ENOENT as "create
2716 // a fresh session AT THIS PATH" so the conversation actually
2717 // gets persisted; without this fallback the subagent child runs
2718 // with sess==nil and every Resume re-starts with no memory
2719 // of the prior turns. Other openers (--continue / --resume /
2720 // the picker) never see ENOENT here because they only choose
2721 // paths that already exist on disk.
2722 if err != nil && errors.Is(err, os.ErrNotExist) {
2723 s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
2724 msgs = nil
2725 }
2726 case args.Continue:
2727 latest := core.LatestSession(sessionsRoot, args.CWD)
2728 if latest != "" {
2729 s, msgs, err = core.OpenSession(latest)
2730 }
2731 case args.Resume:
2732 if args.ResumeSessionID != "" {
2733 picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID)
2734 if lookupErr != nil {
2735 return nil, lookupErr
2736 }
2737 if picked == "" {
2738 return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)
2739 }
2740 s, msgs, err = core.OpenSession(picked)
2741 break
2742 }
2743 picked, perr := pickSession(sessionsRoot, args.CWD)
2744 if perr != nil {
2745 return nil, perr
2746 }
2747 if picked != "" {
2748 s, msgs, err = core.OpenSession(picked)
2749 }
2750 }
2751 if err != nil {
2752 return nil, err
2753 }
2754 if s != nil {
2755 ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2756 ag.SetMessages(msgs)
2757 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
2758 ag.SeedCost(cum)
2759 ag.SeedLastTurnUsage(last)
2760 }
2761 return s, nil
2762 }
2763 s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2764 if err == nil {
2765 ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2766 }
2767 return s, err
2768}
2769
2770func pickSession(root, cwd string) (string, error) {
2771 files := core.ListSessions(root, cwd)
2772 if len(files) == 0 {
2773 fmt.Fprintln(os.Stderr, "no sessions for", cwd)
2774 return "", nil
2775 }
2776 for i, f := range files {
2777 fmt.Fprintf(os.Stderr, " %2d) %s\n", i+1, f)
2778 }
2779 fmt.Fprint(os.Stderr, "pick #: ")
2780 rd := bufio.NewReader(os.Stdin)
2781 line, _ := rd.ReadString('\n')
2782 line = strings.TrimSpace(line)
2783 var n int
2784 if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
2785 return "", fmt.Errorf("invalid selection")
2786 }
2787 return files[n-1], nil
2788}
2789
2790// WriteNewTranscript appends only messages after index `from` from the
2791// agent's transcript to the session. Used by callers that don't hold
2792// the persistMu (non-interactive print/json modes which run a single
2793// turn under their own goroutine).
2794func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) error {
2795 _, err := writeNewTranscriptLocked(ag, sess, from)
2796 return err
2797}
2798
2799// writeNewTranscriptLocked is the same as WriteNewTranscript. The
2800// suffix marks that interactive callers must hold persistMu when
2801// invoking it so concurrent appends from the agent loop don't race
2802// with this catch-up flush.
2803func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (next int, err error) {
2804 next = from
2805 if sess == nil || ag == nil {
2806 return next, nil
2807 }
2808 msgs := ag.Messages()
2809 for i := from; i < len(msgs); i++ {
2810 if err := sess.AppendMessage(msgs[i]); err != nil {
2811 return next, fmt.Errorf("append transcript message %d: %w", i, err)
2812 }
2813 next = i + 1
2814 }
2815 cum := ag.Cost()
2816 if err := sess.AppendUsage(cum, cum); err != nil {
2817 return next, fmt.Errorf("append transcript usage: %w", err)
2818 }
2819 if err := sess.Flush(); err != nil {
2820 return next, fmt.Errorf("flush transcript: %w", err)
2821 }
2822 return next, nil
2823}
2824
2825func readAllStdin() (string, error) {
2826 fi, err := os.Stdin.Stat()
2827 if err != nil {
2828 return "", err
2829 }
2830 if (fi.Mode() & os.ModeCharDevice) != 0 {
2831 return "", nil
2832 }
2833 return readAllStdinFrom(os.Stdin)
2834}
2835
2836func readAllStdinFrom(r io.Reader) (string, error) {
2837 b, err := io.ReadAll(r)
2838 return string(b), err
2839}
2840
2841func printModels() {
2842 models := provider.Active()
2843
2844 // Compute column widths from actual data so wide providers (e.g.
2845 // xiaomi-token-plan-sgp) and long bedrock model ids don't force the
2846 // `name` column off-screen. Floors mirror the historical layout so
2847 // short catalogs look the same as before.
2848 provW, idW, srcW := len("provider"), len("model id"), len("source")
2849 for _, m := range models {
2850 if w := len(m.Provider); w > provW {
2851 provW = w
2852 }
2853 if w := len(m.ID); w > idW {
2854 idW = w
2855 }
2856 source := m.Source
2857 if source == "" {
2858 source = "catalog"
2859 }
2860 if m.Speculative {
2861 source = "speculative"
2862 }
2863 if w := len(source); w > srcW {
2864 srcW = w
2865 }
2866 }
2867
2868 header := fmt.Sprintf("%-*s %-*s %8s %8s %s %-*s %s",
2869 provW, "provider",
2870 idW, "model id",
2871 "context", "max-out", "reasoning",
2872 srcW, "source",
2873 "name")
2874 fmt.Println(header)
2875
2876 for _, m := range models {
2877 reason := " "
2878 if m.Reasoning {
2879 reason = "✓"
2880 }
2881 source := m.Source
2882 if source == "" {
2883 source = "catalog"
2884 }
2885 if m.Speculative {
2886 source = "speculative"
2887 }
2888 fmt.Printf("%-*s %-*s %8d %8d %s %-*s %s\n",
2889 provW, m.Provider,
2890 idW, m.ID,
2891 m.ContextWindow, m.MaxOutput,
2892 reason,
2893 srcW, source,
2894 m.DisplayName)
2895 }
2896}