v0.42.0 82578de

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

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