v0.19.0 d7aa01a

cli.go RawHistoryFolder

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

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