v0.17.0 949637b

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

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