v0.38.0 58133ea

cli.go RawHistoryFolder

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

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