v0.20.0 29be61c

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

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