v0.15.0 6b178f8

cli.go RawHistoryFolder

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

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