v0.25.1 025a25b

cli.go RawHistoryFolder

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

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