detached 4509ec7

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

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 477

↑↓ moveenter openesc close