v0.4.0 a622146

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

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