v0.13.0 befd698

cli.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close