v0.12.0 0c5f8d5

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

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