v0.27.1 131edba

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close