v0.6.0 01ec125

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	started := time.Now()
935	usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
936	elapsed := time.Since(started)
937	WriteNewTranscript(ag, sess, start)
938	if err != nil {
939		return err
940	}
941	if args.StatsPath != "" {
942		return writePrintStats(args.StatsPath, r.Provider, r.Model, usage, elapsed)
943	}
944	return nil
945}
946
947func runStreamMode(ctx context.Context, args Args, version string) error {
948	if args.NoYolo {
949		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
950	}
951	r, err := Resolve(args, true)
952	if err != nil {
953		return err
954	}
955	extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
956	defer stopExt()
957
958	ag := r.NewAgent()
959	initialAg := ag
960	defer func() {
961		closeAgentLSP(ag)
962		if ag != initialAg {
963			closeAgentLSP(initialAg)
964		}
965	}()
966	wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
967	sess, err := openOrCreateSession(args, r, ag, version)
968	if err != nil {
969		return err
970	}
971	if sess != nil {
972		var providerName, model string
973		sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
974		if err != nil {
975			return err
976		}
977		r.Provider, r.Model = providerName, model
978		ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
979		defer sess.Close()
980	}
981	announceSession(extMgr, sess)
982
983	prompt := args.Prompt
984	if prompt == "" {
985		piped, _ := readAllStdin()
986		prompt = strings.TrimSpace(piped)
987	}
988	if prompt == "" {
989		return fmt.Errorf("stream mode requires a prompt (arg or stdin)")
990	}
991
992	start := len(ag.Messages())
993	preSink, finishPre := newStreamTextSink(os.Stdout)
994	if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
995		finishPre()
996		return err
997	}
998	finishPre()
999	if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1000		return err
1001	}
1002	err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
1003	WriteNewTranscript(ag, sess, start)
1004	return err
1005}
1006
1007func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
1008	var streamed, wroteText, lastWasNL bool
1009	writeText := func(text string) {
1010		if text == "" {
1011			return
1012		}
1013		_, _ = fmt.Fprint(out, text)
1014		if syncer, ok := out.(interface{ Sync() error }); ok {
1015			_ = syncer.Sync()
1016		}
1017		wroteText = true
1018		lastWasNL = strings.HasSuffix(text, "\n")
1019	}
1020	sink := func(ev core.AgentEvent) {
1021		switch e := ev.(type) {
1022		case core.EvAssistantStart:
1023			streamed = false
1024		case core.EvTextDelta:
1025			streamed = true
1026			writeText(e.Delta)
1027		case core.EvAssistantMessage:
1028			if streamed {
1029				return
1030			}
1031			var text strings.Builder
1032			for _, content := range e.Message.Content {
1033				if block, ok := content.(provider.TextBlock); ok {
1034					if text.Len() > 0 {
1035						text.WriteString("\n")
1036					}
1037					text.WriteString(block.Text)
1038				}
1039			}
1040			writeText(text.String())
1041		}
1042	}
1043	finish := func() {
1044		if wroteText && !lastWasNL {
1045			writeText("\n")
1046		}
1047	}
1048	return sink, finish
1049}
1050
1051func runJSONMode(ctx context.Context, args Args, version string) error {
1052	if args.NoYolo {
1053		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
1054	}
1055	r, err := Resolve(args, true)
1056	if err != nil {
1057		return err
1058	}
1059	extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
1060	defer stopExt()
1061
1062	ag := r.NewAgent()
1063	initialAg := ag
1064	defer func() {
1065		closeAgentLSP(ag)
1066		if ag != initialAg {
1067			closeAgentLSP(initialAg)
1068		}
1069	}()
1070	wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1071	sess, err := openOrCreateSession(args, r, ag, version)
1072	if err != nil {
1073		return err
1074	}
1075	if sess != nil {
1076		var providerName, model string
1077		sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1078		if err != nil {
1079			return err
1080		}
1081		r.Provider, r.Model = providerName, model
1082		ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
1083		defer sess.Close()
1084	}
1085	announceSession(extMgr, sess)
1086
1087	prompt := args.Prompt
1088	if prompt == "" {
1089		piped, _ := readAllStdin()
1090		prompt = strings.TrimSpace(piped)
1091	}
1092	if prompt == "" {
1093		return fmt.Errorf("json mode requires a prompt (arg or stdin)")
1094	}
1095
1096	start := len(ag.Messages())
1097	enc := json.NewEncoder(os.Stdout)
1098	preSink := func(ev core.AgentEvent) {
1099		_ = enc.Encode(modes.EventToJSON(ev))
1100	}
1101	if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
1102		_ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1103		return err
1104	}
1105	if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1106		_ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1107		return err
1108	}
1109	err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
1110	WriteNewTranscript(ag, sess, start)
1111	return err
1112}
1113
1114// runZutfileStartupPre runs entry.pre before the main non-interactive prompt.
1115// "!command" values execute via BashTool; other values are sent as an agent turn.
1116// shellOut receives live shell chunks (typically os.Stderr); sink receives
1117// agent events for plain-text pre (stream mode wires a live text sink).
1118func runZutfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error {
1119	pre = strings.TrimSpace(pre)
1120	if pre == "" {
1121		return nil
1122	}
1123	if cmd, ok := modes.ShellEscapeCommand(pre); ok {
1124		raw, err := json.Marshal(map[string]any{"command": cmd})
1125		if err != nil {
1126			return err
1127		}
1128		if shellOut != nil {
1129			fmt.Fprintf(shellOut, "$ %s\n", cmd)
1130			if f, ok := shellOut.(interface{ Sync() error }); ok {
1131				_ = f.Sync()
1132			}
1133		}
1134		progress := func(chunk string) {
1135			if shellOut == nil || chunk == "" {
1136				return
1137			}
1138			fmt.Fprint(shellOut, chunk)
1139			if f, ok := shellOut.(interface{ Sync() error }); ok {
1140				_ = f.Sync()
1141			}
1142		}
1143		bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
1144		res, err := bash.Execute(ctx, raw, progress)
1145		if err != nil {
1146			return fmt.Errorf("zutfile entry.pre: %w", err)
1147		}
1148		if res.IsError {
1149			var sb strings.Builder
1150			for _, c := range res.Content {
1151				if tb, ok := c.(provider.TextBlock); ok {
1152					sb.WriteString(tb.Text)
1153				}
1154			}
1155			msg := strings.TrimSpace(sb.String())
1156			if msg == "" {
1157				msg = "command failed"
1158			}
1159			return fmt.Errorf("zutfile entry.pre: %s", msg)
1160		}
1161		return nil
1162	}
1163	if ag == nil {
1164		return fmt.Errorf("zutfile entry.pre requires an agent for non-shell prompts")
1165	}
1166	if sink == nil {
1167		sink = func(core.AgentEvent) {}
1168	}
1169	return ag.Prompt(ctx, pre, nil, sink)
1170}
1171
1172var errInteractiveAgentChanged = errors.New("interactive agent or web-search policy changed during prompt refresh")
1173
1174// refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
1175// skills and currently loaded extension tools) and updates the live
1176// agent's registry and system prompt. Used after /reload-ext and after
1177// zutfile entry.pre installs new skills or extensions.
1178// mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn).
1179// interactive, when non-nil, serializes the final commit with agent replacement.
1180func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) (subagents.WebSearchPolicy, error) {
1181	if ag == nil {
1182		return subagents.WebSearchDeny, nil
1183	}
1184	var webSearchPolicyGeneration uint64
1185	if interactive != nil {
1186		// Capture before Resolve: the final commit must fail if settings,
1187		// Telegram attachment, or a newer registry commit changes policy while
1188		// filesystem/config/extension discovery is in flight.
1189		webSearchPolicyGeneration = interactive.WebSearchPolicyGeneration()
1190	}
1191	resolved, err := Resolve(args, true)
1192	if err != nil {
1193		return subagents.WebSearchDeny, fmt.Errorf("refresh agent prompt and tools: %w", err)
1194	}
1195	if sharedSandbox != nil {
1196		resolved.UseSandbox(sharedSandbox)
1197	}
1198	if extToolAdapter != nil {
1199		resolved.MergeExtensionTools(extToolAdapter)
1200	}
1201	reg := resolved.ToolRegistry
1202	if resolved.WebSearchPolicy != subagents.WebSearchAllow {
1203		delete(reg, "web_search")
1204	}
1205	if mutateRegistry != nil {
1206		reg = mutateRegistry(reg)
1207	}
1208	if interactive != nil {
1209		oldTools, applied := interactive.ApplyAgentPromptConfigAtWebSearchGeneration(ag, resolved.SystemPrompt, reg, webSearchPolicyGeneration)
1210		if !applied {
1211			_ = tools.CloseLSPManagers(reg)
1212			return subagents.WebSearchDeny, errInteractiveAgentChanged
1213		}
1214		interactive.DeferUntilIdle(func() {
1215			_ = tools.CloseLSPManagers(oldTools)
1216		})
1217		return webSearchPolicyForRegistry(resolved.WebSearchPolicy, ag.ToolsSnapshot()), nil
1218	}
1219	oldTools := ag.SetPromptConfig(resolved.SystemPrompt, reg)
1220	_ = tools.CloseLSPManagers(oldTools)
1221	return webSearchPolicyForRegistry(resolved.WebSearchPolicy, reg), nil
1222}
1223
1224func webSearchPolicyForRegistry(policy subagents.WebSearchPolicy, reg core.Registry) subagents.WebSearchPolicy {
1225	if policy == subagents.WebSearchAllow {
1226		if _, ok := reg["web_search"]; ok {
1227			return subagents.WebSearchAllow
1228		}
1229	}
1230	return subagents.WebSearchDeny
1231}
1232
1233func webSearchToolAllowedForSession(args Args) bool {
1234	if args.NoTools || args.PermissionSet != nil {
1235		return false
1236	}
1237	if args.ToolsSet || len(args.Tools) > 0 {
1238		return toolListContains(args.Tools, "web_search")
1239	}
1240	return true
1241}
1242
1243func webSearchExplicitlyEnabledForSession(args Args) bool {
1244	return !args.NoTools && args.PermissionSet == nil && (args.ToolsSet || len(args.Tools) > 0) && toolListContains(args.Tools, "web_search")
1245}
1246
1247// reloadResourcesAfterStartupPre reloads extensions (if any) and
1248// refreshes the agent tool registry + system prompt so skills/extensions
1249// installed by entry.pre are visible to the following turn.
1250func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {
1251	if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1252		return nil
1253	}
1254	adapter := &extToolAdapter{mgr: extMgr}
1255	if extMgr != nil {
1256		_ = extMgr.Reload(ctx, 2*time.Second)
1257	}
1258	_, err := refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil, nil)
1259	return err
1260}
1261
1262// ---- interactive mode: opens the TUI even without credentials ----
1263
1264func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
1265	parseDuration := func(value string) time.Duration {
1266		if strings.TrimSpace(value) == "" {
1267			return 0
1268		}
1269		parsed, err := time.ParseDuration(strings.TrimSpace(value))
1270		if err != nil || parsed <= 0 {
1271			return 0
1272		}
1273		return parsed
1274	}
1275	return subagents.SubagentPolicy{
1276		MaxConcurrent:          cfg.MaxConcurrent,
1277		MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
1278		QueueTimeout:           parseDuration(cfg.QueueTimeout),
1279		DefaultTimeout:         parseDuration(cfg.DefaultTimeout),
1280		MaxTurns:               cfg.MaxTurns,
1281		MaxOutputBytes:         cfg.MaxOutputBytes,
1282		MaxOutputLines:         cfg.MaxOutputLines,
1283		AllowedTools:           append([]string(nil), cfg.AllowedTools...),
1284		AllowedRoots:           append([]string(nil), cfg.AllowedRoots...),
1285		HeartbeatInterval:      parseDuration(cfg.HeartbeatInterval),
1286		IdleTimeout:            parseDuration(cfg.IdleTimeout),
1287		ReconnectTimeout:       parseDuration(cfg.ReconnectTimeout),
1288		CancelGracePeriod:      parseDuration(cfg.CancelGracePeriod),
1289	}
1290}
1291
1292func runInteractive(ctx context.Context, args Args, version string) error {
1293	initialCfg, _ := LoadConfig()
1294	// Resolve WITHOUT requiring credentials.
1295	r, err := Resolve(args, false)
1296	if err != nil {
1297		return err
1298	}
1299
1300	authStore := AuthStoreFor()
1301	mgr := auth.NewManager(authStore)
1302	defer mgr.Close()
1303
1304	// Keep the sandbox pointer stable across agent rebuilds (login / model
1305	// switch). The Interactive UI toggles the lock via this pointer, and
1306	// rebuilt tool instances must share the same one so the lock sticks.
1307	sharedSandbox := r.Sandbox
1308
1309	// persistMu guards the active session and selection across the agent
1310	// loop, TUI callbacks, and session swaps. Declare it before tool
1311	// construction because subagent defaults must read the live selection too.
1312	var persistMu sync.Mutex
1313	// Serialize session transitions with persistence callbacks so a candidate
1314	// is read only after the old agent is flushed and cannot be overwritten by
1315	// an in-flight append before commit.
1316	var sessionTransitionMu sync.RWMutex
1317	activeProvider := r.Provider
1318	activeModel := r.Model
1319
1320	// Build the extension manager BEFORE the agent so we can fold
1321	// extension-defined tools into the registry. Attach the interactive
1322	// host after constructing it below so startup alerts can be buffered.
1323	var iv *modes.Interactive
1324	extHooks := &interactiveExtHooks{}
1325	extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, extHooks)
1326	var startupExtensionErrors []string
1327	// --ext paths first so they win against installed extensions of
1328	// the same name (loadOne's first-write-wins semantics).
1329	for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
1330		startupExtensionErrors = append(startupExtensionErrors, e.Error())
1331	}
1332	// --no-ext skips the global + project-local discovery scan;
1333	// explicit --ext paths above are still honoured so you can run
1334	// "only this extension" with --no-ext --ext ./x.
1335	if !args.NoExt {
1336		for _, e := range extMgr.Discover(ctx) {
1337			startupExtensionErrors = append(startupExtensionErrors, e.Error())
1338		}
1339	}
1340	// Wait briefly for extensions to flush their initial register_tool
1341	// frames before we build the agent's tool registry. Half a second
1342	// is plenty for any extension that's actually well-behaved; ones
1343	// that don't send a ready frame eat the full grace and proceed.
1344	// 3s is the per-extension grace period for the ready frame.
1345	// Native binaries are instant; runtimes like `npx tsx` take ~1.5s
1346	// from cold cache. The wait is tight only for extensions that
1347	// haven't sent ready by then; ones that signalled earlier release
1348	// the wait immediately.
1349	extMgr.WaitForReady(3 * time.Second)
1350	defer extMgr.Stop(2 * time.Second)
1351
1352	extToolAdapter := &extToolAdapter{mgr: extMgr}
1353	r.MergeExtensionTools(extToolAdapter)
1354	webSearchGuard := &webSearchSessionGuard{}
1355
1356	// Build the subagent supervisor BEFORE the agent so the auto-subagents
1357	// tool can reference it during tool-registry construction. State
1358	// lives under ZutHome/subagents so per-agent meta/events survive
1359	// restarts; the user can hunt orphaned agents down with
1360	// `git worktree list` if anything misbehaves.
1361	//
1362	// subagentsMgr is also captured by loadSession / changeCWD closures
1363	// further down the function, which is why we keep the variable
1364	// in this outer scope rather than scoping it tighter.
1365	var subagentsMgr *subagents.Supervisor
1366	subagentsMgr = subagents.New(subagents.Config{
1367		Context:         ctx,
1368		Root:            filepath.Join(ZutHome(), "subagents"),
1369		RepoRoot:        r.CWD,
1370		Provider:        r.Provider,
1371		FastMode:        r.FastMode,
1372		WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1373		BaseURL:         r.BaseURL,
1374		InsecureTLS:     r.InsecureTLS,
1375		Policy:          subagentPolicyFromConfig(initialCfg.Subagents),
1376		// Keep an explicit launch key in this callback only. Supervisor persists
1377		// connection settings, but never stores credentials in Agent metadata
1378		// or puts them in the worker's argv/environment.
1379		ResolveCredential: func(ctx context.Context, providerID string) (subagents.Credential, error) {
1380			childProvider := canonicalProvider(providerID)
1381			persistMu.Lock()
1382			hostProvider := canonicalProvider(activeProvider)
1383			persistMu.Unlock()
1384			if childProvider == "" {
1385				childProvider = hostProvider
1386			}
1387			explicit := ""
1388			// A launch-time API key is scoped to the provider selected at
1389			// startup. Never send it to an explicitly different child
1390			// provider; resolve that provider's own credential instead.
1391			if args.APIKey != "" && childProvider == hostProvider {
1392				explicit = args.APIKey
1393			}
1394			if childProvider == "ollama" && explicit == "" {
1395				return subagents.Credential{Value: "ollama", Method: "apikey"}, nil
1396			}
1397			credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
1398			if err != nil {
1399				// Providers backed only by a local endpoint do not need a key.
1400				// Leave stdin untouched and let the child resolve that endpoint.
1401				if !CredentialAvailable(childProvider) {
1402					return subagents.Credential{}, nil
1403				}
1404				return subagents.Credential{}, err
1405			}
1406			return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
1407		},
1408	})
1409	if args.PermissionSet != nil {
1410		// Packaged zutfile agents have an explicit capability ceiling. A
1411		// child process would otherwise rebuild a fresh unrestricted sandbox,
1412		// so do not expose either slash or tool-based delegation from this
1413		// restricted host until worker capability propagation exists.
1414		subagentsMgr = nil
1415	} else {
1416		// Replaying every historical event log can take seconds for users with
1417		// many completed subagents. The manager is safe for concurrent snapshots,
1418		// so populate the detached-agent dashboard in the background instead of
1419		// blocking the first interactive paint.
1420		go func() { _, _ = subagentsMgr.Reload() }()
1421	}
1422
1423	// onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool
1424	// fires after every successful spawn. It hands the agent off to
1425	// the running Interactive so the watcher can flush a summary back
1426	// into chat when all sub-agents finish. Reads `iv` lazily because
1427	// the Interactive is constructed after the agent.
1428	onSpawnedSupervisor := func(a *subagents.Agent, task string) {
1429		if iv != nil {
1430			iv.TrackSubagentWorker(a, task)
1431		}
1432	}
1433	resolveSubagent := func(name string) (*subagents.Profile, error) {
1434		return findSubagentProfile(r.CWD, name)
1435	}
1436
1437	// Inject the auto-subagent tools only when /settings -> auto-subagents
1438	// is currently enabled. Registering them unconditionally leaves the model
1439	// trying to call them (and getting a polite error) even when the user has
1440	// switched the feature off. The /settings toggle live-mutates the running
1441	// agent's registry separately so flipping the flag mid-session takes effect
1442	// on the next turn.
1443	injectSubagentTools := func(reg core.Registry) core.Registry {
1444		if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) {
1445			return reg
1446		}
1447		if !AutoSubagentsEnabled() {
1448			return reg
1449		}
1450		if autoSubagentsToolAllowed(args) {
1451			canonical := &tools.SubagentSpawnTool{
1452				Supervisor: subagentsMgr,
1453				Enabled:    AutoSubagentsEnabled,
1454				DefaultModel: func() string {
1455					persistMu.Lock()
1456					defer persistMu.Unlock()
1457					return activeModel
1458				},
1459				DefaultProvider: func() string {
1460					persistMu.Lock()
1461					defer persistMu.Unlock()
1462					return activeProvider
1463				},
1464				DefaultReasoning: func() string { return r.Reasoning },
1465				ResolveSubagent:  resolveSubagent,
1466				OnSpawned:        onSpawnedSupervisor,
1467			}
1468			reg[canonical.Name()] = canonical
1469		}
1470		if autoSubagentsStatusToolAllowed(args) {
1471			statusTool := &tools.SubagentStatusTool{
1472				Supervisor: subagentsMgr,
1473				Enabled:    AutoSubagentsEnabled,
1474			}
1475			reg[statusTool.Name()] = statusTool
1476		}
1477		return reg
1478	}
1479	prepareInteractiveRegistry := func(reg core.Registry) core.Registry {
1480		reg = injectSubagentTools(reg)
1481		return webSearchGuard.wrapRegistry(reg)
1482	}
1483	prepareResolvedInteractiveRegistry := func(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
1484		// The resolved policy is a ceiling. In particular, an extension named
1485		// web_search must not reintroduce the capability when --no-tools,
1486		// --tools, a packaged PermissionSet, or persisted settings deny it.
1487		if policy != subagents.WebSearchAllow {
1488			delete(reg, "web_search")
1489		}
1490		return prepareInteractiveRegistry(reg)
1491	}
1492	prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
1493	initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
1494	webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
1495	if subagentsMgr != nil {
1496		subagentsMgr.SetWebSearchPolicy(initialWebSearchPolicy)
1497	}
1498
1499	// Confirmation gate: when --no-yolo is on, the agent must ask
1500	// the user before every tool call. In interactive mode the TUI
1501	// provides the Confirmer; in print/json/rpc modes there's no
1502	// way to prompt, so the gate is constructed with a nil inner
1503	// which auto-refuses every call with a helpful reason.
1504	var confirmGate *core.ConfirmGate
1505	if args.NoYolo {
1506		confirmGate = core.NewConfirmGate(nil) // set below for interactive
1507	}
1508
1509	// Capture current args in a closure so BuildAgent can re-resolve
1510	// after a successful login (picks up the newly stored credential).
1511	wireAgentExt := func(a *core.Agent) *core.Agent {
1512		if a == nil {
1513			return a
1514		}
1515		a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
1516			// Guards run before confirmation because they may rewrite the
1517			// arguments. The user must approve the effective call that will
1518			// actually execute, not the model's original arguments.
1519			r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
1520			if r.Block {
1521				return false, r.Reason, nil
1522			}
1523			effectiveArgs := call.Arguments
1524			if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
1525				effectiveArgs = r.ModifiedArgs
1526			}
1527			if confirmGate != nil {
1528				var content strings.Builder
1529				_, currentTools := a.PromptConfig()
1530				if tool, err := currentTools.Get(call.Name); err == nil {
1531					if previewer, ok := tool.(core.ToolPreviewer); ok {
1532						preview, err := previewer.Preview(ctx, effectiveArgs)
1533						if err != nil {
1534							return false, err.Error(), nil
1535						}
1536						for _, block := range preview.Content {
1537							if text, ok := block.(provider.TextBlock); ok {
1538								if content.Len() > 0 {
1539									content.WriteString("\n")
1540								}
1541								content.WriteString(text.Text)
1542							}
1543						}
1544					}
1545				}
1546				ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
1547					ID:      call.ID,
1548					Name:    call.Name,
1549					Summary: core.BuildPreview(effectiveArgs, 120),
1550					Content: content.String(),
1551					Origin:  call.Origin,
1552				})
1553				if !ok {
1554					return false, reason, nil
1555				}
1556			}
1557			return true, "", r.ModifiedArgs
1558		}
1559		a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1560			r := extMgr.InterceptTurnStart(turnCtx, step)
1561			return !r.Block, r.Reason, r.Context
1562		}
1563		a.BeforeAssistantMessage = func(text string) (bool, string, string) {
1564			r := extMgr.InterceptAssistantMessage(ctx, text)
1565			if r.Block {
1566				return false, r.Reason, ""
1567			}
1568			return true, "", r.ReplaceText
1569		}
1570		a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
1571		return a
1572	}
1573
1574	var ag *core.Agent
1575	buildAgent := func() (*core.Agent, string, string, error) {
1576		resolved, err := Resolve(args, true)
1577		if err != nil {
1578			return nil, "", "", err
1579		}
1580		resolved.UseSandbox(sharedSandbox)
1581		if subagentsMgr != nil {
1582			subagentsMgr.SetProvider(resolved.Provider)
1583			subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1584		}
1585		resolved.MergeExtensionTools(extToolAdapter)
1586		prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1587		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1588	}
1589
1590	// Rebuild agent with an explicit provider/model override.
1591	buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1592		next := args
1593		if providerOverride != "" {
1594			next.Provider = providerOverride
1595		}
1596		if modelOverride != "" {
1597			next.Model = modelOverride
1598		}
1599		resolved, err := Resolve(next, true)
1600		if err != nil {
1601			return nil, "", "", err
1602		}
1603		resolved.UseSandbox(sharedSandbox)
1604		if subagentsMgr != nil {
1605			subagentsMgr.SetProvider(resolved.Provider)
1606			subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1607		}
1608		resolved.MergeExtensionTools(extToolAdapter)
1609		prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1610		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1611	}
1612
1613	// Rebuild agent for the rescue picker after a recoverable failure.
1614	// Unlike buildAgentFor, this drops launch-time --api-key and
1615	// --base-url overrides because those are typically the cause of the
1616	// rescue (a bad key, a typo'd base URL, or a corporate gateway that
1617	// only the originally-picked provider needed). Re-resolving without
1618	// them lets the rescue retry use env vars / auth.json / provider
1619	// defaults the way zut would have without the overrides.
1620	buildAgentForRescue := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1621		next := args
1622		next.APIKey = ""
1623		next.BaseURL = ""
1624		if providerOverride != "" {
1625			next.Provider = providerOverride
1626		}
1627		if modelOverride != "" {
1628			next.Model = modelOverride
1629		}
1630		resolved, err := Resolve(next, true)
1631		if err != nil {
1632			return nil, "", "", err
1633		}
1634		resolved.UseSandbox(sharedSandbox)
1635		if subagentsMgr != nil {
1636			subagentsMgr.SetProvider(resolved.Provider)
1637			subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1638		}
1639		resolved.MergeExtensionTools(extToolAdapter)
1640		prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1641		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1642	}
1643
1644	if r.HasCredential() {
1645		ag = wireAgentExt(r.NewAgent())
1646	}
1647
1648	// /reload-ext callback: after the manager has respawned every
1649	// extension, re-resolve the tool registry (built-ins + freshly-
1650	// registered extension tools) and swap it onto the current
1651	// agent in-place. The current agent may have been replaced by a
1652	// /model swap since spawn, so re-read the live `ag` on each
1653	// invocation.
1654	extMgr.SetOnReload(func() {
1655		current := liveInteractiveAgent(iv, ag)
1656		if current == nil {
1657			return
1658		}
1659		if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil && iv != nil {
1660			iv.ReportError(err)
1661		}
1662	})
1663
1664	var sess *core.Session
1665	var sessBaselineMsgs int // messages already on disk when current session opened
1666	var sessionTitlePending bool
1667	if !args.NoSess && ag != nil {
1668		var sessErr error
1669		sess, sessErr = openOrCreateSession(args, r, ag, version)
1670		if sessErr != nil {
1671			return sessErr
1672		}
1673		sessBaselineMsgs = len(ag.Messages())
1674		if sess != nil {
1675			// Startup resume uses the same provider/model-aware preparation as
1676			// the interactive session picker. This matters for --continue,
1677			// --resume, and explicit --session paths whose latest meta row was
1678			// written by another provider/model.
1679			candidate, resumeErr := applySessionResume(sess, ag, r.Provider, r.Model,
1680				strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", buildAgentFor)
1681			if resumeErr != nil {
1682				return resumeErr
1683			}
1684			sess = candidate.session
1685			ag = candidate.agent
1686			sessBaselineMsgs = candidate.fullMessageCount
1687			activeProvider = candidate.provider
1688			activeModel = candidate.model
1689			r.Provider = candidate.provider
1690			r.Model = candidate.model
1691		}
1692		if sess != nil && ag != nil {
1693			sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
1694		}
1695	}
1696	announceSession(extMgr, sess)
1697	defer func() {
1698		persistMu.Lock()
1699		defer persistMu.Unlock()
1700		if sess != nil {
1701			sess.Close()
1702		}
1703	}()
1704
1705	// persistMessage is the per-message hook bound to the agent. It
1706	// appends each new transcript message to the live session as soon
1707	// as it lands, so a kill / closed terminal / OS crash costs at
1708	// most the in-flight turn instead of the whole session. The
1709	// baseline counter advances in lock-step so the exit-time flush
1710	// doesn't double-write rows already on disk.
1711	persistMessage := func(m provider.Message) {
1712		sessionTransitionMu.RLock()
1713		defer sessionTransitionMu.RUnlock()
1714		persistMu.Lock()
1715		defer persistMu.Unlock()
1716		if sess == nil {
1717			return
1718		}
1719		if err := sess.AppendMessage(m); err == nil {
1720			sessBaselineMsgs++
1721		}
1722	}
1723	persistUsage := func(cum provider.Usage) {
1724		sessionTransitionMu.RLock()
1725		defer sessionTransitionMu.RUnlock()
1726		persistMu.Lock()
1727		defer persistMu.Unlock()
1728		if sess == nil {
1729			return
1730		}
1731		_ = sess.AppendUsage(cum, cum)
1732	}
1733	persistCompaction := func(messages []provider.Message) {
1734		sessionTransitionMu.RLock()
1735		var session *extproto.SessionContext
1736		var states map[string]json.RawMessage
1737		persistMu.Lock()
1738		if sess != nil {
1739			if err := sess.AppendCompaction(messages); err == nil {
1740				sessBaselineMsgs = len(messages)
1741				session = sessionContext(sess)
1742				states = copyExtensionStates(sess.ExtensionState)
1743			}
1744		}
1745		persistMu.Unlock()
1746		sessionTransitionMu.RUnlock()
1747		if session != nil {
1748			extMgr.EmitSessionEvent("session_compacted", session, states)
1749		}
1750	}
1751	persistToolResult := func(_ string, result core.ToolResult) {
1752		persistMu.Lock()
1753		defer persistMu.Unlock()
1754		persistExtensionToolResult(extMgr, sess, result)
1755	}
1756	wireAgentPersist := func(a *core.Agent) *core.Agent {
1757		if a == nil {
1758			return a
1759		}
1760		a.OnMessageAppended = persistMessage
1761		a.OnUsage = persistUsage
1762		a.OnTranscriptCompacted = persistCompaction
1763		a.OnToolResult = persistToolResult
1764		return a
1765	}
1766	wireAgentPersist(ag)
1767	defer func() {
1768		closeAgentLSP(liveInteractiveAgent(iv, ag))
1769		closeResolvedLSP(r)
1770	}()
1771
1772	// Re-wrap the build closures so any agent constructed by the TUI
1773	// (login, /model swap to a different provider) also gets the
1774	// persistence hooks. Without this, switching provider would
1775	// silently revert to the old in-memory-only behaviour. Selection state
1776	// is published by the successful transition itself, not by preparation.
1777	baseBuildAgent := buildAgent
1778	buildAgent = func() (*core.Agent, string, string, error) {
1779		a, p, m, err := baseBuildAgent()
1780		return wireAgentPersist(a), p, m, err
1781	}
1782	baseBuildAgentFor := buildAgentFor
1783	buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1784		a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
1785		return wireAgentPersist(a), p, m, err
1786	}
1787	baseBuildAgentForRescue := buildAgentForRescue
1788	buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1789		a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
1790		return wireAgentPersist(a), p, m, err
1791	}
1792
1793	// loadSession replaces the current session with the one at path and
1794	// hands its messages to the agent. Used by the /sessions picker.
1795	loadSession := func(path string) error {
1796		// Hold the transition lock from the pre-flush through the commit.
1797		// Persistence callbacks take the read side, so an active session
1798		// cannot be snapshotted before its lazy writes land or overwritten by
1799		// a callback while the candidate is being installed.
1800		sessionTransitionMu.Lock()
1801		defer sessionTransitionMu.Unlock()
1802
1803		currentAg := liveInteractiveAgent(iv, ag)
1804		if currentAg == nil {
1805			return fmt.Errorf("no agent running; log in first")
1806		}
1807
1808		persistMu.Lock()
1809		currentProvider := activeProvider
1810		currentModel := activeModel
1811		oldSess := sess
1812		// Flush before reading the candidate. Use the locked form rather
1813		// than the public callback, which would acquire the read lock held
1814		// by this transition.
1815		if oldSess != nil {
1816			writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1817		}
1818		currentMessageCount := len(currentAg.Messages())
1819		currentCost := currentAg.Cost()
1820		persistMu.Unlock()
1821
1822		// A candidate build must not publish its provider/model as the live
1823		// selection before the candidate commits.
1824		candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1825		if err != nil {
1826			// prepareSessionResume closes its append handle on failure;
1827			// no live agent or session has been changed.
1828			return err
1829		}
1830		committed := false
1831		defer func() {
1832			if !committed {
1833				_ = candidate.session.Close()
1834			}
1835		}()
1836
1837		// Wire the private candidate before it can become visible. This is
1838		// also required for legacy same-provider resumes, where the current
1839		// agent is reused and only its transcript/usage are refreshed.
1840		wireAgentPersist(candidate.agent)
1841
1842		persistMu.Lock()
1843		// A provider rebuild ran outside persistMu. Refuse to install it if
1844		// the live agent, session, selection, transcript, or cost changed in
1845		// the meantime; otherwise a slow resume could discard a newer turn or
1846		// model swap.
1847		live := liveInteractiveAgent(iv, ag)
1848		changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg
1849		if !changed && live != nil {
1850			changed = len(live.Messages()) != currentMessageCount || live.Cost() != currentCost
1851		}
1852		if changed {
1853			persistMu.Unlock()
1854			return fmt.Errorf("session changed while loading; please try again")
1855		}
1856		newStates := copyExtensionStates(candidate.session.ExtensionState)
1857		if oldSess != nil {
1858			_ = oldSess.Close()
1859		}
1860		sess = candidate.session
1861		sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
1862		// The live agent only receives a compact resume window, but the
1863		// session file remains intact. Keep the persistence baseline at
1864		// the original on-disk message count so future turns append after
1865		// the full session instead of duplicating the hydrated tail.
1866		sessBaselineMsgs = candidate.fullMessageCount
1867
1868		if candidate.rebuilt {
1869			// Commit the TUI agent while persistMu still protects the session
1870			// writer, so no new message can land on the old file between the
1871			// two state changes.
1872			if iv != nil {
1873				iv.ApplySessionAgent(candidate.agent, candidate.provider, candidate.model)
1874			}
1875			ag = candidate.agent
1876		} else {
1877			currentAg.SetMessages(candidate.messages)
1878			currentAg.SeedCost(candidate.cumulative)
1879			currentAg.SeedLastTurnUsage(candidate.lastTurn)
1880			ag = currentAg
1881		}
1882		activeProvider = candidate.provider
1883		activeModel = candidate.model
1884		if subagentsMgr != nil && candidate.session != nil {
1885			// Keep the dashboard scope in the same commit as the session,
1886			// agent, usage, and persistence baseline.
1887			if iv != nil {
1888				iv.SetSubagentSessionScope(candidate.session.ID)
1889			} else {
1890				subagentsMgr.SetActiveSession(candidate.session.ID)
1891			}
1892		}
1893		committed = true
1894		persistMu.Unlock()
1895		if candidate.session.Meta.Parent != "" {
1896			extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
1897		}
1898		extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
1899		return nil
1900	}
1901
1902	// changeCWD switches the running session to a new working directory.
1903	// Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
1904	// /cd slash command (which itself is only fired by the workspaces
1905	// extension's panel-key Enter handler today; the user can type /cd
1906	// directly but it's not in autocomplete / help / the README).
1907	//
1908	// Steps, in order:
1909	//   1. resolve + validate the new path (~ expansion, abs/rel)
1910	//   2. stage the candidate cwd, sandbox, and subagent state
1911	//   3. rebuild the agent and allocate its new session
1912	//   4. commit all new state together, closing the old session last
1913	//   5. push the committed state into the running Interactive
1914	//
1915	// The /jail state is preserved verbatim: if the sandbox was locked
1916	// to the old cwd, it stays locked, just re-pointed at the new one.
1917	changeCWD := func(path string) error {
1918		sessionTransitionMu.Lock()
1919		defer sessionTransitionMu.Unlock()
1920		if path == "" {
1921			return fmt.Errorf("empty path")
1922		}
1923		// ~ expansion.
1924		if path == "~" || strings.HasPrefix(path, "~/") {
1925			home, herr := os.UserHomeDir()
1926			if herr != nil || home == "" {
1927				return fmt.Errorf("cannot expand ~: %v", herr)
1928			}
1929			if path == "~" {
1930				path = home
1931			} else {
1932				path = filepath.Join(home, path[2:])
1933			}
1934		}
1935		if !filepath.IsAbs(path) {
1936			path = filepath.Join(args.CWD, path)
1937		}
1938		absPath, err := filepath.Abs(path)
1939		if err != nil {
1940			return err
1941		}
1942		info, err := os.Stat(absPath)
1943		if err != nil {
1944			return err
1945		}
1946		if !info.IsDir() {
1947			return fmt.Errorf("not a directory: %s", absPath)
1948		}
1949
1950		currentAg := liveInteractiveAgent(iv, ag)
1951		if currentAg == nil {
1952			return fmt.Errorf("no agent running; log in first")
1953		}
1954
1955		// Stage the candidate state while retaining a complete rollback
1956		// snapshot. A failed rebuild or session allocation must leave the
1957		// current agent, session, sandbox, and subagent root usable.
1958		oldCWD := args.CWD
1959		oldResolvedCWD := r.CWD
1960		oldPermissionSet := args.PermissionSet
1961		persistMu.Lock()
1962		oldActiveProvider := activeProvider
1963		oldActiveModel := activeModel
1964		persistMu.Unlock()
1965		oldSupervisorRoot := ""
1966		if subagentsMgr != nil {
1967			oldSupervisorRoot = subagentsMgr.RepoRoot()
1968		}
1969		oldSandboxRoot := ""
1970		oldSandboxLocked := false
1971		if sharedSandbox != nil {
1972			oldSandboxRoot = sharedSandbox.Root
1973			oldSandboxLocked = sharedSandbox.Locked()
1974		}
1975		rollback := func() {
1976			args.CWD = oldCWD
1977			r.CWD = oldResolvedCWD
1978			args.PermissionSet = oldPermissionSet
1979			persistMu.Lock()
1980			activeProvider = oldActiveProvider
1981			activeModel = oldActiveModel
1982			persistMu.Unlock()
1983			if subagentsMgr != nil {
1984				subagentsMgr.SetRepoRoot(oldSupervisorRoot)
1985			}
1986			if sharedSandbox != nil {
1987				sharedSandbox.Root = oldSandboxRoot
1988				if oldSandboxLocked {
1989					sharedSandbox.Lock()
1990				} else {
1991					sharedSandbox.Unlock()
1992				}
1993			}
1994		}
1995
1996		args.CWD = absPath
1997		r.CWD = absPath
1998		if subagentsMgr != nil {
1999			subagentsMgr.SetRepoRoot(absPath)
2000		}
2001		if args.PermissionSet != nil {
2002			expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
2003			args.PermissionSet = &expanded
2004		}
2005		if sharedSandbox != nil {
2006			sharedSandbox.Root = absPath
2007			if oldSandboxLocked {
2008				sharedSandbox.Lock()
2009			} else {
2010				sharedSandbox.Unlock()
2011			}
2012		}
2013
2014		// Rebuild the agent so tools / AGENTS.md / system prompt
2015		// re-bind to the new cwd. buildAgent() reads from args + r.
2016		newAg, newProvider, newModel, berr := buildAgent()
2017		if berr != nil {
2018			rollback()
2019			return fmt.Errorf("rebuild agent: %w", berr)
2020		}
2021
2022		// Fresh session in the new cwd's bucket. We bypass
2023		// openOrCreateSession's --continue / --resume branches
2024		// because /cd's semantics are "start fresh here", matching
2025		// what relaunching `zut --cwd <path>` would do today.
2026		var newSess *core.Session
2027		if !args.NoSess {
2028			newRoot := agentSessionsRoot(ZutHome(), args)
2029			core.PruneEmptySessions(newRoot, absPath)
2030			var serr error
2031			newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
2032			if serr != nil {
2033				rollback()
2034				return fmt.Errorf("open session in %s: %w", absPath, serr)
2035			}
2036		}
2037
2038		// Invalidate any title request before swapping the host-owned session
2039		// pointer. The interactive state reset below happens after the swap,
2040		// so this guard closes the window where a late result could otherwise
2041		// be written to the new session.
2042		if iv != nil {
2043			iv.CancelPendingSessionTitle()
2044		}
2045
2046		// Commit only after every fallible operation has succeeded. Close
2047		// the old session last so rollback never has to resurrect it.
2048		var newStates map[string]json.RawMessage
2049		persistMu.Lock()
2050		if sess != nil {
2051			writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2052			_ = sess.Close()
2053		}
2054		sess = newSess
2055		if newSess != nil {
2056			newStates = copyExtensionStates(newSess.ExtensionState)
2057		}
2058		sessBaselineMsgs = 0
2059		sessionTitlePending = false
2060		activeProvider = newProvider
2061		activeModel = newModel
2062		persistMu.Unlock()
2063		ag = newAg
2064		closeAgentLSP(currentAg)
2065
2066		// Push the new state into the running Interactive.
2067		if iv != nil {
2068			startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
2069			iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
2070		}
2071
2072		// Re-scope the subagent dashboard to the new session.
2073		if subagentsMgr != nil && newSess != nil {
2074			if iv != nil {
2075				iv.SetSubagentSessionScope(newSess.ID)
2076			} else {
2077				subagentsMgr.SetActiveSession(newSess.ID)
2078			}
2079		}
2080		if newSess != nil {
2081			extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
2082		}
2083		return nil
2084	}
2085
2086	devBuild := isDevVersion(version)
2087	if devBuild {
2088		fmt.Fprintln(os.Stderr, "zut:", devVersionNotice)
2089	}
2090	term := tui.NewProcTerm()
2091
2092	var updateCh chan modes.UpdateInfo
2093	var changelogCh chan modes.ChangelogPayload
2094	if !devBuild {
2095		// Kick off the async update check so the banner can appear when the
2096		// http response eventually arrives (usually <1s on cached DNS). Map
2097		// agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
2098		updateCh = make(chan modes.UpdateInfo, 1)
2099		go func() {
2100			defer close(updateCh)
2101			src := <-CheckForUpdateAsync(ZutHome(), version)
2102			updateCh <- modes.UpdateInfo{
2103				Current:   src.Current,
2104				Latest:    src.Latest,
2105				Available: src.Available,
2106				URL:       src.URL,
2107			}
2108		}()
2109
2110		// Changelog: when the running version differs from the last
2111		// version whose release notes the user dismissed, fetch the
2112		// release body from GitHub and have the TUI show it once. On
2113		// first-ever launch (no prior LastChangelogShown), seed the
2114		// stored version silently — don't dump release notes at someone
2115		// who just installed.
2116		changelogCh = make(chan modes.ChangelogPayload, 1)
2117		go func() {
2118			defer close(changelogCh)
2119			cfg, _ := LoadConfig()
2120			if cfg.LastChangelogShown == "" {
2121				SeedChangelogVersion(version)
2122				return
2123			}
2124			if !ShouldShowChangelog(version, cfg) {
2125				return
2126			}
2127			info := <-FetchChangelogAsync(version)
2128			if info.Body == "" {
2129				return
2130			}
2131			changelogCh <- modes.ChangelogPayload{
2132				Version: info.Version,
2133				Body:    info.Body,
2134				URL:     info.URL,
2135			}
2136		}()
2137	}
2138
2139	fastMode := r.FastMode
2140	quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
2141	for idx, s := range initialCfg.QuickModelShortcuts {
2142		quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
2143	}
2144	theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), initialCfg.Theme, 80*time.Millisecond)
2145	if themeErr != nil {
2146		fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2147		if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {
2148			initialCfg.Theme = ""
2149			_ = SaveConfig(initialCfg)
2150		}
2151	}
2152
2153	// subagentsMgr was constructed and reloaded earlier (before the agent
2154	// build, so the auto-subagents tool could capture it). Here we just
2155	// scope the dashboard to the active host session so /subagents only
2156	// shows agents this session spawned (and any pre-upgrade unscoped
2157	// agents — see SnapshotAll docs). Updated again whenever the
2158	// user swaps sessions via loadSession below.
2159	if sess != nil && subagentsMgr != nil {
2160		subagentsMgr.SetActiveSession(sess.ID)
2161	}
2162	// Best-effort shutdown on interactive exit: stop all running
2163	// agents so they don't outlive their parent zut.
2164	if subagentsMgr != nil {
2165		defer subagentsMgr.StopAll()
2166	}
2167
2168	var startupSkills []*skills.Skill
2169	if r.SkillTool != nil {
2170		startupSkills = r.SkillTool.Skills()
2171	}
2172	homeDir, _ := os.UserHomeDir()
2173	discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
2174	subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2175	autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
2176	autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
2177	webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)
2178	webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
2179	setWebSearchAvailable := func(available bool) {
2180		webSearchGuard.setAvailable(available)
2181		if subagentsMgr == nil {
2182			return
2183		}
2184		policy := subagents.WebSearchDeny
2185		if available {
2186			policy = subagents.WebSearchAllow
2187		}
2188		subagentsMgr.SetWebSearchPolicy(policy)
2189	}
2190
2191	iv = modes.NewInteractive(modes.InteractiveConfig{
2192		Terminal:                       term,
2193		Theme:                          theme,
2194		InlineImagesEnabled:            initialCfg.InlineImagesEnabled,
2195		TerminalAlertsEnabled:          initialCfg.TerminalAlertsEnabled,
2196		TerminalTitleEnabled:           initialCfg.TerminalTitleEnabled,
2197		AutoSubagentsEnabled:           initialCfg.AutoSubagentsEnabled,
2198		PonytailEnabled:                initialCfg.PonytailEnabled,
2199		WebSearchEnabled:               initialCfg.WebSearchEnabled,
2200		WebSearchToolAllowed:           &webSearchToolAllowedForInvocation,
2201		WebSearchInvocationOverride:    webSearchInvocationOverride,
2202		AutoSubagentsToolAllowed:       &autoSubagentsToolAllowedForSession,
2203		AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2204		FastMode:                       &fastMode,
2205		LSPEnabled:                     initialCfg.LSPEnabled,
2206		SubagentLSPEnabled:             initialCfg.SubagentLSPEnabled,
2207		AutoCompactThreshold:           initialCfg.AutoCompactThreshold,
2208		JailByDefault:                  initialCfg.JailByDefault,
2209		QuickModelShortcuts:            quickModelShortcuts,
2210		RecursiveFileSuggest:           initialCfg.RecursiveFileSuggest,
2211		RespectGitignore:               initialCfg.RespectGitignore,
2212		CompactMode:                    initialCfg.CompactMode,
2213		TUIInputStyle:                  initialCfg.TUIInputStyle,
2214		TUIStatusPosition:              initialCfg.TUIStatusPosition,
2215		TUIWorkingPosition:             initialCfg.TUIWorkingPosition,
2216		TUISubagentPosition:            initialCfg.TUISubagentPosition,
2217		ThemeName:                      initialCfg.Theme,
2218		FlatTools:                      initialCfg.FlatToolRender(),
2219		CompactUser:                    initialCfg.CompactUserInput(),
2220		ExtensionThemes:                extMgr.ThemeOptions,
2221		AutoSubagentsSystemAddendum:    AutoSubagentsSystemAddendum,
2222		SubagentsSystemAddendum:        subagentsAddendum,
2223		SettingsStore:                  configSettingsStore{},
2224		Model:                          r.Model,
2225		Provider:                       r.Provider,
2226		AuthMethod:                     r.AuthMethod,
2227		BaseURL:                        r.BaseURL,
2228		Reasoning:                      r.Reasoning,
2229		SystemPrompt:                   r.SystemPrompt,
2230		Tools:                          r.ToolRegistry,
2231		MaxSteps:                       r.MaxSteps,
2232		CWD:                            r.CWD,
2233		StartupAgentName:               args.AgentName,
2234		StartupContextPaths:            instructionContextPaths(r.ContextFiles),
2235		StartupExtensionNames:          startupExtensionNames(extMgr.All()),
2236		StartupExtensionErrors:         startupExtensionErrors,
2237		StartupSkillNames:              startupSkillNames(startupSkills),
2238		ShowInstructionsAtStartup:      initialCfg.ShowInstructionsAtStartup,
2239		ZutHome:                        ZutHome(),
2240		SessionsRoot:                   agentSessionsRoot(ZutHome(), args),
2241		Version:                        version,
2242		UpdateInfoChan:                 updateCh,
2243		Sandbox:                        sharedSandbox,
2244		Agent:                          ag,
2245		InitialSessionTitle: func() string {
2246			if sess == nil || sessionTitlePending {
2247				return ""
2248			}
2249			return sess.Title
2250		}(),
2251		InitialSessionTitlePending: sessionTitlePending,
2252		InitialInput:               args.Prompt,
2253		StartupPre:                 args.StartupPre,
2254		OnStartupPreDone: func() {
2255			// entry.pre often installs skills/extensions; rediscover them
2256			// before the user starts a model turn.
2257			current := liveInteractiveAgent(iv, ag)
2258			if extMgr != nil {
2259				_ = extMgr.Reload(context.Background(), 2*time.Second)
2260			}
2261			if current != nil {
2262				if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil {
2263					iv.ReportError(err)
2264				}
2265			}
2266		},
2267		RefreshTools: func() error {
2268			current := liveInteractiveAgent(iv, ag)
2269			webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2270			if err != nil {
2271				if !errors.Is(err, errInteractiveAgentChanged) {
2272					setWebSearchAvailable(false)
2273				}
2274				return err
2275			}
2276			setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2277			return nil
2278		},
2279		SetWebSearchAvailable: setWebSearchAvailable,
2280		RefreshPrompt: func() error {
2281			current := liveInteractiveAgent(iv, ag)
2282			webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2283			if err != nil {
2284				return err
2285			}
2286			setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2287			return nil
2288		},
2289		AuthManager:                mgr,
2290		LlamaCPPConfig:             ResolveLlamaCPPConfig,
2291		RefreshLlamaCPPModels:      RefreshLlamaCPPModels,
2292		BuildAgent:                 buildAgent,
2293		SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
2294		BuildAgentFor:              buildAgentFor,
2295		BuildAgentForRescue:        buildAgentForRescue,
2296		LoggedInProviders: func() []string {
2297			var out []string
2298			seen := map[string]bool{}
2299			for _, p := range knownProviders {
2300				if CredentialAvailable(p) && !seen[p] {
2301					out = append(out, p)
2302					seen[p] = true
2303				}
2304			}
2305			// Include custom providers that have credentials stored.
2306			for p := range provider.CustomProviders() {
2307				if CredentialAvailable(p) && !seen[p] {
2308					out = append(out, p)
2309					seen[p] = true
2310				}
2311			}
2312			// Ollama models are always available (no auth needed).
2313			if !seen["ollama"] {
2314				out = append(out, "ollama")
2315			}
2316			return out
2317		},
2318		LoadSession: loadSession,
2319		ChangeCWD:   changeCWD,
2320		CurrentSessionPath: func() string {
2321			persistMu.Lock()
2322			defer persistMu.Unlock()
2323			if sess == nil {
2324				return ""
2325			}
2326			return sess.Path
2327		},
2328		FlushSession: func() {
2329			sessionTransitionMu.RLock()
2330			defer sessionTransitionMu.RUnlock()
2331			// Append any not-yet-persisted agent messages to the
2332			// current session file, then advance the baseline so
2333			// the final WriteNewTranscript at exit doesn't write
2334			// duplicates. Per-message persistence keeps the on-
2335			// disk file current already, so this is mostly a
2336			// defensive flush — still needed for /session export
2337			// to guarantee the exported bytes include the very
2338			// last in-flight turn.
2339			currentAg := iv.Agent()
2340			if currentAg == nil {
2341				return
2342			}
2343			persistMu.Lock()
2344			defer persistMu.Unlock()
2345			if sess == nil {
2346				return
2347			}
2348			writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2349			sessBaselineMsgs = len(currentAg.Messages())
2350		},
2351		SessionTransition: newSessionTransition(&sessionTransitionMu),
2352		Extensions:        extMgr,
2353		Supervisor:        subagentsMgr,
2354		ResolveSubagent:   resolveSubagent,
2355		ChangelogChan:     changelogCh,
2356		OnChangelogDismiss: func() {
2357			// For development builds, store the actual release version
2358			// so the same changelog doesn't show again next launch.
2359			// For real builds, store the binary version.
2360			v := version
2361			if devBuild {
2362				if iv != nil && iv.ChangelogVersion() != "" {
2363					v = iv.ChangelogVersion()
2364				}
2365			}
2366			_ = MarkChangelogShown(v)
2367		},
2368		SkillSnapshot: func() []*skills.Skill {
2369			if args.NoSkill {
2370				// --no-skill: nothing for the picker to show.
2371				return nil
2372			}
2373			// Re-discover so the picker reflects edits made during
2374			// the session. Cheap; SKILL.md files are small. Filter
2375			// out built-in skills — they're hidden from user-facing
2376			// surfaces because they're implementation detail; the
2377			// model still sees them through the system-prompt
2378			// manifest and the skill tool.
2379			userHome, _ := os.UserHomeDir()
2380			list, _ := skills.Discover(ZutHome(), r.CWD, userHome, args.WithSkills)
2381			list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills())
2382			return skills.VisibleSkills(list)
2383		},
2384		NoYolo:      args.NoYolo,
2385		ConfirmGate: confirmGate,
2386		PersistModel: newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, func(message string) {
2387			if iv != nil {
2388				iv.Notify("session", "error", message)
2389			}
2390		}),
2391		CurrentSessionTitle: func() string {
2392			sessionTransitionMu.RLock()
2393			defer sessionTransitionMu.RUnlock()
2394			persistMu.Lock()
2395			defer persistMu.Unlock()
2396			if sess == nil || sessionTitlePending {
2397				return ""
2398			}
2399			return sess.Title
2400		},
2401		CurrentSessionTitlePending: func() bool {
2402			sessionTransitionMu.RLock()
2403			defer sessionTransitionMu.RUnlock()
2404			persistMu.Lock()
2405			defer persistMu.Unlock()
2406			return sessionTitlePending
2407		},
2408		PersistTitle: func(title string) error {
2409			persistMu.Lock()
2410			defer persistMu.Unlock()
2411			if sess == nil {
2412				return nil
2413			}
2414			if err := sess.UpdateTitle(title); err != nil {
2415				return err
2416			}
2417			sessionTitlePending = false
2418			return nil
2419		},
2420		OnSessionTitleChanged: func(title string) {
2421			sessionTransitionMu.RLock()
2422			defer sessionTransitionMu.RUnlock()
2423			persistMu.Lock()
2424			defer persistMu.Unlock()
2425			if sess != nil {
2426				sess.Title = title
2427				sessionTitlePending = false
2428			}
2429		},
2430	})
2431	extHooks.attachInteractive(iv)
2432
2433	// Bind the interactive TUI as the Confirmer. We deferred this
2434	// until now because the gate is constructed before the TUI
2435	// (the BeforeToolExecute closure captures it). SetConfirmer
2436	// is mutex-guarded on the gate so this is safe.
2437	if confirmGate != nil {
2438		confirmGate.SetConfirmer(&confirmationEventConfirmer{
2439			inner: iv,
2440			emit:  extMgr.EmitEvent,
2441		})
2442	}
2443
2444	// Signal-driven flush: a SIGTERM / SIGHUP to the zut process
2445	// (closed terminal window, system shutdown, kill) used to lose
2446	// the entire in-memory transcript because the deferred post-Run
2447	// flush below never ran. Per-message persistence above covers
2448	// most of it; this handler writes any in-flight remainder and
2449	// then exits the process so we don't double-paint over a
2450	// broken terminal that the TUI's restore deferreds can no
2451	// longer fix from a signal context.
2452	//
2453	// SIGINT is intentionally NOT handled here — the TUI consumes
2454	// Ctrl+C as a regular key event for cancel/clear semantics, and
2455	// installing a SIGINT notifier here would swallow it.
2456	sigCh := make(chan os.Signal, 1)
2457	signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
2458	defer signal.Stop(sigCh)
2459	go func() {
2460		_, ok := <-sigCh
2461		if !ok {
2462			return
2463		}
2464		finalAg := iv.Agent()
2465		if finalAg != nil {
2466			persistMu.Lock()
2467			if sess != nil {
2468				writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2469				sessBaselineMsgs = len(finalAg.Messages())
2470				_ = sess.Close()
2471				sess = nil
2472			}
2473			persistMu.Unlock()
2474		}
2475		closeAgentLSP(finalAg)
2476		closeResolvedLSP(r)
2477		// Stop supervised workers before exiting. os.Exit skips deferred
2478		// cleanup, so relying only on the normal runInteractive defer would
2479		// leave child daemons (and their descendants) alive after a TERM or
2480		// HUP delivered to the host.
2481		if subagentsMgr != nil {
2482			subagentsMgr.StopAll()
2483		}
2484		// Exit cleanly. Re-raising the signal would skip os.Exit's
2485		// at-exit hooks; explicit exit is fine because we've already
2486		// flushed the only at-risk state (the session file) and stopped
2487		// supervised workers.
2488		os.Exit(0)
2489	}()
2490
2491	runErr := iv.Run(ctx)
2492
2493	// Flush final transcript to session (only if we had / ended up with an agent).
2494	if finalAg := iv.Agent(); finalAg != nil {
2495		persistMu.Lock()
2496		if sess != nil {
2497			writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2498			sessBaselineMsgs = len(finalAg.Messages())
2499		}
2500		persistMu.Unlock()
2501	}
2502	return runErr
2503}
2504
2505func agentSessionsRoot(root string, args Args) string {
2506	if args.AgentName == "" {
2507		return root
2508	}
2509	return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName))
2510}
2511
2512// openOrCreateSession returns a session for the run. sess may be nil
2513// with a nil error if session persistence is disabled.
2514func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
2515	if args.NoSess {
2516		return nil, nil
2517	}
2518	// Sweep meta-only files left over from older zut versions (and from
2519	// any session that crashed before its first AppendMessage). Cheap;
2520	// reads the first few bytes of each file in the cwd's session dir.
2521	sessionsRoot := agentSessionsRoot(ZutHome(), args)
2522	core.PruneEmptySessions(sessionsRoot, args.CWD)
2523	var (
2524		s    *core.Session
2525		msgs []provider.Message
2526		err  error
2527	)
2528	switch {
2529	case args.Session != "":
2530		s, msgs, err = core.OpenSession(args.Session)
2531		// The subagent-worker child passes a fixed --session path that
2532		// may not exist yet on first Spawn. Treat ENOENT as "create
2533		// a fresh session AT THIS PATH" so the conversation actually
2534		// gets persisted; without this fallback the subagent child runs
2535		// with sess==nil and every Resume re-starts with no memory
2536		// of the prior turns. Other openers (--continue / --resume /
2537		// the picker) never see ENOENT here because they only choose
2538		// paths that already exist on disk.
2539		if err != nil && errors.Is(err, os.ErrNotExist) {
2540			s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
2541			msgs = nil
2542		}
2543	case args.Continue:
2544		latest := core.LatestSession(sessionsRoot, args.CWD)
2545		if latest != "" {
2546			s, msgs, err = core.OpenSession(latest)
2547		}
2548	case args.Resume:
2549		picked, perr := pickSession(sessionsRoot, args.CWD)
2550		if perr != nil {
2551			return nil, perr
2552		}
2553		if picked != "" {
2554			s, msgs, err = core.OpenSession(picked)
2555		}
2556	}
2557	if err != nil {
2558		return nil, err
2559	}
2560	if s != nil {
2561		ag.SetMessages(msgs)
2562		if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
2563			ag.SeedCost(cum)
2564			ag.SeedLastTurnUsage(last)
2565		}
2566		return s, nil
2567	}
2568	return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2569}
2570
2571func pickSession(root, cwd string) (string, error) {
2572	files := core.ListSessions(root, cwd)
2573	if len(files) == 0 {
2574		fmt.Fprintln(os.Stderr, "no sessions for", cwd)
2575		return "", nil
2576	}
2577	for i, f := range files {
2578		fmt.Fprintf(os.Stderr, "  %2d) %s\n", i+1, f)
2579	}
2580	fmt.Fprint(os.Stderr, "pick #: ")
2581	rd := bufio.NewReader(os.Stdin)
2582	line, _ := rd.ReadString('\n')
2583	line = strings.TrimSpace(line)
2584	var n int
2585	if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
2586		return "", fmt.Errorf("invalid selection")
2587	}
2588	return files[n-1], nil
2589}
2590
2591// WriteNewTranscript appends only messages after index `from` from the
2592// agent's transcript to the session. Used by callers that don't hold
2593// the persistMu (non-interactive print/json modes which run a single
2594// turn under their own goroutine).
2595func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
2596	writeNewTranscriptLocked(ag, sess, from)
2597}
2598
2599// writeNewTranscriptLocked is the same as WriteNewTranscript. The
2600// suffix marks that interactive callers must hold persistMu when
2601// invoking it so concurrent appends from the agent loop don't race
2602// with this catch-up flush.
2603func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) {
2604	if sess == nil || ag == nil {
2605		return
2606	}
2607	msgs := ag.Messages()
2608	for i := from; i < len(msgs); i++ {
2609		_ = sess.AppendMessage(msgs[i])
2610	}
2611	cum := ag.Cost()
2612	_ = sess.AppendUsage(cum, cum)
2613}
2614
2615func readAllStdin() (string, error) {
2616	fi, err := os.Stdin.Stat()
2617	if err != nil {
2618		return "", err
2619	}
2620	if (fi.Mode() & os.ModeCharDevice) != 0 {
2621		return "", nil
2622	}
2623	b, err := io.ReadAll(os.Stdin)
2624	return string(b), err
2625}
2626
2627func printModels() {
2628	models := provider.Active()
2629
2630	// Compute column widths from actual data so wide providers (e.g.
2631	// xiaomi-token-plan-sgp) and long bedrock model ids don't force the
2632	// `name` column off-screen. Floors mirror the historical layout so
2633	// short catalogs look the same as before.
2634	provW, idW, srcW := len("provider"), len("model id"), len("source")
2635	for _, m := range models {
2636		if w := len(m.Provider); w > provW {
2637			provW = w
2638		}
2639		if w := len(m.ID); w > idW {
2640			idW = w
2641		}
2642		source := m.Source
2643		if source == "" {
2644			source = "catalog"
2645		}
2646		if m.Speculative {
2647			source = "speculative"
2648		}
2649		if w := len(source); w > srcW {
2650			srcW = w
2651		}
2652	}
2653
2654	header := fmt.Sprintf("%-*s  %-*s  %8s  %8s  %s  %-*s  %s",
2655		provW, "provider",
2656		idW, "model id",
2657		"context", "max-out", "reasoning",
2658		srcW, "source",
2659		"name")
2660	fmt.Println(header)
2661
2662	for _, m := range models {
2663		reason := " "
2664		if m.Reasoning {
2665			reason = "✓"
2666		}
2667		source := m.Source
2668		if source == "" {
2669			source = "catalog"
2670		}
2671		if m.Speculative {
2672			source = "speculative"
2673		}
2674		fmt.Printf("%-*s  %-*s  %8d  %8d     %s      %-*s  %s\n",
2675			provW, m.Provider,
2676			idW, m.ID,
2677			m.ContextWindow, m.MaxOutput,
2678			reason,
2679			srcW, source,
2680			m.DisplayName)
2681	}
2682}

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