v0.11.4 020512d

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	onResumedSupervisor := func(a *subagents.Agent, prompt string) {
1458		if iv != nil {
1459			iv.TrackResumedSubagentWorker(a, prompt)
1460		}
1461	}
1462	onStopRequestedSupervisor := func(a *subagents.Agent) {
1463		if iv != nil {
1464			iv.TrackStoppedSubagentWorker(a)
1465		}
1466	}
1467	resolveSubagent := func(name string) (*subagents.Profile, error) {
1468		return findSubagentProfile(r.CWD, name)
1469	}
1470
1471	// Register the canonical subagent tools whenever launch-time policy permits
1472	// them. The auto-subagents setting controls strict orchestration guidance,
1473	// not explicit user-requested delegation.
1474	injectSubagentTools := func(reg core.Registry) core.Registry {
1475		if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) {
1476			return reg
1477		}
1478		if autoSubagentsToolAllowed(args) {
1479			canonical := &tools.SubagentSpawnTool{
1480				Supervisor: subagentsMgr,
1481				Enabled:    func() bool { return true },
1482				DefaultModel: func() string {
1483					persistMu.Lock()
1484					defer persistMu.Unlock()
1485					return activeModel
1486				},
1487				DefaultProvider: func() string {
1488					persistMu.Lock()
1489					defer persistMu.Unlock()
1490					return activeProvider
1491				},
1492				DefaultReasoning: func() string { return r.Reasoning },
1493				ResolveSubagent:  resolveSubagent,
1494				OnSpawned:        onSpawnedSupervisor,
1495			}
1496			reg[canonical.Name()] = canonical
1497		}
1498		if autoSubagentsStatusToolAllowed(args) {
1499			statusTool := &tools.SubagentStatusTool{
1500				Supervisor: subagentsMgr,
1501				Enabled:    func() bool { return true },
1502			}
1503			reg[statusTool.Name()] = statusTool
1504		}
1505		if autoSubagentsStopToolAllowed(args) {
1506			stopTool := &tools.SubagentStopTool{
1507				Supervisor:      subagentsMgr,
1508				Enabled:         func() bool { return true },
1509				OnStopRequested: onStopRequestedSupervisor,
1510			}
1511			reg[stopTool.Name()] = stopTool
1512		}
1513		if autoSubagentsResumeToolAllowed(args) {
1514			resumeTool := &tools.SubagentResumeTool{
1515				Supervisor: subagentsMgr,
1516				Enabled:    func() bool { return true },
1517				OnResumed:  onResumedSupervisor,
1518			}
1519			reg[resumeTool.Name()] = resumeTool
1520		}
1521		return reg
1522	}
1523	prepareInteractiveRegistry := func(reg core.Registry) core.Registry {
1524		reg = injectSubagentTools(reg)
1525		return webSearchGuard.wrapRegistry(reg)
1526	}
1527	prepareResolvedInteractiveRegistry := func(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
1528		// The resolved policy is a ceiling. In particular, an extension named
1529		// web_search must not reintroduce the capability when --no-tools,
1530		// --tools, a packaged PermissionSet, or persisted settings deny it.
1531		if policy != subagents.WebSearchAllow {
1532			delete(reg, "web_search")
1533		}
1534		return prepareInteractiveRegistry(reg)
1535	}
1536	prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
1537	initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
1538	webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
1539	if subagentsMgr != nil {
1540		subagentsMgr.SetWebSearchPolicy(initialWebSearchPolicy)
1541	}
1542
1543	// Confirmation gate: when --no-yolo is on, the agent must ask
1544	// the user before every tool call. In interactive mode the TUI
1545	// provides the Confirmer; in print/json/rpc modes there's no
1546	// way to prompt, so the gate is constructed with a nil inner
1547	// which auto-refuses every call with a helpful reason.
1548	var confirmGate *core.ConfirmGate
1549	if args.NoYolo {
1550		confirmGate = core.NewConfirmGate(nil) // set below for interactive
1551	}
1552
1553	// Capture current args in a closure so BuildAgent can re-resolve
1554	// after a successful login (picks up the newly stored credential).
1555	wireAgentExt := func(a *core.Agent) *core.Agent {
1556		if a == nil {
1557			return a
1558		}
1559		a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
1560			// Guards run before confirmation because they may rewrite the
1561			// arguments. The user must approve the effective call that will
1562			// actually execute, not the model's original arguments.
1563			r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
1564			if r.Block {
1565				return false, r.Reason, nil
1566			}
1567			effectiveArgs := call.Arguments
1568			if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
1569				effectiveArgs = r.ModifiedArgs
1570			}
1571			if confirmGate != nil {
1572				var content strings.Builder
1573				_, currentTools := a.PromptConfig()
1574				if tool, err := currentTools.Get(call.Name); err == nil {
1575					if previewer, ok := tool.(core.ToolPreviewer); ok {
1576						preview, err := previewer.Preview(ctx, effectiveArgs)
1577						if err != nil {
1578							return false, err.Error(), nil
1579						}
1580						for _, block := range preview.Content {
1581							if text, ok := block.(provider.TextBlock); ok {
1582								if content.Len() > 0 {
1583									content.WriteString("\n")
1584								}
1585								content.WriteString(text.Text)
1586							}
1587						}
1588					}
1589				}
1590				ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
1591					ID:      call.ID,
1592					Name:    call.Name,
1593					Summary: core.BuildPreview(effectiveArgs, 120),
1594					Content: content.String(),
1595					Origin:  call.Origin,
1596				})
1597				if !ok {
1598					return false, reason, nil
1599				}
1600			}
1601			return true, "", r.ModifiedArgs
1602		}
1603		a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1604			r := extMgr.InterceptTurnStart(turnCtx, step)
1605			return !r.Block, r.Reason, r.Context
1606		}
1607		a.BeforeAssistantMessage = func(text string) (bool, string, string) {
1608			r := extMgr.InterceptAssistantMessage(ctx, text)
1609			if r.Block {
1610				return false, r.Reason, ""
1611			}
1612			return true, "", r.ReplaceText
1613		}
1614		a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
1615		return a
1616	}
1617
1618	var ag *core.Agent
1619	buildAgent := func() (*core.Agent, string, string, error) {
1620		resolved, err := Resolve(args, true)
1621		if err != nil {
1622			return nil, "", "", err
1623		}
1624		resolved.UseSandbox(sharedSandbox)
1625		if subagentsMgr != nil {
1626			subagentsMgr.SetProvider(resolved.Provider)
1627			subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1628		}
1629		resolved.MergeExtensionTools(extToolAdapter)
1630		prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1631		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1632	}
1633
1634	// Rebuild agent with an explicit provider/model override.
1635	buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1636		next := args
1637		if providerOverride != "" {
1638			next.Provider = providerOverride
1639		}
1640		if modelOverride != "" {
1641			next.Model = modelOverride
1642		}
1643		resolved, err := Resolve(next, true)
1644		if err != nil {
1645			return nil, "", "", err
1646		}
1647		resolved.UseSandbox(sharedSandbox)
1648		if subagentsMgr != nil {
1649			subagentsMgr.SetProvider(resolved.Provider)
1650			subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1651		}
1652		resolved.MergeExtensionTools(extToolAdapter)
1653		prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1654		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1655	}
1656
1657	// Rebuild agent for the rescue picker after a recoverable failure.
1658	// Unlike buildAgentFor, this drops launch-time --api-key and
1659	// --base-url overrides because those are typically the cause of the
1660	// rescue (a bad key, a typo'd base URL, or a corporate gateway that
1661	// only the originally-picked provider needed). Re-resolving without
1662	// them lets the rescue retry use env vars / auth.json / provider
1663	// defaults the way zut would have without the overrides.
1664	buildAgentForRescue := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1665		next := args
1666		next.APIKey = ""
1667		next.BaseURL = ""
1668		if providerOverride != "" {
1669			next.Provider = providerOverride
1670		}
1671		if modelOverride != "" {
1672			next.Model = modelOverride
1673		}
1674		resolved, err := Resolve(next, true)
1675		if err != nil {
1676			return nil, "", "", err
1677		}
1678		resolved.UseSandbox(sharedSandbox)
1679		if subagentsMgr != nil {
1680			subagentsMgr.SetProvider(resolved.Provider)
1681			subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1682		}
1683		resolved.MergeExtensionTools(extToolAdapter)
1684		prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1685		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1686	}
1687
1688	if r.HasCredential() {
1689		ag = wireAgentExt(r.NewAgent())
1690	}
1691
1692	// /reload-ext callback: after the manager has respawned every
1693	// extension, re-resolve the tool registry (built-ins + freshly-
1694	// registered extension tools) and swap it onto the current
1695	// agent in-place. The current agent may have been replaced by a
1696	// /model swap since spawn, so re-read the live `ag` on each
1697	// invocation.
1698	extMgr.SetOnReload(func() {
1699		current := liveInteractiveAgent(iv, ag)
1700		if current == nil {
1701			return
1702		}
1703		if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil && iv != nil {
1704			iv.ReportError(err)
1705		}
1706	})
1707
1708	var sess *core.Session
1709	var sessBaselineMsgs int // messages already on disk when current session opened
1710	var sessionTitlePending bool
1711	if !args.NoSess && ag != nil {
1712		var sessErr error
1713		sess, sessErr = openOrCreateSession(args, r, ag, version)
1714		if sessErr != nil {
1715			return sessErr
1716		}
1717		sessBaselineMsgs = len(ag.Messages())
1718		if sess != nil {
1719			// Startup resume uses the same provider/model-aware preparation as
1720			// the interactive session picker. This matters for --continue,
1721			// --resume, and explicit --session paths whose latest meta row was
1722			// written by another provider/model.
1723			candidate, resumeErr := applySessionResume(sess, ag, r.Provider, r.Model,
1724				strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", buildAgentFor)
1725			if resumeErr != nil {
1726				return resumeErr
1727			}
1728			sess = candidate.session
1729			ag = candidate.agent
1730			sessBaselineMsgs = candidate.fullMessageCount
1731			activeProvider = candidate.provider
1732			activeModel = candidate.model
1733			r.Provider = candidate.provider
1734			r.Model = candidate.model
1735		}
1736		if sess != nil && ag != nil {
1737			sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
1738		}
1739	}
1740	announceSession(extMgr, sess)
1741	defer func() {
1742		persistMu.Lock()
1743		defer persistMu.Unlock()
1744		if sess != nil {
1745			sess.Close()
1746		}
1747	}()
1748
1749	// persistMessage is the per-message hook bound to the agent. It
1750	// appends each new transcript message to the live session as soon
1751	// as it lands, so a kill / closed terminal / OS crash costs at
1752	// most the in-flight turn instead of the whole session. The
1753	// baseline counter advances in lock-step so the exit-time flush
1754	// doesn't double-write rows already on disk.
1755	persistMessage := func(m provider.Message) {
1756		sessionTransitionMu.RLock()
1757		defer sessionTransitionMu.RUnlock()
1758		persistMu.Lock()
1759		defer persistMu.Unlock()
1760		if sess == nil {
1761			return
1762		}
1763		if err := sess.AppendMessage(m); err == nil {
1764			sessBaselineMsgs++
1765		}
1766	}
1767	persistUsage := func(cum provider.Usage) {
1768		sessionTransitionMu.RLock()
1769		defer sessionTransitionMu.RUnlock()
1770		persistMu.Lock()
1771		defer persistMu.Unlock()
1772		if sess == nil {
1773			return
1774		}
1775		_ = sess.AppendUsage(cum, cum)
1776	}
1777	persistCompaction := func(messages []provider.Message) {
1778		sessionTransitionMu.RLock()
1779		var session *extproto.SessionContext
1780		var states map[string]json.RawMessage
1781		persistMu.Lock()
1782		if sess != nil {
1783			if err := sess.AppendCompaction(messages); err == nil {
1784				sessBaselineMsgs = len(messages)
1785				session = sessionContext(sess)
1786				states = copyExtensionStates(sess.ExtensionState)
1787			}
1788		}
1789		persistMu.Unlock()
1790		sessionTransitionMu.RUnlock()
1791		if session != nil {
1792			extMgr.EmitSessionEvent("session_compacted", session, states)
1793		}
1794	}
1795	persistCompactHandoff := func(state json.RawMessage) error {
1796		sessionTransitionMu.RLock()
1797		defer sessionTransitionMu.RUnlock()
1798		persistMu.Lock()
1799		defer persistMu.Unlock()
1800		if sess == nil {
1801			return nil
1802		}
1803		return sess.UpdateCompactHandoff(state)
1804	}
1805	currentCompactHandoff := func() json.RawMessage {
1806		persistMu.Lock()
1807		defer persistMu.Unlock()
1808		if sess == nil {
1809			return nil
1810		}
1811		return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
1812	}
1813	persistToolResult := func(_ string, result core.ToolResult) {
1814		persistMu.Lock()
1815		defer persistMu.Unlock()
1816		persistExtensionToolResult(extMgr, sess, result)
1817	}
1818	wireAgentPersist := func(a *core.Agent) *core.Agent {
1819		if a == nil {
1820			return a
1821		}
1822		a.OnMessageAppended = persistMessage
1823		a.OnUsage = persistUsage
1824		a.OnTranscriptCompacted = persistCompaction
1825		a.OnToolResult = persistToolResult
1826		return a
1827	}
1828	wireAgentPersist(ag)
1829	defer func() {
1830		closeAgentLSP(liveInteractiveAgent(iv, ag))
1831		closeResolvedLSP(r)
1832	}()
1833
1834	// Re-wrap the build closures so any agent constructed by the TUI
1835	// (login, /model swap to a different provider) also gets the
1836	// persistence hooks. Without this, switching provider would
1837	// silently revert to the old in-memory-only behaviour. Selection state
1838	// is published by the successful transition itself, not by preparation.
1839	baseBuildAgent := buildAgent
1840	buildAgent = func() (*core.Agent, string, string, error) {
1841		a, p, m, err := baseBuildAgent()
1842		return wireAgentPersist(a), p, m, err
1843	}
1844	baseBuildAgentFor := buildAgentFor
1845	buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1846		a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
1847		return wireAgentPersist(a), p, m, err
1848	}
1849	baseBuildAgentForRescue := buildAgentForRescue
1850	buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1851		a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
1852		return wireAgentPersist(a), p, m, err
1853	}
1854
1855	// loadSession replaces the current session with the one at path and
1856	// hands its messages to the agent. Used by the /sessions picker.
1857	loadSession := func(path string) error {
1858		// Hold the transition lock from the pre-flush through the commit.
1859		// Persistence callbacks take the read side, so an active session
1860		// cannot be snapshotted before its lazy writes land or overwritten by
1861		// a callback while the candidate is being installed.
1862		sessionTransitionMu.Lock()
1863		defer sessionTransitionMu.Unlock()
1864
1865		currentAg := liveInteractiveAgent(iv, ag)
1866		if currentAg == nil {
1867			return fmt.Errorf("no agent running; log in first")
1868		}
1869
1870		persistMu.Lock()
1871		currentProvider := activeProvider
1872		currentModel := activeModel
1873		oldSess := sess
1874		// Flush before reading the candidate. Use the locked form rather
1875		// than the public callback, which would acquire the read lock held
1876		// by this transition.
1877		if oldSess != nil {
1878			writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1879		}
1880		currentMessageCount := len(currentAg.Messages())
1881		currentCost := currentAg.Cost()
1882		persistMu.Unlock()
1883
1884		// A candidate build must not publish its provider/model as the live
1885		// selection before the candidate commits.
1886		candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1887		if err != nil {
1888			// prepareSessionResume closes its append handle on failure;
1889			// no live agent or session has been changed.
1890			return err
1891		}
1892		committed := false
1893		defer func() {
1894			if !committed {
1895				_ = candidate.session.Close()
1896			}
1897		}()
1898
1899		// Wire the private candidate before it can become visible. This is
1900		// also required for legacy same-provider resumes, where the current
1901		// agent is reused and only its transcript/usage are refreshed.
1902		wireAgentPersist(candidate.agent)
1903
1904		persistMu.Lock()
1905		// A provider rebuild ran outside persistMu. Refuse to install it if
1906		// the live agent, session, selection, transcript, or cost changed in
1907		// the meantime; otherwise a slow resume could discard a newer turn or
1908		// model swap.
1909		live := liveInteractiveAgent(iv, ag)
1910		changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg
1911		if !changed && live != nil {
1912			changed = len(live.Messages()) != currentMessageCount || live.Cost() != currentCost
1913		}
1914		if changed {
1915			persistMu.Unlock()
1916			return fmt.Errorf("session changed while loading; please try again")
1917		}
1918		newStates := copyExtensionStates(candidate.session.ExtensionState)
1919		if oldSess != nil {
1920			_ = oldSess.Close()
1921		}
1922		sess = candidate.session
1923		sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
1924		// The live agent only receives a compact resume window, but the
1925		// session file remains intact. Keep the persistence baseline at
1926		// the original on-disk message count so future turns append after
1927		// the full session instead of duplicating the hydrated tail.
1928		sessBaselineMsgs = candidate.fullMessageCount
1929
1930		if candidate.rebuilt {
1931			// Commit the TUI agent while persistMu still protects the session
1932			// writer, so no new message can land on the old file between the
1933			// two state changes.
1934			if iv != nil {
1935				iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)
1936			}
1937			ag = candidate.agent
1938		} else {
1939			currentAg.SetMessages(candidate.messages)
1940			currentAg.SeedCost(candidate.cumulative)
1941			currentAg.SeedLastTurnUsage(candidate.lastTurn)
1942			ag = currentAg
1943		}
1944		activeProvider = candidate.provider
1945		activeModel = candidate.model
1946		if subagentsMgr != nil && candidate.session != nil {
1947			// Keep the dashboard scope in the same commit as the session,
1948			// agent, usage, and persistence baseline.
1949			if iv != nil {
1950				iv.SetSubagentSessionScope(candidate.session.ID)
1951			} else {
1952				subagentsMgr.SetActiveSession(candidate.session.ID)
1953			}
1954		}
1955		committed = true
1956		persistMu.Unlock()
1957		if candidate.session.Meta.Parent != "" {
1958			extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
1959		}
1960		extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
1961		return nil
1962	}
1963
1964	// changeCWD switches the running session to a new working directory.
1965	// Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
1966	// /cd slash command (which itself is only fired by the workspaces
1967	// extension's panel-key Enter handler today; the user can type /cd
1968	// directly but it's not in autocomplete / help / the README).
1969	//
1970	// Steps, in order:
1971	//   1. resolve + validate the new path (~ expansion, abs/rel)
1972	//   2. stage the candidate cwd, sandbox, and subagent state
1973	//   3. rebuild the agent and allocate its new session
1974	//   4. commit all new state together, closing the old session last
1975	//   5. push the committed state into the running Interactive
1976	//
1977	// The /jail state is preserved verbatim: if the sandbox was locked
1978	// to the old cwd, it stays locked, just re-pointed at the new one.
1979	changeCWD := func(path string) error {
1980		sessionTransitionMu.Lock()
1981		defer sessionTransitionMu.Unlock()
1982		if path == "" {
1983			return fmt.Errorf("empty path")
1984		}
1985		// ~ expansion.
1986		if path == "~" || strings.HasPrefix(path, "~/") {
1987			home, herr := os.UserHomeDir()
1988			if herr != nil || home == "" {
1989				return fmt.Errorf("cannot expand ~: %v", herr)
1990			}
1991			if path == "~" {
1992				path = home
1993			} else {
1994				path = filepath.Join(home, path[2:])
1995			}
1996		}
1997		if !filepath.IsAbs(path) {
1998			path = filepath.Join(args.CWD, path)
1999		}
2000		absPath, err := filepath.Abs(path)
2001		if err != nil {
2002			return err
2003		}
2004		info, err := os.Stat(absPath)
2005		if err != nil {
2006			return err
2007		}
2008		if !info.IsDir() {
2009			return fmt.Errorf("not a directory: %s", absPath)
2010		}
2011
2012		currentAg := liveInteractiveAgent(iv, ag)
2013		if currentAg == nil {
2014			return fmt.Errorf("no agent running; log in first")
2015		}
2016
2017		// Stage the candidate state while retaining a complete rollback
2018		// snapshot. A failed rebuild or session allocation must leave the
2019		// current agent, session, sandbox, and subagent root usable.
2020		oldCWD := args.CWD
2021		oldResolvedCWD := r.CWD
2022		oldPermissionSet := args.PermissionSet
2023		persistMu.Lock()
2024		oldActiveProvider := activeProvider
2025		oldActiveModel := activeModel
2026		persistMu.Unlock()
2027		oldSupervisorRoot := ""
2028		if subagentsMgr != nil {
2029			oldSupervisorRoot = subagentsMgr.RepoRoot()
2030		}
2031		oldSandboxRoot := ""
2032		oldSandboxLocked := false
2033		if sharedSandbox != nil {
2034			oldSandboxRoot = sharedSandbox.Root
2035			oldSandboxLocked = sharedSandbox.Locked()
2036		}
2037		rollback := func() {
2038			args.CWD = oldCWD
2039			r.CWD = oldResolvedCWD
2040			args.PermissionSet = oldPermissionSet
2041			persistMu.Lock()
2042			activeProvider = oldActiveProvider
2043			activeModel = oldActiveModel
2044			persistMu.Unlock()
2045			if subagentsMgr != nil {
2046				subagentsMgr.SetRepoRoot(oldSupervisorRoot)
2047			}
2048			if sharedSandbox != nil {
2049				sharedSandbox.Root = oldSandboxRoot
2050				if oldSandboxLocked {
2051					sharedSandbox.Lock()
2052				} else {
2053					sharedSandbox.Unlock()
2054				}
2055			}
2056		}
2057
2058		args.CWD = absPath
2059		r.CWD = absPath
2060		if subagentsMgr != nil {
2061			subagentsMgr.SetRepoRoot(absPath)
2062		}
2063		if args.PermissionSet != nil {
2064			expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
2065			args.PermissionSet = &expanded
2066		}
2067		if sharedSandbox != nil {
2068			sharedSandbox.Root = absPath
2069			if oldSandboxLocked {
2070				sharedSandbox.Lock()
2071			} else {
2072				sharedSandbox.Unlock()
2073			}
2074		}
2075
2076		// Rebuild the agent so tools / AGENTS.md / system prompt
2077		// re-bind to the new cwd. buildAgent() reads from args + r.
2078		newAg, newProvider, newModel, berr := buildAgent()
2079		if berr != nil {
2080			rollback()
2081			return fmt.Errorf("rebuild agent: %w", berr)
2082		}
2083
2084		// Fresh session in the new cwd's bucket. We bypass
2085		// openOrCreateSession's --continue / --resume branches
2086		// because /cd's semantics are "start fresh here", matching
2087		// what relaunching `zut --cwd <path>` would do today.
2088		var newSess *core.Session
2089		if !args.NoSess {
2090			newRoot := agentSessionsRoot(ZutHome(), args)
2091			core.PruneEmptySessions(newRoot, absPath)
2092			var serr error
2093			newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
2094			if serr != nil {
2095				rollback()
2096				return fmt.Errorf("open session in %s: %w", absPath, serr)
2097			}
2098		}
2099
2100		// Invalidate any title request before swapping the host-owned session
2101		// pointer. The interactive state reset below happens after the swap,
2102		// so this guard closes the window where a late result could otherwise
2103		// be written to the new session.
2104		if iv != nil {
2105			iv.CancelPendingSessionTitle()
2106		}
2107
2108		// Commit only after every fallible operation has succeeded. Close
2109		// the old session last so rollback never has to resurrect it.
2110		var newStates map[string]json.RawMessage
2111		persistMu.Lock()
2112		if sess != nil {
2113			writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2114			_ = sess.Close()
2115		}
2116		sess = newSess
2117		if newSess != nil {
2118			newStates = copyExtensionStates(newSess.ExtensionState)
2119		}
2120		sessBaselineMsgs = 0
2121		sessionTitlePending = false
2122		activeProvider = newProvider
2123		activeModel = newModel
2124		persistMu.Unlock()
2125		ag = newAg
2126		closeAgentLSP(currentAg)
2127
2128		// Push the new state into the running Interactive.
2129		if iv != nil {
2130			startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
2131			iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
2132		}
2133
2134		// Re-scope the subagent dashboard to the new session.
2135		if subagentsMgr != nil && newSess != nil {
2136			if iv != nil {
2137				iv.SetSubagentSessionScope(newSess.ID)
2138			} else {
2139				subagentsMgr.SetActiveSession(newSess.ID)
2140			}
2141		}
2142		if newSess != nil {
2143			extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
2144		}
2145		return nil
2146	}
2147
2148	devBuild := isDevVersion(version)
2149	if devBuild {
2150		fmt.Fprintln(os.Stderr, "zut:", devVersionNotice)
2151	}
2152	term := tui.NewProcTerm()
2153
2154	var updateCh chan modes.UpdateInfo
2155	var changelogCh chan modes.ChangelogPayload
2156	if !devBuild {
2157		// Kick off the async update check so the banner can appear when the
2158		// http response eventually arrives (usually <1s on cached DNS). Map
2159		// agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
2160		updateCh = make(chan modes.UpdateInfo, 1)
2161		go func() {
2162			defer close(updateCh)
2163			src := <-CheckForUpdateAsync(ZutHome(), version)
2164			updateCh <- modes.UpdateInfo{
2165				Current:   src.Current,
2166				Latest:    src.Latest,
2167				Available: src.Available,
2168				URL:       src.URL,
2169			}
2170		}()
2171
2172		// Changelog: when the running version differs from the last
2173		// version whose release notes the user dismissed, fetch the
2174		// release body from GitHub and have the TUI show it once. On
2175		// first-ever launch (no prior LastChangelogShown), seed the
2176		// stored version silently — don't dump release notes at someone
2177		// who just installed.
2178		changelogCh = make(chan modes.ChangelogPayload, 1)
2179		go func() {
2180			defer close(changelogCh)
2181			cfg, _ := LoadConfig()
2182			if cfg.LastChangelogShown == "" {
2183				SeedChangelogVersion(version)
2184				return
2185			}
2186			if !ShouldShowChangelog(version, cfg) {
2187				return
2188			}
2189			info := <-FetchChangelogAsync(version)
2190			if info.Body == "" {
2191				return
2192			}
2193			changelogCh <- modes.ChangelogPayload{
2194				Version: info.Version,
2195				Body:    info.Body,
2196				URL:     info.URL,
2197			}
2198		}()
2199	}
2200
2201	fastMode := r.FastMode
2202	quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
2203	for idx, s := range initialCfg.QuickModelShortcuts {
2204		quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
2205	}
2206	theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), initialCfg.Theme, 80*time.Millisecond)
2207	if themeErr != nil {
2208		fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2209		if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {
2210			initialCfg.Theme = ""
2211			_ = SaveConfig(initialCfg)
2212		}
2213	}
2214
2215	// subagentsMgr was constructed and reloaded earlier (before the agent
2216	// build, so the auto-subagents tool could capture it). Here we just
2217	// scope the dashboard to the active host session so /subagents only
2218	// shows agents this session spawned (and any pre-upgrade unscoped
2219	// agents — see SnapshotAll docs). Updated again whenever the
2220	// user swaps sessions via loadSession below.
2221	if sess != nil && subagentsMgr != nil {
2222		subagentsMgr.SetActiveSession(sess.ID)
2223	}
2224	// Best-effort shutdown on interactive exit: stop all running
2225	// agents so they don't outlive their parent zut.
2226	if subagentsMgr != nil {
2227		defer subagentsMgr.StopAll()
2228	}
2229
2230	var startupSkills []*skills.Skill
2231	if r.SkillTool != nil {
2232		startupSkills = r.SkillTool.Skills()
2233	}
2234	homeDir, _ := os.UserHomeDir()
2235	discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
2236	subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2237	autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
2238	autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
2239	autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args)
2240	autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args)
2241	webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)
2242	webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
2243	setWebSearchAvailable := func(available bool) {
2244		webSearchGuard.setAvailable(available)
2245		if subagentsMgr == nil {
2246			return
2247		}
2248		policy := subagents.WebSearchDeny
2249		if available {
2250			policy = subagents.WebSearchAllow
2251		}
2252		subagentsMgr.SetWebSearchPolicy(policy)
2253	}
2254
2255	iv = modes.NewInteractive(modes.InteractiveConfig{
2256		Terminal:                       term,
2257		Theme:                          theme,
2258		InlineImagesEnabled:            initialCfg.InlineImagesEnabled,
2259		TerminalAlertsEnabled:          initialCfg.TerminalAlertsEnabled,
2260		TerminalTitleEnabled:           initialCfg.TerminalTitleEnabled,
2261		AutoSubagentsEnabled:           initialCfg.AutoSubagentsEnabled,
2262		PonytailEnabled:                initialCfg.PonytailEnabled,
2263		WebSearchEnabled:               initialCfg.WebSearchEnabled,
2264		WebSearchToolAllowed:           &webSearchToolAllowedForInvocation,
2265		WebSearchInvocationOverride:    webSearchInvocationOverride,
2266		AutoSubagentsToolAllowed:       &autoSubagentsToolAllowedForSession,
2267		AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2268		AutoSubagentsStopToolAllowed:   &autoSubagentsStopToolAllowedForSession,
2269		AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,
2270		FastMode:                       &fastMode,
2271		LSPEnabled:                     initialCfg.LSPEnabled,
2272		SubagentLSPEnabled:             initialCfg.SubagentLSPEnabled,
2273		AutoCompactThreshold:           initialCfg.AutoCompactThreshold,
2274		JailByDefault:                  initialCfg.JailByDefault,
2275		QuickModelShortcuts:            quickModelShortcuts,
2276		RecursiveFileSuggest:           initialCfg.RecursiveFileSuggest,
2277		RespectGitignore:               initialCfg.RespectGitignore,
2278		CompactMode:                    initialCfg.CompactMode,
2279		TUIInputStyle:                  initialCfg.TUIInputStyle,
2280		TUIStatusPosition:              initialCfg.TUIStatusPosition,
2281		TUIWorkingPosition:             initialCfg.TUIWorkingPosition,
2282		TUISubagentPosition:            initialCfg.TUISubagentPosition,
2283		ThemeName:                      initialCfg.Theme,
2284		FlatTools:                      initialCfg.FlatToolRender(),
2285		CompactUser:                    initialCfg.CompactUserInput(),
2286		ExtensionThemes:                extMgr.ThemeOptions,
2287		AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(
2288			autoSubagentsToolAllowedForSession,
2289			autoSubagentsStopToolAllowedForSession,
2290			autoSubagentsResumeToolAllowedForSession,
2291		),
2292		OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,
2293		SubagentsSystemAddendum:         subagentsAddendum,
2294		SettingsStore:                   configSettingsStore{},
2295		Model:                           r.Model,
2296		Provider:                        r.Provider,
2297		AuthMethod:                      r.AuthMethod,
2298		BaseURL:                         r.BaseURL,
2299		Reasoning:                       r.Reasoning,
2300		SystemPrompt:                    r.SystemPrompt,
2301		Tools:                           r.ToolRegistry,
2302		MaxSteps:                        r.MaxSteps,
2303		CWD:                             r.CWD,
2304		StartupAgentName:                args.AgentName,
2305		StartupContextPaths:             instructionContextPaths(r.ContextFiles),
2306		StartupExtensionNames:           startupExtensionNames(extMgr.All()),
2307		StartupExtensionErrors:          startupExtensionErrors,
2308		StartupSkillNames:               startupSkillNames(startupSkills),
2309		ShowInstructionsAtStartup:       initialCfg.ShowInstructionsAtStartup,
2310		ZutHome:                         ZutHome(),
2311		SessionsRoot:                    agentSessionsRoot(ZutHome(), args),
2312		Version:                         version,
2313		UpdateInfoChan:                  updateCh,
2314		Sandbox:                         sharedSandbox,
2315		Agent:                           ag,
2316		InitialCompactHandoff:           currentCompactHandoff(),
2317		InitialSessionTitle: func() string {
2318			if sess == nil || sessionTitlePending {
2319				return ""
2320			}
2321			return sess.Title
2322		}(),
2323		InitialSessionTitlePending: sessionTitlePending,
2324		InitialInput:               args.Prompt,
2325		StartupPre:                 args.StartupPre,
2326		OnStartupPreDone: func() {
2327			// entry.pre often installs skills/extensions; rediscover them
2328			// before the user starts a model turn.
2329			current := liveInteractiveAgent(iv, ag)
2330			if extMgr != nil {
2331				_ = extMgr.Reload(context.Background(), 2*time.Second)
2332			}
2333			if current != nil {
2334				if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil {
2335					iv.ReportError(err)
2336				}
2337			}
2338		},
2339		RefreshTools: func() error {
2340			current := liveInteractiveAgent(iv, ag)
2341			webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2342			if err != nil {
2343				if !errors.Is(err, errInteractiveAgentChanged) {
2344					setWebSearchAvailable(false)
2345				}
2346				return err
2347			}
2348			setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2349			return nil
2350		},
2351		SetWebSearchAvailable: setWebSearchAvailable,
2352		RefreshPrompt: func() error {
2353			current := liveInteractiveAgent(iv, ag)
2354			webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2355			if err != nil {
2356				return err
2357			}
2358			setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2359			return nil
2360		},
2361		AuthManager:                mgr,
2362		LlamaCPPConfig:             ResolveLlamaCPPConfig,
2363		RefreshLlamaCPPModels:      RefreshLlamaCPPModels,
2364		BuildAgent:                 buildAgent,
2365		SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
2366		BuildAgentFor:              buildAgentFor,
2367		BuildAgentForRescue:        buildAgentForRescue,
2368		LoggedInProviders: func() []string {
2369			var out []string
2370			seen := map[string]bool{}
2371			for _, p := range knownProviders {
2372				if CredentialAvailable(p) && !seen[p] {
2373					out = append(out, p)
2374					seen[p] = true
2375				}
2376			}
2377			// Include custom providers that have credentials stored.
2378			for p := range provider.CustomProviders() {
2379				if CredentialAvailable(p) && !seen[p] {
2380					out = append(out, p)
2381					seen[p] = true
2382				}
2383			}
2384			// Ollama models are always available (no auth needed).
2385			if !seen["ollama"] {
2386				out = append(out, "ollama")
2387			}
2388			return out
2389		},
2390		LoadSession:           loadSession,
2391		ChangeCWD:             changeCWD,
2392		PersistCompactHandoff: persistCompactHandoff,
2393		CurrentCompactHandoff: currentCompactHandoff,
2394		CurrentSessionPath: func() string {
2395			persistMu.Lock()
2396			defer persistMu.Unlock()
2397			if sess == nil {
2398				return ""
2399			}
2400			return sess.Path
2401		},
2402		FlushSession: func() {
2403			sessionTransitionMu.RLock()
2404			defer sessionTransitionMu.RUnlock()
2405			// Append any not-yet-persisted agent messages to the
2406			// current session file, then advance the baseline so
2407			// the final WriteNewTranscript at exit doesn't write
2408			// duplicates. Per-message persistence keeps the on-
2409			// disk file current already, so this is mostly a
2410			// defensive flush — still needed for /session export
2411			// to guarantee the exported bytes include the very
2412			// last in-flight turn.
2413			currentAg := iv.Agent()
2414			if currentAg == nil {
2415				return
2416			}
2417			persistMu.Lock()
2418			defer persistMu.Unlock()
2419			if sess == nil {
2420				return
2421			}
2422			writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2423			sessBaselineMsgs = len(currentAg.Messages())
2424		},
2425		SessionTransition: newSessionTransition(&sessionTransitionMu),
2426		Extensions:        extMgr,
2427		Supervisor:        subagentsMgr,
2428		ResolveSubagent:   resolveSubagent,
2429		ChangelogChan:     changelogCh,
2430		OnChangelogDismiss: func() {
2431			// For development builds, store the actual release version
2432			// so the same changelog doesn't show again next launch.
2433			// For real builds, store the binary version.
2434			v := version
2435			if devBuild {
2436				if iv != nil && iv.ChangelogVersion() != "" {
2437					v = iv.ChangelogVersion()
2438				}
2439			}
2440			_ = MarkChangelogShown(v)
2441		},
2442		SkillSnapshot: func() []*skills.Skill {
2443			if args.NoSkill {
2444				// --no-skill: nothing for the picker to show.
2445				return nil
2446			}
2447			// Re-discover so the picker reflects edits made during
2448			// the session. Cheap; SKILL.md files are small. Filter
2449			// out built-in skills — they're hidden from user-facing
2450			// surfaces because they're implementation detail; the
2451			// model still sees them through the system-prompt
2452			// manifest and the skill tool.
2453			userHome, _ := os.UserHomeDir()
2454			list, _ := skills.Discover(ZutHome(), r.CWD, userHome, args.WithSkills)
2455			list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills())
2456			return skills.VisibleSkills(list)
2457		},
2458		NoYolo:      args.NoYolo,
2459		ConfirmGate: confirmGate,
2460		PersistModel: newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, func(message string) {
2461			if iv != nil {
2462				iv.Notify("session", "error", message)
2463			}
2464		}),
2465		CurrentSessionTitle: func() string {
2466			sessionTransitionMu.RLock()
2467			defer sessionTransitionMu.RUnlock()
2468			persistMu.Lock()
2469			defer persistMu.Unlock()
2470			if sess == nil || sessionTitlePending {
2471				return ""
2472			}
2473			return sess.Title
2474		},
2475		CurrentSessionTitlePending: func() bool {
2476			sessionTransitionMu.RLock()
2477			defer sessionTransitionMu.RUnlock()
2478			persistMu.Lock()
2479			defer persistMu.Unlock()
2480			return sessionTitlePending
2481		},
2482		PersistTitle: func(title string) error {
2483			persistMu.Lock()
2484			defer persistMu.Unlock()
2485			if sess == nil {
2486				return nil
2487			}
2488			if err := sess.UpdateTitle(title); err != nil {
2489				return err
2490			}
2491			sessionTitlePending = false
2492			return nil
2493		},
2494		OnSessionTitleChanged: func(title string) {
2495			sessionTransitionMu.RLock()
2496			defer sessionTransitionMu.RUnlock()
2497			persistMu.Lock()
2498			defer persistMu.Unlock()
2499			if sess != nil {
2500				sess.Title = title
2501				sessionTitlePending = false
2502			}
2503		},
2504	})
2505	extHooks.attachInteractive(iv)
2506
2507	// Bind the interactive TUI as the Confirmer. We deferred this
2508	// until now because the gate is constructed before the TUI
2509	// (the BeforeToolExecute closure captures it). SetConfirmer
2510	// is mutex-guarded on the gate so this is safe.
2511	if confirmGate != nil {
2512		confirmGate.SetConfirmer(&confirmationEventConfirmer{
2513			inner: iv,
2514			emit:  extMgr.EmitEvent,
2515		})
2516	}
2517
2518	// Signal-driven flush: a SIGTERM / SIGHUP to the zut process
2519	// (closed terminal window, system shutdown, kill) used to lose
2520	// the entire in-memory transcript because the deferred post-Run
2521	// flush below never ran. Per-message persistence above covers
2522	// most of it; this handler writes any in-flight remainder and
2523	// then exits the process so we don't double-paint over a
2524	// broken terminal that the TUI's restore deferreds can no
2525	// longer fix from a signal context.
2526	//
2527	// SIGINT is intentionally NOT handled here — the TUI consumes
2528	// Ctrl+C as a regular key event for cancel/clear semantics, and
2529	// installing a SIGINT notifier here would swallow it.
2530	sigCh := make(chan os.Signal, 1)
2531	signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
2532	defer signal.Stop(sigCh)
2533	go func() {
2534		_, ok := <-sigCh
2535		if !ok {
2536			return
2537		}
2538		finalAg := iv.Agent()
2539		if finalAg != nil {
2540			persistMu.Lock()
2541			if sess != nil {
2542				writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2543				sessBaselineMsgs = len(finalAg.Messages())
2544				_ = sess.Close()
2545				sess = nil
2546			}
2547			persistMu.Unlock()
2548		}
2549		closeAgentLSP(finalAg)
2550		closeResolvedLSP(r)
2551		// Stop supervised workers before exiting. os.Exit skips deferred
2552		// cleanup, so relying only on the normal runInteractive defer would
2553		// leave child daemons (and their descendants) alive after a TERM or
2554		// HUP delivered to the host.
2555		if subagentsMgr != nil {
2556			subagentsMgr.StopAll()
2557		}
2558		// Exit cleanly. Re-raising the signal would skip os.Exit's
2559		// at-exit hooks; explicit exit is fine because we've already
2560		// flushed the only at-risk state (the session file) and stopped
2561		// supervised workers.
2562		os.Exit(0)
2563	}()
2564
2565	runErr := iv.Run(ctx)
2566
2567	// Flush final transcript to session (only if we had / ended up with an agent).
2568	if finalAg := iv.Agent(); finalAg != nil {
2569		persistMu.Lock()
2570		if sess != nil {
2571			writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2572			sessBaselineMsgs = len(finalAg.Messages())
2573		}
2574		persistMu.Unlock()
2575	}
2576	return runErr
2577}
2578
2579func agentSessionsRoot(root string, args Args) string {
2580	if args.AgentName == "" {
2581		return root
2582	}
2583	return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName))
2584}
2585
2586// openOrCreateSession returns a session for the run. sess may be nil
2587// with a nil error if session persistence is disabled.
2588func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
2589	if args.NoSess {
2590		return nil, nil
2591	}
2592	// Sweep meta-only files left over from older zut versions (and from
2593	// any session that crashed before its first AppendMessage). Cheap;
2594	// reads the first few bytes of each file in the cwd's session dir.
2595	sessionsRoot := agentSessionsRoot(ZutHome(), args)
2596	core.PruneEmptySessions(sessionsRoot, args.CWD)
2597	var (
2598		s    *core.Session
2599		msgs []provider.Message
2600		err  error
2601	)
2602	switch {
2603	case args.Session != "":
2604		s, msgs, err = core.OpenSession(args.Session)
2605		// The subagent-worker child passes a fixed --session path that
2606		// may not exist yet on first Spawn. Treat ENOENT as "create
2607		// a fresh session AT THIS PATH" so the conversation actually
2608		// gets persisted; without this fallback the subagent child runs
2609		// with sess==nil and every Resume re-starts with no memory
2610		// of the prior turns. Other openers (--continue / --resume /
2611		// the picker) never see ENOENT here because they only choose
2612		// paths that already exist on disk.
2613		if err != nil && errors.Is(err, os.ErrNotExist) {
2614			s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
2615			msgs = nil
2616		}
2617	case args.Continue:
2618		latest := core.LatestSession(sessionsRoot, args.CWD)
2619		if latest != "" {
2620			s, msgs, err = core.OpenSession(latest)
2621		}
2622	case args.Resume:
2623		picked, perr := pickSession(sessionsRoot, args.CWD)
2624		if perr != nil {
2625			return nil, perr
2626		}
2627		if picked != "" {
2628			s, msgs, err = core.OpenSession(picked)
2629		}
2630	}
2631	if err != nil {
2632		return nil, err
2633	}
2634	if s != nil {
2635		ag.SetMessages(msgs)
2636		if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
2637			ag.SeedCost(cum)
2638			ag.SeedLastTurnUsage(last)
2639		}
2640		return s, nil
2641	}
2642	return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2643}
2644
2645func pickSession(root, cwd string) (string, error) {
2646	files := core.ListSessions(root, cwd)
2647	if len(files) == 0 {
2648		fmt.Fprintln(os.Stderr, "no sessions for", cwd)
2649		return "", nil
2650	}
2651	for i, f := range files {
2652		fmt.Fprintf(os.Stderr, "  %2d) %s\n", i+1, f)
2653	}
2654	fmt.Fprint(os.Stderr, "pick #: ")
2655	rd := bufio.NewReader(os.Stdin)
2656	line, _ := rd.ReadString('\n')
2657	line = strings.TrimSpace(line)
2658	var n int
2659	if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
2660		return "", fmt.Errorf("invalid selection")
2661	}
2662	return files[n-1], nil
2663}
2664
2665// WriteNewTranscript appends only messages after index `from` from the
2666// agent's transcript to the session. Used by callers that don't hold
2667// the persistMu (non-interactive print/json modes which run a single
2668// turn under their own goroutine).
2669func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
2670	writeNewTranscriptLocked(ag, sess, from)
2671}
2672
2673// writeNewTranscriptLocked is the same as WriteNewTranscript. The
2674// suffix marks that interactive callers must hold persistMu when
2675// invoking it so concurrent appends from the agent loop don't race
2676// with this catch-up flush.
2677func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) {
2678	if sess == nil || ag == nil {
2679		return
2680	}
2681	msgs := ag.Messages()
2682	for i := from; i < len(msgs); i++ {
2683		_ = sess.AppendMessage(msgs[i])
2684	}
2685	cum := ag.Cost()
2686	_ = sess.AppendUsage(cum, cum)
2687}
2688
2689func readAllStdin() (string, error) {
2690	fi, err := os.Stdin.Stat()
2691	if err != nil {
2692		return "", err
2693	}
2694	if (fi.Mode() & os.ModeCharDevice) != 0 {
2695		return "", nil
2696	}
2697	b, err := io.ReadAll(os.Stdin)
2698	return string(b), err
2699}
2700
2701func printModels() {
2702	models := provider.Active()
2703
2704	// Compute column widths from actual data so wide providers (e.g.
2705	// xiaomi-token-plan-sgp) and long bedrock model ids don't force the
2706	// `name` column off-screen. Floors mirror the historical layout so
2707	// short catalogs look the same as before.
2708	provW, idW, srcW := len("provider"), len("model id"), len("source")
2709	for _, m := range models {
2710		if w := len(m.Provider); w > provW {
2711			provW = w
2712		}
2713		if w := len(m.ID); w > idW {
2714			idW = w
2715		}
2716		source := m.Source
2717		if source == "" {
2718			source = "catalog"
2719		}
2720		if m.Speculative {
2721			source = "speculative"
2722		}
2723		if w := len(source); w > srcW {
2724			srcW = w
2725		}
2726	}
2727
2728	header := fmt.Sprintf("%-*s  %-*s  %8s  %8s  %s  %-*s  %s",
2729		provW, "provider",
2730		idW, "model id",
2731		"context", "max-out", "reasoning",
2732		srcW, "source",
2733		"name")
2734	fmt.Println(header)
2735
2736	for _, m := range models {
2737		reason := " "
2738		if m.Reasoning {
2739			reason = "✓"
2740		}
2741		source := m.Source
2742		if source == "" {
2743			source = "catalog"
2744		}
2745		if m.Speculative {
2746			source = "speculative"
2747		}
2748		fmt.Printf("%-*s  %-*s  %8d  %8d     %s      %-*s  %s\n",
2749			provW, m.Provider,
2750			idW, m.ID,
2751			m.ContextWindow, m.MaxOutput,
2752			reason,
2753			srcW, source,
2754			m.DisplayName)
2755	}
2756}

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