v0.14.0 75d4151

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	wireRetryLifecyclePersistence(ag, sess)
992	if sess != nil {
993		defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
994	}
995
996	prompt := args.Prompt
997	if prompt == "" {
998		piped, _ := readAllStdin()
999		prompt = strings.TrimSpace(piped)
1000	}
1001	if prompt == "" {
1002		return fmt.Errorf("print mode requires a prompt (arg or stdin)")
1003	}
1004
1005	start := len(ag.Messages())
1006	if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {
1007		return err
1008	}
1009	if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1010		return err
1011	}
1012	var persistCompaction func([]provider.Message) error
1013	if sess != nil {
1014		persistCompaction = sess.AppendCompaction
1015	}
1016	started := time.Now()
1017	usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
1018	elapsed := time.Since(started)
1019	transcriptStart := start
1020	if recovery.Compacted {
1021		transcriptStart = recovery.OutputStart
1022	}
1023	if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1024		if err != nil {
1025			return errors.Join(err, persistErr)
1026		}
1027		return persistErr
1028	}
1029	if err != nil {
1030		return err
1031	}
1032	if args.StatsPath != "" {
1033		return writePrintStats(args.StatsPath, r.Provider, r.Model, usage, elapsed)
1034	}
1035	return nil
1036}
1037
1038func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {
1039	if args.Orchestrate {
1040		return runOrchestratedStreamMode(ctx, args, version)
1041	}
1042	if args.NoYolo {
1043		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
1044	}
1045	r, err := Resolve(args, true)
1046	if err != nil {
1047		return err
1048	}
1049	extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
1050	defer stopExt()
1051
1052	ag := r.NewAgent()
1053	initialAg := ag
1054	defer func() {
1055		closeAgentLSP(ag)
1056		if ag != initialAg {
1057			closeAgentLSP(initialAg)
1058		}
1059	}()
1060	wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1061	sess, err := openOrCreateSession(ctx, args, r, ag, version)
1062	if err != nil {
1063		return err
1064	}
1065	if sess != nil {
1066		var providerName, model string
1067		sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1068		if err != nil {
1069			return err
1070		}
1071		r.Provider, r.Model = providerName, model
1072		ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1073		defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1074	}
1075	announceSession(extMgr, sess)
1076	wireRetryLifecyclePersistence(ag, sess)
1077	if sess != nil {
1078		defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1079	}
1080
1081	prompt := args.Prompt
1082	if prompt == "" {
1083		piped, _ := readAllStdin()
1084		prompt = strings.TrimSpace(piped)
1085	}
1086	if prompt == "" {
1087		return fmt.Errorf("stream mode requires a prompt (arg or stdin)")
1088	}
1089
1090	start := len(ag.Messages())
1091	preSink, finishPre := newStreamTextSink(os.Stdout)
1092	if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
1093		finishPre()
1094		return err
1095	}
1096	finishPre()
1097	if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1098		return err
1099	}
1100	var persistCompaction func([]provider.Message) error
1101	if sess != nil {
1102		persistCompaction = sess.AppendCompaction
1103	}
1104	recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction)
1105	transcriptStart := start
1106	if recovery.Compacted {
1107		transcriptStart = recovery.OutputStart
1108	}
1109	if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1110		if err != nil {
1111			return errors.Join(err, persistErr)
1112		}
1113		return persistErr
1114	}
1115	return err
1116}
1117
1118func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
1119	var streamed, wroteText, lastWasNL bool
1120	writeText := func(text string) {
1121		if text == "" {
1122			return
1123		}
1124		_, _ = fmt.Fprint(out, text)
1125		if syncer, ok := out.(interface{ Sync() error }); ok {
1126			_ = syncer.Sync()
1127		}
1128		wroteText = true
1129		lastWasNL = strings.HasSuffix(text, "\n")
1130	}
1131	sink := func(ev core.AgentEvent) {
1132		switch e := ev.(type) {
1133		case core.EvAssistantStart:
1134			streamed = false
1135		case core.EvTextDelta:
1136			streamed = true
1137			writeText(e.Delta)
1138		case core.EvAssistantMessage:
1139			if streamed {
1140				return
1141			}
1142			var text strings.Builder
1143			for _, content := range e.Message.Content {
1144				if block, ok := content.(provider.TextBlock); ok {
1145					if text.Len() > 0 {
1146						text.WriteString("\n")
1147					}
1148					text.WriteString(block.Text)
1149				}
1150			}
1151			writeText(text.String())
1152		}
1153	}
1154	finish := func() {
1155		if wroteText && !lastWasNL {
1156			writeText("\n")
1157		}
1158	}
1159	return sink, finish
1160}
1161
1162func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {
1163	if args.Orchestrate {
1164		return runOrchestratedJSONMode(ctx, args, version)
1165	}
1166	if args.NoYolo {
1167		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
1168	}
1169	r, err := Resolve(args, true)
1170	if err != nil {
1171		return err
1172	}
1173	extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
1174	defer stopExt()
1175
1176	ag := r.NewAgent()
1177	initialAg := ag
1178	defer func() {
1179		closeAgentLSP(ag)
1180		if ag != initialAg {
1181			closeAgentLSP(initialAg)
1182		}
1183	}()
1184	wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1185	sess, err := openOrCreateSession(ctx, args, r, ag, version)
1186	if err != nil {
1187		return err
1188	}
1189	if sess != nil {
1190		var providerName, model string
1191		sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1192		if err != nil {
1193			return err
1194		}
1195		r.Provider, r.Model = providerName, model
1196		ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1197		defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1198	}
1199	announceSession(extMgr, sess)
1200	wireRetryLifecyclePersistence(ag, sess)
1201	if sess != nil {
1202		defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }()
1203	}
1204
1205	prompt := args.Prompt
1206	if prompt == "" {
1207		piped, _ := readAllStdin()
1208		prompt = strings.TrimSpace(piped)
1209	}
1210	if prompt == "" {
1211		return fmt.Errorf("json mode requires a prompt (arg or stdin)")
1212	}
1213
1214	start := len(ag.Messages())
1215	enc := json.NewEncoder(os.Stdout)
1216	preSink := func(ev core.AgentEvent) {
1217		_ = enc.Encode(modes.EventToJSON(ev))
1218	}
1219	if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
1220		_ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1221		return err
1222	}
1223	if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1224		_ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1225		return err
1226	}
1227	var persistCompaction func([]provider.Message) error
1228	if sess != nil {
1229		persistCompaction = sess.AppendCompaction
1230	}
1231	recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
1232	transcriptStart := start
1233	if recovery.Compacted {
1234		transcriptStart = recovery.OutputStart
1235	}
1236	if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1237		finalErr := persistErr
1238		if err != nil {
1239			finalErr = errors.Join(err, persistErr)
1240		}
1241		_ = enc.Encode(map[string]any{"type": "error", "message": finalErr.Error()})
1242		return finalErr
1243	}
1244	return err
1245}
1246
1247// runZutfileStartupPre runs entry.pre before the main non-interactive prompt.
1248// "!command" values execute via BashTool; other values are sent as an agent turn.
1249// shellOut receives live shell chunks (typically os.Stderr); sink receives
1250// agent events for plain-text pre (stream mode wires a live text sink).
1251func runZutfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error {
1252	pre = strings.TrimSpace(pre)
1253	if pre == "" {
1254		return nil
1255	}
1256	if cmd, ok := modes.ShellEscapeCommand(pre); ok {
1257		raw, err := json.Marshal(map[string]any{"command": cmd})
1258		if err != nil {
1259			return err
1260		}
1261		if shellOut != nil {
1262			fmt.Fprintf(shellOut, "$ %s\n", cmd)
1263			if f, ok := shellOut.(interface{ Sync() error }); ok {
1264				_ = f.Sync()
1265			}
1266		}
1267		progress := func(chunk string) {
1268			if shellOut == nil || chunk == "" {
1269				return
1270			}
1271			fmt.Fprint(shellOut, chunk)
1272			if f, ok := shellOut.(interface{ Sync() error }); ok {
1273				_ = f.Sync()
1274			}
1275		}
1276		bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
1277		res, err := bash.Execute(ctx, raw, progress)
1278		if err != nil {
1279			return fmt.Errorf("zutfile entry.pre: %w", err)
1280		}
1281		if res.IsError {
1282			var sb strings.Builder
1283			for _, c := range res.Content {
1284				if tb, ok := c.(provider.TextBlock); ok {
1285					sb.WriteString(tb.Text)
1286				}
1287			}
1288			msg := strings.TrimSpace(sb.String())
1289			if msg == "" {
1290				msg = "command failed"
1291			}
1292			return fmt.Errorf("zutfile entry.pre: %s", msg)
1293		}
1294		return nil
1295	}
1296	if ag == nil {
1297		return fmt.Errorf("zutfile entry.pre requires an agent for non-shell prompts")
1298	}
1299	if sink == nil {
1300		sink = func(core.AgentEvent) {}
1301	}
1302	return ag.Prompt(ctx, pre, nil, sink)
1303}
1304
1305var errInteractiveAgentChanged = errors.New("interactive agent or web-search policy changed during prompt refresh")
1306
1307// refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
1308// skills and currently loaded extension tools) and updates the live
1309// agent's registry and system prompt. Used after /reload-ext and after
1310// zutfile entry.pre installs new skills or extensions.
1311// mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn).
1312// interactive, when non-nil, serializes the final commit with agent replacement.
1313func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) (subagents.WebSearchPolicy, error) {
1314	if ag == nil {
1315		return subagents.WebSearchDeny, nil
1316	}
1317	var webSearchPolicyGeneration uint64
1318	if interactive != nil {
1319		// Capture before Resolve: the final commit must fail if settings,
1320		// Telegram attachment, or a newer registry commit changes policy while
1321		// filesystem/config/extension discovery is in flight.
1322		webSearchPolicyGeneration = interactive.WebSearchPolicyGeneration()
1323	}
1324	resolved, err := Resolve(args, true)
1325	if err != nil {
1326		return subagents.WebSearchDeny, fmt.Errorf("refresh agent prompt and tools: %w", err)
1327	}
1328	if sharedSandbox != nil {
1329		resolved.UseSandbox(sharedSandbox)
1330	}
1331	if extToolAdapter != nil {
1332		resolved.MergeExtensionTools(extToolAdapter)
1333	}
1334	reg := resolved.ToolRegistry
1335	if resolved.WebSearchPolicy != subagents.WebSearchAllow {
1336		delete(reg, "web_search")
1337	}
1338	if mutateRegistry != nil {
1339		reg = mutateRegistry(reg)
1340	}
1341	if interactive != nil {
1342		oldTools, applied := interactive.ApplyAgentPromptConfigAtWebSearchGeneration(ag, resolved.SystemPrompt, reg, webSearchPolicyGeneration)
1343		if !applied {
1344			_ = tools.CloseLSPManagers(reg)
1345			return subagents.WebSearchDeny, errInteractiveAgentChanged
1346		}
1347		interactive.DeferUntilIdle(func() {
1348			_ = tools.CloseLSPManagers(oldTools)
1349		})
1350		return webSearchPolicyForRegistry(resolved.WebSearchPolicy, ag.ToolsSnapshot()), nil
1351	}
1352	oldTools := ag.SetPromptConfig(resolved.SystemPrompt, reg)
1353	_ = tools.CloseLSPManagers(oldTools)
1354	return webSearchPolicyForRegistry(resolved.WebSearchPolicy, reg), nil
1355}
1356
1357func webSearchPolicyForRegistry(policy subagents.WebSearchPolicy, reg core.Registry) subagents.WebSearchPolicy {
1358	if policy == subagents.WebSearchAllow {
1359		if _, ok := reg["web_search"]; ok {
1360			return subagents.WebSearchAllow
1361		}
1362	}
1363	return subagents.WebSearchDeny
1364}
1365
1366func webSearchToolAllowedForSession(args Args) bool {
1367	if args.NoTools || args.PermissionSet != nil {
1368		return false
1369	}
1370	if args.ToolsSet || len(args.Tools) > 0 {
1371		return toolListContains(args.Tools, "web_search")
1372	}
1373	return true
1374}
1375
1376func webSearchExplicitlyEnabledForSession(args Args) bool {
1377	return !args.NoTools && args.PermissionSet == nil && (args.ToolsSet || len(args.Tools) > 0) && toolListContains(args.Tools, "web_search")
1378}
1379
1380// reloadResourcesAfterStartupPre reloads extensions (if any) and
1381// refreshes the agent tool registry + system prompt so skills/extensions
1382// installed by entry.pre are visible to the following turn.
1383func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {
1384	_, err := reloadResourcesAfterStartupPreWithRegistry(ctx, args, extMgr, sharedSandbox, ag, nil)
1385	return err
1386}
1387
1388func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) (subagents.WebSearchPolicy, error) {
1389	if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1390		return subagents.WebSearchDeny, nil
1391	}
1392	adapter := &extToolAdapter{mgr: extMgr}
1393	if extMgr != nil {
1394		_ = extMgr.Reload(ctx, 2*time.Second)
1395	}
1396	return refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, mutateRegistry, nil)
1397}
1398
1399// ---- interactive mode: opens the TUI even without credentials ----
1400
1401func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
1402	initialCfg, _ := LoadConfig()
1403	// Resolve WITHOUT requiring credentials.
1404	r, err := Resolve(args, false)
1405	if err != nil {
1406		return err
1407	}
1408
1409	authStore := AuthStoreFor()
1410	mgr := auth.NewManager(authStore)
1411	defer mgr.Close()
1412
1413	// Keep the sandbox pointer stable across agent rebuilds (login / model
1414	// switch). The Interactive UI toggles the lock via this pointer, and
1415	// rebuilt tool instances must share the same one so the lock sticks.
1416	sharedSandbox := r.Sandbox
1417
1418	// persistMu guards the active session and selection across the agent
1419	// loop, TUI callbacks, and session swaps. Declare it before tool
1420	// construction because subagent defaults must read the live selection too.
1421	var persistMu sync.Mutex
1422	// Serialize session transitions with persistence callbacks so a candidate
1423	// is read only after the old agent is flushed and cannot be overwritten by
1424	// an in-flight append before commit.
1425	var sessionTransitionMu sync.RWMutex
1426	activeProvider := r.Provider
1427	activeModel := r.Model
1428
1429	// Build the extension manager BEFORE the agent so we can fold
1430	// extension-defined tools into the registry. Attach the interactive
1431	// host after constructing it below so startup alerts can be buffered.
1432	var iv *modes.Interactive
1433	extHooks := &interactiveExtHooks{}
1434	extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, extHooks)
1435	var startupExtensionErrors []string
1436	// --ext paths first so they win against installed extensions of
1437	// the same name (loadOne's first-write-wins semantics).
1438	for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
1439		startupExtensionErrors = append(startupExtensionErrors, e.Error())
1440	}
1441	// --no-ext skips the global + project-local discovery scan;
1442	// explicit --ext paths above are still honoured so you can run
1443	// "only this extension" with --no-ext --ext ./x.
1444	if !args.NoExt {
1445		for _, e := range extMgr.Discover(ctx) {
1446			startupExtensionErrors = append(startupExtensionErrors, e.Error())
1447		}
1448	}
1449	// Wait briefly for extensions to flush their initial register_tool
1450	// frames before we build the agent's tool registry. Half a second
1451	// is plenty for any extension that's actually well-behaved; ones
1452	// that don't send a ready frame eat the full grace and proceed.
1453	// 3s is the per-extension grace period for the ready frame.
1454	// Native binaries are instant; runtimes like `npx tsx` take ~1.5s
1455	// from cold cache. The wait is tight only for extensions that
1456	// haven't sent ready by then; ones that signalled earlier release
1457	// the wait immediately.
1458	extMgr.WaitForReady(3 * time.Second)
1459	defer extMgr.Stop(2 * time.Second)
1460
1461	extToolAdapter := &extToolAdapter{mgr: extMgr}
1462	r.MergeExtensionTools(extToolAdapter)
1463	webSearchGuard := &webSearchSessionGuard{}
1464
1465	// Keep the shared subagent runtime in the outer scope: session reloads,
1466	// dashboard updates, agent rebuilds, and shutdown all use the same owner.
1467	var runtime *subagentRuntime
1468	var subagentsMgr *subagents.Supervisor
1469	// These callbacks deliberately resolve iv lazily because the Interactive
1470	// instance is constructed after the agent and its registry.
1471	onSpawnedSupervisor := func(a *subagents.Agent, task string, required bool) {
1472		if iv != nil {
1473			iv.TrackSubagentWorker(a, task, required)
1474		}
1475	}
1476	onResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) {
1477		if iv != nil {
1478			iv.TrackResumedSubagentWorker(a, prompt, required)
1479		}
1480	}
1481	onBeforeResumedSupervisor := func(a *subagents.Agent, prompt string, required bool) func() {
1482		if iv != nil {
1483			return iv.PrepareResumedSubagentWorker(a, prompt, required)
1484		}
1485		return nil
1486	}
1487	onStopRequestedSupervisor := func(a *subagents.Agent) {
1488		if iv != nil {
1489			iv.TrackStoppedSubagentWorker(a)
1490		}
1491	}
1492	activeProviderForSubagents := func() string {
1493		persistMu.Lock()
1494		defer persistMu.Unlock()
1495		return activeProvider
1496	}
1497	activeModelForSubagents := func() string {
1498		persistMu.Lock()
1499		defer persistMu.Unlock()
1500		return activeModel
1501	}
1502
1503	runtime = newSubagentRuntime(subagentRuntimeConfig{
1504		Context:         ctx,
1505		Args:            args,
1506		Root:            filepath.Join(ZutHome(), "subagents"),
1507		RepoRoot:        r.CWD,
1508		Provider:        r.Provider,
1509		Model:           r.Model,
1510		Reasoning:       r.Reasoning,
1511		BaseURL:         r.BaseURL,
1512		InsecureTLS:     r.InsecureTLS,
1513		FastMode:        r.FastMode,
1514		APIKey:          args.APIKey,
1515		Policy:          subagentPolicyFromConfig(initialCfg.Subagents),
1516		WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1517		WebSearchGuard:  webSearchGuard,
1518		ActiveProvider:  activeProviderForSubagents,
1519		ActiveModel:     activeModelForSubagents,
1520		OnSpawned:       onSpawnedSupervisor,
1521		OnResumed:       onResumedSupervisor,
1522		BeforeResumed:   onBeforeResumedSupervisor,
1523		OnStopRequested: onStopRequestedSupervisor,
1524	})
1525	subagentsMgr = runtime.Supervisor()
1526	reloadDone := make(chan struct{})
1527	var reloadErrs []error
1528	runtime.SetRequiredWorkerReady(reloadDone, func() error {
1529		return errors.Join(reloadErrs...)
1530	})
1531	if subagentsMgr != nil {
1532		// Replaying historical event logs can take seconds. Populate the
1533		// detached-agent dashboard without blocking the first interactive paint.
1534		go func() {
1535			_, reloadErrs = subagentsMgr.Reload()
1536			close(reloadDone)
1537		}()
1538	} else {
1539		close(reloadDone)
1540	}
1541	defer func() {
1542		// Reload may attach detached workers, so let it finish before shutdown
1543		// snapshots the supervisor's worker set.
1544		<-reloadDone
1545		_ = closeSubagentRuntimeFresh(runtime)
1546	}()
1547
1548	prepareInteractiveRegistry := runtime.PrepareRegistry
1549	prepareResolvedInteractiveRegistry := runtime.PrepareResolvedRegistry
1550	prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
1551	initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
1552	webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
1553	runtime.SetWebSearchPolicy(initialWebSearchPolicy)
1554	resolveSubagent := runtime.resolveSubagent
1555
1556	// Confirmation gate: when --no-yolo is on, the agent must ask
1557	// the user before every tool call. In interactive mode the TUI
1558	// provides the Confirmer; in print/json/rpc modes there's no
1559	// way to prompt, so the gate is constructed with a nil inner
1560	// which auto-refuses every call with a helpful reason.
1561	var confirmGate *core.ConfirmGate
1562	if args.NoYolo {
1563		confirmGate = core.NewConfirmGate(nil) // set below for interactive
1564	}
1565
1566	// Capture current args in a closure so BuildAgent can re-resolve
1567	// after a successful login (picks up the newly stored credential).
1568	wireAgentExt := func(a *core.Agent) *core.Agent {
1569		if a == nil {
1570			return a
1571		}
1572		a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
1573			// Guards run before confirmation because they may rewrite the
1574			// arguments. The user must approve the effective call that will
1575			// actually execute, not the model's original arguments.
1576			r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
1577			if r.Block {
1578				return false, r.Reason, nil
1579			}
1580			effectiveArgs := call.Arguments
1581			if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
1582				effectiveArgs = r.ModifiedArgs
1583			}
1584			if confirmGate != nil {
1585				var content strings.Builder
1586				_, currentTools := a.PromptConfig()
1587				if tool, err := currentTools.Get(call.Name); err == nil {
1588					if previewer, ok := tool.(core.ToolPreviewer); ok {
1589						preview, err := previewer.Preview(ctx, effectiveArgs)
1590						if err != nil {
1591							return false, err.Error(), nil
1592						}
1593						for _, block := range preview.Content {
1594							if text, ok := block.(provider.TextBlock); ok {
1595								if content.Len() > 0 {
1596									content.WriteString("\n")
1597								}
1598								content.WriteString(text.Text)
1599							}
1600						}
1601					}
1602				}
1603				ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
1604					ID:      call.ID,
1605					Name:    call.Name,
1606					Summary: core.BuildPreview(effectiveArgs, 120),
1607					Content: content.String(),
1608					Origin:  call.Origin,
1609				})
1610				if !ok {
1611					return false, reason, nil
1612				}
1613			}
1614			return true, "", r.ModifiedArgs
1615		}
1616		a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1617			r := extMgr.InterceptTurnStart(turnCtx, step)
1618			return !r.Block, r.Reason, r.Context
1619		}
1620		a.BeforeAssistantMessage = func(text string) (bool, string, string) {
1621			r := extMgr.InterceptAssistantMessage(ctx, text)
1622			if r.Block {
1623				return false, r.Reason, ""
1624			}
1625			return true, "", r.ReplaceText
1626		}
1627		a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
1628		runtime.WireRequiredWorkerGate(a)
1629		return a
1630	}
1631
1632	var ag *core.Agent
1633	buildAgent := func() (*core.Agent, string, string, error) {
1634		resolved, err := Resolve(args, true)
1635		if err != nil {
1636			return nil, "", "", err
1637		}
1638		resolved.UseSandbox(sharedSandbox)
1639		runtime.SetProvider(resolved.Provider)
1640		runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1641		runtime.SetFastMode(resolved.FastMode)
1642		resolved.MergeExtensionTools(extToolAdapter)
1643		prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1644		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1645	}
1646
1647	// Rebuild agent with an explicit provider/model override.
1648	buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1649		next := args
1650		if providerOverride != "" {
1651			next.Provider = providerOverride
1652		}
1653		if modelOverride != "" {
1654			next.Model = modelOverride
1655		}
1656		resolved, err := Resolve(next, true)
1657		if err != nil {
1658			return nil, "", "", err
1659		}
1660		resolved.UseSandbox(sharedSandbox)
1661		runtime.SetProvider(resolved.Provider)
1662		runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1663		runtime.SetFastMode(resolved.FastMode)
1664		resolved.MergeExtensionTools(extToolAdapter)
1665		prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1666		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1667	}
1668
1669	// Rebuild agent for the rescue picker after a recoverable failure.
1670	// Unlike buildAgentFor, this drops launch-time --api-key and
1671	// --base-url overrides because those are typically the cause of the
1672	// rescue (a bad key, a typo'd base URL, or a corporate gateway that
1673	// only the originally-picked provider needed). Re-resolving without
1674	// them lets the rescue retry use env vars / auth.json / provider
1675	// defaults the way zut would have without the overrides.
1676	buildAgentForRescue := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1677		next := args
1678		next.APIKey = ""
1679		next.BaseURL = ""
1680		if providerOverride != "" {
1681			next.Provider = providerOverride
1682		}
1683		if modelOverride != "" {
1684			next.Model = modelOverride
1685		}
1686		resolved, err := Resolve(next, true)
1687		if err != nil {
1688			return nil, "", "", err
1689		}
1690		resolved.UseSandbox(sharedSandbox)
1691		runtime.SetProvider(resolved.Provider)
1692		runtime.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1693		runtime.SetFastMode(resolved.FastMode)
1694		resolved.MergeExtensionTools(extToolAdapter)
1695		prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1696		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1697	}
1698
1699	if r.HasCredential() {
1700		ag = wireAgentExt(r.NewAgent())
1701	}
1702
1703	// /reload-ext callback: after the manager has respawned every
1704	// extension, re-resolve the tool registry (built-ins + freshly-
1705	// registered extension tools) and swap it onto the current
1706	// agent in-place. The current agent may have been replaced by a
1707	// /model swap since spawn, so re-read the live `ag` on each
1708	// invocation.
1709	extMgr.SetOnReload(func() {
1710		current := liveInteractiveAgent(iv, ag)
1711		if current == nil {
1712			return
1713		}
1714		if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil && iv != nil {
1715			iv.ReportError(err)
1716		}
1717	})
1718
1719	var sess *core.Session
1720	var sessBaselineMsgs int // messages already on disk when current session opened
1721	var sessionTitlePending bool
1722	if !args.NoSess && ag != nil {
1723		var sessErr error
1724		sess, sessErr = openOrCreateSession(ctx, args, r, ag, version)
1725		if sessErr != nil {
1726			return sessErr
1727		}
1728		sessBaselineMsgs = len(ag.Messages())
1729		if sess != nil {
1730			// Startup resume uses the same provider/model-aware preparation as
1731			// the interactive session picker. This matters for --continue,
1732			// --resume, and explicit --session paths whose latest meta row was
1733			// written by another provider/model.
1734			candidate, resumeErr := applySessionResume(sess, ag, r.Provider, r.Model,
1735				strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", buildAgentFor)
1736			if resumeErr != nil {
1737				return resumeErr
1738			}
1739			sess = candidate.session
1740			ag = candidate.agent
1741			sessBaselineMsgs = candidate.fullMessageCount
1742			activeProvider = candidate.provider
1743			activeModel = candidate.model
1744			r.Provider = candidate.provider
1745			r.Model = candidate.model
1746		}
1747		if sess != nil && ag != nil {
1748			sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
1749		}
1750	}
1751	announceSession(extMgr, sess)
1752	// Print the hint after the session close defer below and all terminal/TUI
1753	// cleanup defers. Close removes a fresh empty session, so only a session
1754	// that remains discoverable through the hinted command gets a hint.
1755	defer func() {
1756		if !iv.ExitedViaCtrlC() {
1757			return
1758		}
1759		persistMu.Lock()
1760		current := sess
1761		persistMu.Unlock()
1762		if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" {
1763			fmt.Print(resumeSessionHint(resumeID))
1764		}
1765	}()
1766	defer func() {
1767		persistMu.Lock()
1768		defer persistMu.Unlock()
1769		runErr = joinSessionCloseError(runErr, sess)
1770	}()
1771
1772	// persistMessage is the per-message hook bound to the agent. It
1773	// appends each new transcript message to the live session as soon
1774	// as it lands, so a kill / closed terminal / OS crash costs at
1775	// most the in-flight turn instead of the whole session. The
1776	// baseline counter advances in lock-step so the exit-time flush
1777	// doesn't double-write rows already on disk.
1778	persistMessage := func(m provider.Message) {
1779		sessionTransitionMu.RLock()
1780		defer sessionTransitionMu.RUnlock()
1781		persistMu.Lock()
1782		defer persistMu.Unlock()
1783		if sess == nil {
1784			return
1785		}
1786		if err := sess.AppendMessage(m); err == nil {
1787			sessBaselineMsgs++
1788		}
1789	}
1790	persistUsage := func(cum provider.Usage) {
1791		sessionTransitionMu.RLock()
1792		defer sessionTransitionMu.RUnlock()
1793		persistMu.Lock()
1794		defer persistMu.Unlock()
1795		if sess == nil {
1796			return
1797		}
1798		_ = sess.AppendUsage(cum, cum)
1799	}
1800	persistRetryLifecycle := func(record core.RetryLifecycleRecord) {
1801		sessionTransitionMu.RLock()
1802		defer sessionTransitionMu.RUnlock()
1803		persistMu.Lock()
1804		defer persistMu.Unlock()
1805		if sess != nil {
1806			sess.RecordRetryLifecycle(record)
1807		}
1808	}
1809	persistCompaction := func(messages []provider.Message) {
1810		sessionTransitionMu.RLock()
1811		var session *extproto.SessionContext
1812		var states map[string]json.RawMessage
1813		persistMu.Lock()
1814		if sess != nil {
1815			if err := sess.AppendCompaction(messages); err == nil {
1816				sessBaselineMsgs = len(messages)
1817				session = sessionContext(sess)
1818				states = copyExtensionStates(sess.ExtensionState)
1819			}
1820		}
1821		persistMu.Unlock()
1822		sessionTransitionMu.RUnlock()
1823		if session != nil {
1824			extMgr.EmitSessionEvent("session_compacted", session, states)
1825		}
1826	}
1827	persistCompactHandoff := func(state json.RawMessage) error {
1828		sessionTransitionMu.RLock()
1829		defer sessionTransitionMu.RUnlock()
1830		persistMu.Lock()
1831		defer persistMu.Unlock()
1832		if sess == nil {
1833			return nil
1834		}
1835		return sess.UpdateCompactHandoff(state)
1836	}
1837	currentCompactHandoff := func() json.RawMessage {
1838		persistMu.Lock()
1839		defer persistMu.Unlock()
1840		if sess == nil {
1841			return nil
1842		}
1843		return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
1844	}
1845	persistGoal := func(goal *core.SessionGoal) error {
1846		sessionTransitionMu.RLock()
1847		defer sessionTransitionMu.RUnlock()
1848		persistMu.Lock()
1849		defer persistMu.Unlock()
1850		if sess == nil {
1851			return errors.New("session persistence is disabled")
1852		}
1853		return sess.UpdateGoal(goal)
1854	}
1855	currentGoal := func() *core.SessionGoal {
1856		persistMu.Lock()
1857		defer persistMu.Unlock()
1858		if sess == nil || sess.Meta.Goal == nil {
1859			return nil
1860		}
1861		goal := *sess.Meta.Goal
1862		return &goal
1863	}
1864	bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) {
1865		if a == nil {
1866			return
1867		}
1868		a.CommitToolResult = func(_ string, result core.ToolResult) error {
1869			persistMu.Lock()
1870			defer persistMu.Unlock()
1871			return persistToolResultState(extMgr, boundSession, result)
1872		}
1873	}
1874	wireAgentPersist := func(a *core.Agent) *core.Agent {
1875		if a == nil {
1876			return a
1877		}
1878		persistMu.Lock()
1879		currentSession := sess
1880		persistMu.Unlock()
1881		if currentSession != nil {
1882			a.SetSessionTimeContext(currentSession.Meta.Started, currentSession.Meta.Timezone, currentSession.Meta.TimezoneOffset)
1883		}
1884		a.OnMessageAppended = persistMessage
1885		a.OnUsage = persistUsage
1886		a.OnRetryLifecycle = persistRetryLifecycle
1887		a.OnTranscriptCompacted = persistCompaction
1888		bindAgentToolResultSession(a, currentSession)
1889		return a
1890	}
1891	wireAgentPersist(ag)
1892	defer func() {
1893		closeAgentLSP(liveInteractiveAgent(iv, ag))
1894		closeResolvedLSP(r)
1895	}()
1896
1897	// Re-wrap the build closures so any agent constructed by the TUI
1898	// (login, /model swap to a different provider) also gets the
1899	// persistence hooks. Without this, switching provider would
1900	// silently revert to the old in-memory-only behaviour. Selection state
1901	// is published by the successful transition itself, not by preparation.
1902	baseBuildAgent := buildAgent
1903	buildAgent = func() (*core.Agent, string, string, error) {
1904		a, p, m, err := baseBuildAgent()
1905		return wireAgentPersist(a), p, m, err
1906	}
1907	baseBuildAgentFor := buildAgentFor
1908	buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1909		a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
1910		return wireAgentPersist(a), p, m, err
1911	}
1912	baseBuildAgentForRescue := buildAgentForRescue
1913	buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1914		a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
1915		return wireAgentPersist(a), p, m, err
1916	}
1917
1918	// loadSession replaces the current session with the one at path and
1919	// hands its messages to the agent. Used by the /sessions picker.
1920	loadSession := func(path string) (loadErr error) {
1921		// Hold the transition lock from the pre-flush through the commit.
1922		// Persistence callbacks take the read side, so an active session
1923		// cannot be snapshotted before its lazy writes land or overwritten by
1924		// a callback while the candidate is being installed.
1925		sessionTransitionMu.Lock()
1926		defer sessionTransitionMu.Unlock()
1927
1928		currentAg := liveInteractiveAgent(iv, ag)
1929		if currentAg == nil {
1930			return fmt.Errorf("no agent running; log in first")
1931		}
1932
1933		persistMu.Lock()
1934		currentProvider := activeProvider
1935		currentModel := activeModel
1936		oldSess := sess
1937		// Flush before reading the candidate. Use the locked form rather
1938		// than the public callback, which would acquire the read lock held
1939		// by this transition.
1940		if oldSess != nil {
1941			next, flushErr := writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1942			sessBaselineMsgs = next
1943			if flushErr != nil {
1944				persistMu.Unlock()
1945				return fmt.Errorf("flush current session: %w", flushErr)
1946			}
1947		}
1948		currentMessageCount := len(currentAg.Messages())
1949		currentCost := currentAg.Cost()
1950		persistMu.Unlock()
1951
1952		// A candidate build must not publish its provider/model as the live
1953		// selection before the candidate commits. The builder also stages
1954		// provider settings on the shared runtime, so restore those settings on
1955		// every failed path, including errors returned during preparation.
1956		runtimeConfig := runtime.snapshotConfiguration()
1957		committed := false
1958		var candidate sessionResumeCandidate
1959		defer func() {
1960			if !committed {
1961				runtime.restoreConfiguration(runtimeConfig)
1962				loadErr = joinSessionCloseError(loadErr, candidate.session)
1963			}
1964		}()
1965		candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1966		if err != nil {
1967			// prepareSessionResume closes its append handle on failure;
1968			// no live agent or session has been changed.
1969			return err
1970		}
1971
1972		// Wire the private candidate before it can become visible. This is
1973		// also required for legacy same-provider resumes, where the current
1974		// agent is reused and only its transcript/usage are refreshed.
1975		wireAgentPersist(candidate.agent)
1976
1977		persistMu.Lock()
1978		// A provider rebuild ran outside persistMu. Refuse to install it if
1979		// the live agent, session, selection, transcript, or cost changed in
1980		// the meantime; otherwise a slow resume could discard a newer turn or
1981		// model swap.
1982		live := liveInteractiveAgent(iv, ag)
1983		changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg
1984		if !changed && live != nil {
1985			changed = len(live.Messages()) != currentMessageCount || live.Cost() != currentCost
1986		}
1987		if changed {
1988			persistMu.Unlock()
1989			return fmt.Errorf("session changed while loading; please try again")
1990		}
1991		newStates := copyExtensionStates(candidate.session.ExtensionState)
1992		var oldCloseErr error
1993		if oldSess != nil {
1994			oldCloseErr = oldSess.Close()
1995		}
1996		sess = candidate.session
1997		bindAgentToolResultSession(candidate.agent, candidate.session)
1998		candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
1999		sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
2000		// The live agent only receives a compact resume window, but the
2001		// session file remains intact. Keep the persistence baseline at
2002		// the original on-disk message count so future turns append after
2003		// the full session instead of duplicating the hydrated tail.
2004		sessBaselineMsgs = candidate.fullMessageCount
2005
2006		if candidate.rebuilt {
2007			// Commit the TUI agent while persistMu still protects the session
2008			// writer, so no new message can land on the old file between the
2009			// two state changes.
2010			if iv != nil {
2011				iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)
2012			}
2013			ag = candidate.agent
2014		} else {
2015			currentAg.SetMessages(candidate.messages)
2016			currentAg.SeedCost(candidate.cumulative)
2017			currentAg.SeedLastTurnUsage(candidate.lastTurn)
2018			ag = currentAg
2019		}
2020		activeProvider = candidate.provider
2021		activeModel = candidate.model
2022		if subagentsMgr != nil && candidate.session != nil {
2023			// Keep the dashboard scope in the same commit as the session,
2024			// agent, usage, and persistence baseline.
2025			if iv != nil {
2026				iv.SetSubagentSessionScope(candidate.session.ID)
2027			} else {
2028				runtime.SetActiveSession(candidate.session.ID)
2029			}
2030		}
2031		committed = true
2032		persistMu.Unlock()
2033		if iv != nil {
2034			iv.RefreshGoal()
2035		}
2036		if candidate.session.Meta.Parent != "" {
2037			extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
2038		}
2039		extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
2040		if oldCloseErr != nil {
2041			return fmt.Errorf("close current session: %w", oldCloseErr)
2042		}
2043		return nil
2044	}
2045
2046	// changeCWD switches the running session to a new working directory.
2047	// Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
2048	// /cd slash command (which itself is only fired by the workspaces
2049	// extension's panel-key Enter handler today; the user can type /cd
2050	// directly but it's not in autocomplete / help / the README).
2051	//
2052	// Steps, in order:
2053	//   1. resolve + validate the new path (~ expansion, abs/rel)
2054	//   2. stage the candidate cwd, sandbox, and subagent state
2055	//   3. rebuild the agent and allocate its new session
2056	//   4. commit all new state together, closing the old session last
2057	//   5. push the committed state into the running Interactive
2058	//
2059	// The /jail state is preserved verbatim: if the sandbox was locked
2060	// to the old cwd, it stays locked, just re-pointed at the new one.
2061	changeCWD := func(path string) error {
2062		sessionTransitionMu.Lock()
2063		defer sessionTransitionMu.Unlock()
2064		if path == "" {
2065			return fmt.Errorf("empty path")
2066		}
2067		// ~ expansion.
2068		if path == "~" || strings.HasPrefix(path, "~/") {
2069			home, herr := os.UserHomeDir()
2070			if herr != nil || home == "" {
2071				return fmt.Errorf("cannot expand ~: %v", herr)
2072			}
2073			if path == "~" {
2074				path = home
2075			} else {
2076				path = filepath.Join(home, path[2:])
2077			}
2078		}
2079		if !filepath.IsAbs(path) {
2080			path = filepath.Join(args.CWD, path)
2081		}
2082		absPath, err := filepath.Abs(path)
2083		if err != nil {
2084			return err
2085		}
2086		info, err := os.Stat(absPath)
2087		if err != nil {
2088			return err
2089		}
2090		if !info.IsDir() {
2091			return fmt.Errorf("not a directory: %s", absPath)
2092		}
2093
2094		currentAg := liveInteractiveAgent(iv, ag)
2095		if currentAg == nil {
2096			return fmt.Errorf("no agent running; log in first")
2097		}
2098
2099		// Stage the candidate state while retaining a complete rollback
2100		// snapshot. A failed rebuild or session allocation must leave the
2101		// current agent, session, sandbox, and subagent root usable.
2102		oldCWD := args.CWD
2103		oldResolvedCWD := r.CWD
2104		oldPermissionSet := args.PermissionSet
2105		persistMu.Lock()
2106		oldActiveProvider := activeProvider
2107		oldActiveModel := activeModel
2108		persistMu.Unlock()
2109		runtimeConfig := runtime.snapshotConfiguration()
2110		oldSandboxRoot := ""
2111		oldSandboxLocked := false
2112		if sharedSandbox != nil {
2113			oldSandboxRoot = sharedSandbox.Root
2114			oldSandboxLocked = sharedSandbox.Locked()
2115		}
2116		rollback := func() {
2117			args.CWD = oldCWD
2118			r.CWD = oldResolvedCWD
2119			args.PermissionSet = oldPermissionSet
2120			persistMu.Lock()
2121			activeProvider = oldActiveProvider
2122			activeModel = oldActiveModel
2123			persistMu.Unlock()
2124			runtime.restoreConfiguration(runtimeConfig)
2125			if sharedSandbox != nil {
2126				sharedSandbox.Root = oldSandboxRoot
2127				if oldSandboxLocked {
2128					sharedSandbox.Lock()
2129				} else {
2130					sharedSandbox.Unlock()
2131				}
2132			}
2133		}
2134
2135		args.CWD = absPath
2136		r.CWD = absPath
2137		runtime.SetRepoRoot(absPath)
2138		if args.PermissionSet != nil {
2139			expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
2140			args.PermissionSet = &expanded
2141		}
2142		if sharedSandbox != nil {
2143			sharedSandbox.Root = absPath
2144			if oldSandboxLocked {
2145				sharedSandbox.Lock()
2146			} else {
2147				sharedSandbox.Unlock()
2148			}
2149		}
2150
2151		// Rebuild the agent so tools / AGENTS.md / system prompt
2152		// re-bind to the new cwd. buildAgent() reads from args + r.
2153		newAg, newProvider, newModel, berr := buildAgent()
2154		if berr != nil {
2155			rollback()
2156			return fmt.Errorf("rebuild agent: %w", berr)
2157		}
2158
2159		// Fresh session in the new cwd's bucket. We bypass
2160		// openOrCreateSession's --continue / --resume branches
2161		// because /cd's semantics are "start fresh here", matching
2162		// what relaunching `zut --cwd <path>` would do today.
2163		var newSess *core.Session
2164		if !args.NoSess {
2165			newRoot := agentSessionsRoot(ZutHome(), args)
2166			core.PruneEmptySessions(newRoot, absPath)
2167			var serr error
2168			newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
2169			if serr != nil {
2170				rollback()
2171				return fmt.Errorf("open session in %s: %w", absPath, serr)
2172			}
2173		}
2174
2175		// Invalidate any title request before swapping the host-owned session
2176		// pointer. The interactive state reset below happens after the swap,
2177		// so this guard closes the window where a late result could otherwise
2178		// be written to the new session.
2179		if iv != nil {
2180			iv.CancelPendingSessionTitle()
2181		}
2182
2183		// Commit only after every fallible operation has succeeded. Close
2184		// the old session last so rollback never has to resurrect it.
2185		var newStates map[string]json.RawMessage
2186		var oldCloseErr error
2187		persistMu.Lock()
2188		if sess != nil {
2189			next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2190			sessBaselineMsgs = next
2191			if flushErr != nil {
2192				persistMu.Unlock()
2193				rollbackErr := joinSessionCloseError(fmt.Errorf("flush current session: %w", flushErr), newSess)
2194				rollback()
2195				return rollbackErr
2196			}
2197			oldCloseErr = sess.Close()
2198		}
2199		sess = newSess
2200		bindAgentToolResultSession(newAg, newSess)
2201		if newSess != nil {
2202			newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset)
2203			newStates = copyExtensionStates(newSess.ExtensionState)
2204		}
2205		sessBaselineMsgs = 0
2206		sessionTitlePending = false
2207		activeProvider = newProvider
2208		activeModel = newModel
2209		persistMu.Unlock()
2210		ag = newAg
2211		closeAgentLSP(currentAg)
2212
2213		// Push the new state into the running Interactive.
2214		if iv != nil {
2215			startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
2216			iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
2217			iv.RefreshGoal()
2218		}
2219
2220		// Re-scope the subagent dashboard to the new session.
2221		if newSess != nil {
2222			if iv != nil {
2223				iv.SetSubagentSessionScope(newSess.ID)
2224			} else {
2225				runtime.SetActiveSession(newSess.ID)
2226			}
2227		}
2228		if newSess != nil {
2229			extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
2230		}
2231		if oldCloseErr != nil {
2232			return fmt.Errorf("close current session: %w", oldCloseErr)
2233		}
2234		return nil
2235	}
2236
2237	devBuild := isDevVersion(version)
2238	if devBuild {
2239		fmt.Fprintln(os.Stderr, "zut:", devVersionNotice)
2240	}
2241	term := tui.NewProcTerm()
2242
2243	var updateCh chan modes.UpdateInfo
2244	var changelogCh chan modes.ChangelogPayload
2245	if !devBuild {
2246		// Kick off the async update check so the banner can appear when the
2247		// http response eventually arrives (usually <1s on cached DNS). Map
2248		// agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
2249		updateCh = make(chan modes.UpdateInfo, 1)
2250		go func() {
2251			defer close(updateCh)
2252			src := <-CheckForUpdateAsync(ZutHome(), version)
2253			updateCh <- modes.UpdateInfo{
2254				Current:   src.Current,
2255				Latest:    src.Latest,
2256				Available: src.Available,
2257				URL:       src.URL,
2258			}
2259		}()
2260
2261		// Changelog: when the running version differs from the last
2262		// version whose release notes the user dismissed, fetch the
2263		// release body from GitHub and have the TUI show it once. On
2264		// first-ever launch (no prior LastChangelogShown), seed the
2265		// stored version silently — don't dump release notes at someone
2266		// who just installed.
2267		changelogCh = make(chan modes.ChangelogPayload, 1)
2268		go func() {
2269			defer close(changelogCh)
2270			cfg, _ := LoadConfig()
2271			if cfg.LastChangelogShown == "" {
2272				SeedChangelogVersion(version)
2273				return
2274			}
2275			if !ShouldShowChangelog(version, cfg) {
2276				return
2277			}
2278			info := <-FetchChangelogAsync(version)
2279			if info.Body == "" {
2280				return
2281			}
2282			changelogCh <- modes.ChangelogPayload{
2283				Version: info.Version,
2284				Body:    info.Body,
2285				URL:     info.URL,
2286			}
2287		}()
2288	}
2289
2290	fastMode := r.FastMode
2291	quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
2292	for idx, s := range initialCfg.QuickModelShortcuts {
2293		quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
2294	}
2295	theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), initialCfg.Theme, 80*time.Millisecond)
2296	if themeErr != nil {
2297		fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2298		if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {
2299			initialCfg.Theme = ""
2300			_ = SaveConfig(initialCfg)
2301		}
2302	}
2303
2304	// Scope the dashboard to the active host session. The runtime owns
2305	// shutdown separately and closes the supervisor exactly once.
2306	if sess != nil {
2307		runtime.SetActiveSession(sess.ID)
2308	}
2309
2310	var startupSkills []*skills.Skill
2311	if r.SkillTool != nil {
2312		startupSkills = r.SkillTool.Skills()
2313	}
2314	homeDir, _ := os.UserHomeDir()
2315	discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
2316	subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2317	autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
2318	autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
2319	autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args)
2320	autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args)
2321	webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)
2322	webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
2323	setWebSearchAvailable := func(available bool) {
2324		webSearchGuard.setAvailable(available)
2325		if subagentsMgr == nil {
2326			return
2327		}
2328		policy := subagents.WebSearchDeny
2329		if available {
2330			policy = subagents.WebSearchAllow
2331		}
2332		runtime.SetWebSearchPolicy(policy)
2333	}
2334
2335	iv = modes.NewInteractive(modes.InteractiveConfig{
2336		Terminal:                       term,
2337		Theme:                          theme,
2338		InlineImagesEnabled:            initialCfg.InlineImagesEnabled,
2339		TerminalAlertsEnabled:          initialCfg.TerminalAlertsEnabled,
2340		TerminalTitleEnabled:           initialCfg.TerminalTitleEnabled,
2341		AutoSubagentsEnabled:           initialCfg.AutoSubagentsEnabled,
2342		PonytailEnabled:                initialCfg.PonytailEnabled,
2343		WebSearchEnabled:               initialCfg.WebSearchEnabled,
2344		WebSearchToolAllowed:           &webSearchToolAllowedForInvocation,
2345		WebSearchInvocationOverride:    webSearchInvocationOverride,
2346		AutoSubagentsToolAllowed:       &autoSubagentsToolAllowedForSession,
2347		AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2348		AutoSubagentsStopToolAllowed:   &autoSubagentsStopToolAllowedForSession,
2349		AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,
2350		FastMode:                       &fastMode,
2351		LSPEnabled:                     initialCfg.LSPEnabled,
2352		SubagentLSPEnabled:             initialCfg.SubagentLSPEnabled,
2353		AutoCompactThreshold:           initialCfg.AutoCompactThreshold,
2354		JailByDefault:                  initialCfg.JailByDefault,
2355		QuickModelShortcuts:            quickModelShortcuts,
2356		RecursiveFileSuggest:           initialCfg.RecursiveFileSuggest,
2357		RespectGitignore:               initialCfg.RespectGitignore,
2358		CompactMode:                    initialCfg.CompactMode,
2359		TUIInputStyle:                  initialCfg.TUIInputStyle,
2360		TUIStatusPosition:              initialCfg.TUIStatusPosition,
2361		TUIWorkingPosition:             initialCfg.TUIWorkingPosition,
2362		TUISubagentPosition:            initialCfg.TUISubagentPosition,
2363		ThemeName:                      initialCfg.Theme,
2364		FlatTools:                      initialCfg.FlatToolRender(),
2365		CompactUser:                    initialCfg.CompactUserInput(),
2366		ExtensionThemes:                extMgr.ThemeOptions,
2367		AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(
2368			autoSubagentsToolAllowedForSession,
2369			autoSubagentsStopToolAllowedForSession,
2370			autoSubagentsResumeToolAllowedForSession,
2371		),
2372		OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,
2373		SubagentsSystemAddendum:         subagentsAddendum,
2374		SettingsStore:                   configSettingsStore{},
2375		Model:                           r.Model,
2376		Provider:                        r.Provider,
2377		AuthMethod:                      r.AuthMethod,
2378		BaseURL:                         r.BaseURL,
2379		Reasoning:                       r.Reasoning,
2380		OnReasoningChanged:              runtime.SetReasoning,
2381		SystemPrompt:                    r.SystemPrompt,
2382		Tools:                           r.ToolRegistry,
2383		MaxSteps:                        r.MaxSteps,
2384		CWD:                             r.CWD,
2385		StartupAgentName:                args.AgentName,
2386		StartupContextPaths:             instructionContextPaths(r.ContextFiles),
2387		StartupExtensionNames:           startupExtensionNames(extMgr.All()),
2388		StartupExtensionErrors:          startupExtensionErrors,
2389		StartupSkillNames:               startupSkillNames(startupSkills),
2390		ShowInstructionsAtStartup:       initialCfg.ShowInstructionsAtStartup,
2391		ZutHome:                         ZutHome(),
2392		SessionsRoot:                    agentSessionsRoot(ZutHome(), args),
2393		Version:                         version,
2394		UpdateInfoChan:                  updateCh,
2395		Sandbox:                         sharedSandbox,
2396		Agent:                           ag,
2397		InitialCompactHandoff:           currentCompactHandoff(),
2398		InitialSessionTitle: func() string {
2399			if sess == nil || sessionTitlePending {
2400				return ""
2401			}
2402			return sess.Title
2403		}(),
2404		InitialSessionTitlePending: sessionTitlePending,
2405		InitialInput:               args.Prompt,
2406		StartupPre:                 args.StartupPre,
2407		OnStartupPreDone: func() {
2408			// entry.pre often installs skills/extensions; rediscover them
2409			// before the user starts a model turn.
2410			current := liveInteractiveAgent(iv, ag)
2411			if extMgr != nil {
2412				_ = extMgr.Reload(context.Background(), 2*time.Second)
2413			}
2414			if current != nil {
2415				if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil {
2416					iv.ReportError(err)
2417				}
2418			}
2419		},
2420		RefreshTools: func() error {
2421			current := liveInteractiveAgent(iv, ag)
2422			webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2423			if err != nil {
2424				if !errors.Is(err, errInteractiveAgentChanged) {
2425					setWebSearchAvailable(false)
2426				}
2427				return err
2428			}
2429			setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2430			return nil
2431		},
2432		SetWebSearchAvailable: setWebSearchAvailable,
2433		RefreshPrompt: func() error {
2434			current := liveInteractiveAgent(iv, ag)
2435			webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2436			if err != nil {
2437				return err
2438			}
2439			setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2440			return nil
2441		},
2442		AuthManager:                mgr,
2443		LlamaCPPConfig:             ResolveLlamaCPPConfig,
2444		RefreshLlamaCPPModels:      RefreshLlamaCPPModels,
2445		BuildAgent:                 buildAgent,
2446		SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
2447		BuildAgentFor:              buildAgentFor,
2448		BuildAgentForRescue:        buildAgentForRescue,
2449		LoggedInProviders: func() []string {
2450			var out []string
2451			seen := map[string]bool{}
2452			for _, p := range knownProviders {
2453				if CredentialAvailable(p) && !seen[p] {
2454					out = append(out, p)
2455					seen[p] = true
2456				}
2457			}
2458			// Include custom providers that have credentials stored.
2459			for p := range provider.CustomProviders() {
2460				if CredentialAvailable(p) && !seen[p] {
2461					out = append(out, p)
2462					seen[p] = true
2463				}
2464			}
2465			// Ollama models are always available (no auth needed).
2466			if !seen["ollama"] {
2467				out = append(out, "ollama")
2468			}
2469			return out
2470		},
2471		LoadSession:           loadSession,
2472		ChangeCWD:             changeCWD,
2473		PersistCompactHandoff: persistCompactHandoff,
2474		CurrentCompactHandoff: currentCompactHandoff,
2475		PersistGoal:           persistGoal,
2476		CurrentGoal:           currentGoal,
2477		CurrentSessionPath: func() string {
2478			persistMu.Lock()
2479			defer persistMu.Unlock()
2480			if sess == nil {
2481				return ""
2482			}
2483			return sess.Path
2484		},
2485		FlushSession: func() {
2486			sessionTransitionMu.RLock()
2487			defer sessionTransitionMu.RUnlock()
2488			// Append any not-yet-persisted agent messages to the
2489			// current session file, then advance the baseline so
2490			// the final WriteNewTranscript at exit doesn't write
2491			// duplicates. Per-message persistence keeps the on-
2492			// disk file current already, so this is mostly a
2493			// defensive flush — still needed for /session export
2494			// to guarantee the exported bytes include the very
2495			// last in-flight turn.
2496			currentAg := iv.Agent()
2497			if currentAg == nil {
2498				return
2499			}
2500			persistMu.Lock()
2501			defer persistMu.Unlock()
2502			if sess == nil {
2503				return
2504			}
2505			next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2506			sessBaselineMsgs = next
2507			if flushErr != nil {
2508				iv.ReportError(fmt.Errorf("flush session: %w", flushErr))
2509				return
2510			}
2511		},
2512		SessionTransition: newSessionTransition(&sessionTransitionMu),
2513		Extensions:        extMgr,
2514		Supervisor:        subagentsMgr,
2515		ResolveSubagent:   resolveSubagent,
2516		ChangelogChan:     changelogCh,
2517		OnChangelogDismiss: func() {
2518			// For development builds, store the actual release version
2519			// so the same changelog doesn't show again next launch.
2520			// For real builds, store the binary version.
2521			v := version
2522			if devBuild {
2523				if iv != nil && iv.ChangelogVersion() != "" {
2524					v = iv.ChangelogVersion()
2525				}
2526			}
2527			_ = MarkChangelogShown(v)
2528		},
2529		SkillSnapshot: func() []*skills.Skill {
2530			if args.NoSkill {
2531				// --no-skill: nothing for the picker to show.
2532				return nil
2533			}
2534			// Re-discover so the picker reflects edits made during
2535			// the session. Cheap; SKILL.md files are small. Filter
2536			// out built-in skills — they're hidden from user-facing
2537			// surfaces because they're implementation detail; the
2538			// model still sees them through the system-prompt
2539			// manifest and the skill tool.
2540			userHome, _ := os.UserHomeDir()
2541			list, _ := skills.Discover(ZutHome(), r.CWD, userHome, args.WithSkills)
2542			list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills())
2543			return skills.VisibleSkills(list)
2544		},
2545		NoYolo:      args.NoYolo,
2546		ConfirmGate: confirmGate,
2547		PersistModel: newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, func(message string) {
2548			if iv != nil {
2549				iv.Notify("session", "error", message)
2550			}
2551		}),
2552		CurrentSessionTitle: func() string {
2553			sessionTransitionMu.RLock()
2554			defer sessionTransitionMu.RUnlock()
2555			persistMu.Lock()
2556			defer persistMu.Unlock()
2557			if sess == nil || sessionTitlePending {
2558				return ""
2559			}
2560			return sess.Title
2561		},
2562		CurrentSessionTitlePending: func() bool {
2563			sessionTransitionMu.RLock()
2564			defer sessionTransitionMu.RUnlock()
2565			persistMu.Lock()
2566			defer persistMu.Unlock()
2567			return sessionTitlePending
2568		},
2569		PersistTitle: func(title string) error {
2570			persistMu.Lock()
2571			defer persistMu.Unlock()
2572			if sess == nil {
2573				return nil
2574			}
2575			if err := sess.UpdateTitle(title); err != nil {
2576				return err
2577			}
2578			sessionTitlePending = false
2579			return nil
2580		},
2581		OnSessionTitleChanged: func(title string) {
2582			sessionTransitionMu.RLock()
2583			defer sessionTransitionMu.RUnlock()
2584			persistMu.Lock()
2585			defer persistMu.Unlock()
2586			if sess != nil {
2587				sess.Title = title
2588				sessionTitlePending = false
2589			}
2590		},
2591	})
2592	extHooks.attachInteractive(iv)
2593	go func() {
2594		<-reloadDone
2595		for _, reloadErr := range reloadErrs {
2596			if reloadErr != nil {
2597				iv.ReportError(fmt.Errorf("reload subagents: %w", reloadErr))
2598			}
2599		}
2600	}()
2601
2602	// Bind the interactive TUI as the Confirmer. We deferred this
2603	// until now because the gate is constructed before the TUI
2604	// (the BeforeToolExecute closure captures it). SetConfirmer
2605	// is mutex-guarded on the gate so this is safe.
2606	if confirmGate != nil {
2607		confirmGate.SetConfirmer(&confirmationEventConfirmer{
2608			inner: iv,
2609			emit:  extMgr.EmitEvent,
2610		})
2611	}
2612
2613	// Signal-driven flush: a SIGTERM / SIGHUP to the zut process
2614	// (closed terminal window, system shutdown, kill) used to lose
2615	// the entire in-memory transcript because the deferred post-Run
2616	// flush below never ran. Per-message persistence above covers
2617	// most of it; this handler writes any in-flight remainder and
2618	// then exits the process so we don't double-paint over a
2619	// broken terminal that the TUI's restore deferreds can no
2620	// longer fix from a signal context.
2621	//
2622	// SIGINT is intentionally NOT handled here — the TUI consumes
2623	// Ctrl+C as a regular key event for cancel/clear semantics, and
2624	// installing a SIGINT notifier here would swallow it.
2625	sigCh := make(chan os.Signal, 1)
2626	signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
2627	defer signal.Stop(sigCh)
2628	go func() {
2629		_, ok := <-sigCh
2630		if !ok {
2631			return
2632		}
2633		finalAg := iv.Agent()
2634		if finalAg != nil {
2635			persistMu.Lock()
2636			if sess != nil {
2637				next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2638				sessBaselineMsgs = next
2639				if flushErr != nil {
2640					fmt.Fprintf(os.Stderr, "flush session: %v\n", flushErr)
2641				}
2642				if closeErr := sess.Close(); closeErr != nil {
2643					fmt.Fprintf(os.Stderr, "close session: %v\n", closeErr)
2644				}
2645				sess = nil
2646			}
2647			persistMu.Unlock()
2648		}
2649		closeAgentLSP(finalAg)
2650		closeResolvedLSP(r)
2651		// Stop supervised workers before exiting. os.Exit skips deferred
2652		// cleanup, so wait for reload and close the shared runtime explicitly.
2653		<-reloadDone
2654		_ = closeSubagentRuntimeFresh(runtime)
2655		// Exit cleanly. Re-raising the signal would skip os.Exit's
2656		// at-exit hooks; explicit exit is fine because we've already
2657		// flushed the only at-risk state (the session file) and stopped
2658		// supervised workers.
2659		os.Exit(0)
2660	}()
2661
2662	runErr = iv.Run(ctx)
2663
2664	// Flush final transcript to session (only if we had / ended up with an agent).
2665	if finalAg := iv.Agent(); finalAg != nil {
2666		persistMu.Lock()
2667		if sess != nil {
2668			next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2669			sessBaselineMsgs = next
2670			if flushErr != nil {
2671				runErr = errors.Join(runErr, fmt.Errorf("flush session: %w", flushErr))
2672			}
2673		}
2674		persistMu.Unlock()
2675	}
2676	return runErr
2677}
2678
2679func resumableSessionID(root string, sess *core.Session) string {
2680	if sess == nil || sess.ID == "" || sess.Path == "" {
2681		return ""
2682	}
2683	path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID)
2684	if err != nil || !sameSessionPath(path, sess.Path) {
2685		return ""
2686	}
2687	snapshot, err := core.ReadSessionSnapshot(path)
2688	if err != nil || len(snapshot.Messages) == 0 {
2689		return ""
2690	}
2691	return sess.ID
2692}
2693
2694func sameSessionPath(left, right string) bool {
2695	leftAbs, leftErr := filepath.Abs(left)
2696	rightAbs, rightErr := filepath.Abs(right)
2697	if leftErr == nil && rightErr == nil {
2698		return filepath.Clean(leftAbs) == filepath.Clean(rightAbs)
2699	}
2700	return filepath.Clean(left) == filepath.Clean(right)
2701}
2702
2703func resumeSessionHint(id string) string {
2704	return fmt.Sprintf("Resume this session with: zut --resume %s\n", id)
2705}
2706
2707func agentSessionsRoot(root string, args Args) string {
2708	if args.AgentName == "" {
2709		return root
2710	}
2711	return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName))
2712}
2713
2714// openOrCreateSession returns a session for the run. sess may be nil
2715// with a nil error if session persistence is disabled.
2716func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
2717	if args.NoSess {
2718		return nil, nil
2719	}
2720	// Sweep meta-only files left over from older zut versions (and from
2721	// any session that crashed before its first AppendMessage) for the
2722	// cwd-scoped picker paths. Explicit UUID lookup stays strict so it
2723	// can report metadata failures instead of turning them into misses.
2724	sessionsRoot := agentSessionsRoot(ZutHome(), args)
2725	if args.ResumeSessionID == "" {
2726		core.PruneEmptySessions(sessionsRoot, args.CWD)
2727	}
2728	var (
2729		s    *core.Session
2730		msgs []provider.Message
2731		err  error
2732	)
2733	switch {
2734	case args.Session != "":
2735		s, msgs, err = core.OpenSession(args.Session)
2736		// The subagent-worker child passes a fixed --session path that
2737		// may not exist yet on first Spawn. Treat ENOENT as "create
2738		// a fresh session AT THIS PATH" so the conversation actually
2739		// gets persisted; without this fallback the subagent child runs
2740		// with sess==nil and every Resume re-starts with no memory
2741		// of the prior turns. Other openers (--continue / --resume /
2742		// the picker) never see ENOENT here because they only choose
2743		// paths that already exist on disk.
2744		if err != nil && errors.Is(err, os.ErrNotExist) {
2745			s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
2746			msgs = nil
2747		}
2748	case args.Continue:
2749		latest := core.LatestSession(sessionsRoot, args.CWD)
2750		if latest != "" {
2751			s, msgs, err = core.OpenSession(latest)
2752		}
2753	case args.Resume:
2754		if args.ResumeSessionID != "" {
2755			picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID)
2756			if lookupErr != nil {
2757				return nil, lookupErr
2758			}
2759			if picked == "" {
2760				return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)
2761			}
2762			s, msgs, err = core.OpenSession(picked)
2763			break
2764		}
2765		picked, perr := pickSession(sessionsRoot, args.CWD)
2766		if perr != nil {
2767			return nil, perr
2768		}
2769		if picked != "" {
2770			s, msgs, err = core.OpenSession(picked)
2771		}
2772	}
2773	if err != nil {
2774		return nil, err
2775	}
2776	if s != nil {
2777		ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2778		ag.SetMessages(msgs)
2779		if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
2780			ag.SeedCost(cum)
2781			ag.SeedLastTurnUsage(last)
2782		}
2783		return s, nil
2784	}
2785	s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2786	if err == nil {
2787		ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2788	}
2789	return s, err
2790}
2791
2792func pickSession(root, cwd string) (string, error) {
2793	files := core.ListSessions(root, cwd)
2794	if len(files) == 0 {
2795		fmt.Fprintln(os.Stderr, "no sessions for", cwd)
2796		return "", nil
2797	}
2798	for i, f := range files {
2799		fmt.Fprintf(os.Stderr, "  %2d) %s\n", i+1, f)
2800	}
2801	fmt.Fprint(os.Stderr, "pick #: ")
2802	rd := bufio.NewReader(os.Stdin)
2803	line, _ := rd.ReadString('\n')
2804	line = strings.TrimSpace(line)
2805	var n int
2806	if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
2807		return "", fmt.Errorf("invalid selection")
2808	}
2809	return files[n-1], nil
2810}
2811
2812// WriteNewTranscript appends only messages after index `from` from the
2813// agent's transcript to the session. Used by callers that don't hold
2814// the persistMu (non-interactive print/json modes which run a single
2815// turn under their own goroutine).
2816func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) error {
2817	_, err := writeNewTranscriptLocked(ag, sess, from)
2818	return err
2819}
2820
2821func wireRetryLifecyclePersistence(ag *core.Agent, sess *core.Session) {
2822	if ag == nil {
2823		return
2824	}
2825	if sess == nil {
2826		ag.OnRetryLifecycle = nil
2827		return
2828	}
2829	ag.OnRetryLifecycle = sess.RecordRetryLifecycle
2830}
2831
2832// writeNewTranscriptLocked is the same as WriteNewTranscript. The
2833// suffix marks that interactive callers must hold persistMu when
2834// invoking it so concurrent appends from the agent loop don't race
2835// with this catch-up flush.
2836func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (next int, err error) {
2837	next = from
2838	if sess == nil || ag == nil {
2839		return next, nil
2840	}
2841	msgs := ag.Messages()
2842	for i := from; i < len(msgs); i++ {
2843		if err := sess.AppendMessage(msgs[i]); err != nil {
2844			return next, fmt.Errorf("append transcript message %d: %w", i, err)
2845		}
2846		next = i + 1
2847	}
2848	cum := ag.Cost()
2849	if err := sess.AppendUsage(cum, cum); err != nil {
2850		return next, fmt.Errorf("append transcript usage: %w", err)
2851	}
2852	if err := sess.Flush(); err != nil {
2853		return next, fmt.Errorf("flush transcript: %w", err)
2854	}
2855	return next, nil
2856}
2857
2858func readAllStdin() (string, error) {
2859	fi, err := os.Stdin.Stat()
2860	if err != nil {
2861		return "", err
2862	}
2863	if (fi.Mode() & os.ModeCharDevice) != 0 {
2864		return "", nil
2865	}
2866	return readAllStdinFrom(os.Stdin)
2867}
2868
2869func readAllStdinFrom(r io.Reader) (string, error) {
2870	b, err := io.ReadAll(r)
2871	return string(b), err
2872}
2873
2874func printModels() {
2875	models := provider.Active()
2876
2877	// Compute column widths from actual data so wide providers (e.g.
2878	// xiaomi-token-plan-sgp) and long bedrock model ids don't force the
2879	// `name` column off-screen. Floors mirror the historical layout so
2880	// short catalogs look the same as before.
2881	provW, idW, srcW := len("provider"), len("model id"), len("source")
2882	for _, m := range models {
2883		if w := len(m.Provider); w > provW {
2884			provW = w
2885		}
2886		if w := len(m.ID); w > idW {
2887			idW = w
2888		}
2889		source := m.Source
2890		if source == "" {
2891			source = "catalog"
2892		}
2893		if m.Speculative {
2894			source = "speculative"
2895		}
2896		if w := len(source); w > srcW {
2897			srcW = w
2898		}
2899	}
2900
2901	header := fmt.Sprintf("%-*s  %-*s  %8s  %8s  %s  %-*s  %s",
2902		provW, "provider",
2903		idW, "model id",
2904		"context", "max-out", "reasoning",
2905		srcW, "source",
2906		"name")
2907	fmt.Println(header)
2908
2909	for _, m := range models {
2910		reason := " "
2911		if m.Reasoning {
2912			reason = "✓"
2913		}
2914		source := m.Source
2915		if source == "" {
2916			source = "catalog"
2917		}
2918		if m.Speculative {
2919			source = "speculative"
2920		}
2921		fmt.Printf("%-*s  %-*s  %8d  %8d     %s      %-*s  %s\n",
2922			provW, m.Provider,
2923			idW, m.ID,
2924			m.ContextWindow, m.MaxOutput,
2925			reason,
2926			srcW, source,
2927			m.DisplayName)
2928	}
2929}

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