v0.23.0 edaaa7c

cli.go RawHistoryFolder

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

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