v0.1.1 7141aff

cli.go RawHistoryFolder

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

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