v0.9.1 406ddf4

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

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