v0.3.67 80dd4c6

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	"strings"
14	"sync"
15	"syscall"
16	"time"
17
18	"github.com/patriceckhart/zot/packages/agent/extensions"
19	"github.com/patriceckhart/zot/packages/agent/extproto"
20	"github.com/patriceckhart/zot/packages/agent/modes"
21	"github.com/patriceckhart/zot/packages/agent/skills"
22	"github.com/patriceckhart/zot/packages/agent/swarm"
23	"github.com/patriceckhart/zot/packages/agent/tools"
24	"github.com/patriceckhart/zot/packages/core"
25	"github.com/patriceckhart/zot/packages/provider"
26	"github.com/patriceckhart/zot/packages/provider/auth"
27	"github.com/patriceckhart/zot/packages/tui"
28)
29
30// interactiveExtHooks is a tiny adapter that lets the extension
31// manager call back into the Interactive instance built later in
32// runInteractive. The forward-declared *modes.Interactive is filled
33// in immediately after manager construction.
34type interactiveExtHooks struct {
35	ivPtr **modes.Interactive
36}
37
38func (h *interactiveExtHooks) iv() *modes.Interactive {
39	if h == nil || h.ivPtr == nil {
40		return nil
41	}
42	return *h.ivPtr
43}
44
45func (h *interactiveExtHooks) Notify(extName, level, message string) {
46	if iv := h.iv(); iv != nil {
47		iv.Notify(extName, level, message)
48	}
49}
50func (h *interactiveExtHooks) Submit(text string) {
51	if iv := h.iv(); iv != nil {
52		iv.SubmitOrQueue(text, nil)
53	}
54}
55func (h *interactiveExtHooks) SubmitSlash(text string) {
56	if iv := h.iv(); iv != nil {
57		iv.SubmitSlash(text)
58	}
59}
60func (h *interactiveExtHooks) Insert(text string) {
61	if iv := h.iv(); iv != nil {
62		iv.Insert(text)
63	}
64}
65func (h *interactiveExtHooks) Display(extName, text string) {
66	if iv := h.iv(); iv != nil {
67		iv.Display(extName, text)
68	}
69}
70func (h *interactiveExtHooks) ClearNotes(extName string) {
71	if iv := h.iv(); iv != nil {
72		iv.ClearNotes(extName)
73	}
74}
75func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
76	if iv := h.iv(); iv != nil {
77		iv.OpenPanel(extName, spec)
78	}
79}
80func (h *interactiveExtHooks) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
81	if iv := h.iv(); iv != nil {
82		iv.UpdatePanel(extName, panelID, title, lines, footer)
83	}
84}
85func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
86	if iv := h.iv(); iv != nil {
87		iv.ClosePanel(extName, panelID)
88	}
89}
90
91// extToolAdapter bridges *extensions.Manager to the
92// ExtensionToolSource interface declared in build.go (kept narrow to
93// avoid a build->extensions import cycle). One adapter instance per
94// run; used at every Resolve point so re-built agents pick up the
95// same set of extension tools.
96type extToolAdapter struct {
97	mgr *extensions.Manager
98}
99
100func (a *extToolAdapter) Tools() []ExtensionToolInfo {
101	infos := a.mgr.Tools()
102	out := make([]ExtensionToolInfo, len(infos))
103	for i, t := range infos {
104		out[i] = ExtensionToolInfo{
105			Extension:   t.Extension,
106			Name:        t.Name,
107			Description: t.Description,
108			Schema:      t.Schema,
109			Deferred:    t.Deferred,
110		}
111	}
112	return out
113}
114
115func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
116	return extensions.NewTool(a.mgr, extensions.ToolInfo{
117		Extension:   info.Extension,
118		Name:        info.Name,
119		Description: info.Description,
120		Schema:      info.Schema,
121		Deferred:    info.Deferred,
122	})
123}
124
125// liveInteractiveAgent returns the agent currently owned by the TUI. The
126// fallback is only used before the Interactive has been constructed.
127//
128// Agent rebuilds, such as cross-provider model switches, replace the TUI's
129// agent without replacing the startup pointer held by runInteractive. Session
130// operations must therefore resolve the live agent at the time of the action.
131func liveInteractiveAgent(iv *modes.Interactive, fallback *core.Agent) *core.Agent {
132	if iv != nil {
133		return iv.Agent()
134	}
135	return fallback
136}
137
138func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
139	if keepTail <= 0 || len(msgs) <= keepTail {
140		return provider.RepairOrphanedToolResults(msgs)
141	}
142	var out []provider.Message
143	start := len(msgs) - keepTail
144	// Preserve the synthetic compaction summary when present so an
145	// already-compacted session stays compacted after resume.
146	if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 1 {
147		out = append(out, msgs[0])
148	}
149	// Avoid hydrating a tail that starts with orphan tool_result rows;
150	// provider APIs require those to be paired with an earlier tool_use.
151	for start < len(msgs) && msgs[start].Role == provider.RoleTool {
152		start++
153	}
154	out = append(out, msgs[start:]...)
155	return provider.RepairOrphanedToolResults(out)
156}
157
158// fanoutAgentEvent translates a core.AgentEvent into the wire-format
159// EventFromHost and pushes it through the extension manager. Only
160// the events that have a clear extension-facing meaning are
161// forwarded; internal-only ones (text_delta, tool_progress) are
162// dropped to keep the per-extension stream sane.
163func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
164	if mgr == nil {
165		return
166	}
167	switch e := ev.(type) {
168	case core.EvTurnStart:
169		mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
170	case core.EvToolCall:
171		mgr.EmitEvent(extproto.EventFromHost{
172			Event: "tool_call", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args,
173		})
174	case core.EvAssistantMessage:
175		// Concat the visible text portions of the message; binary
176		// blocks (tool_use, etc.) are skipped because subscribers
177		// usually want a string they can grep / display.
178		var text string
179		for _, c := range e.Message.Content {
180			if tb, ok := c.(provider.TextBlock); ok {
181				text += tb.Text
182			}
183		}
184		mgr.EmitEvent(extproto.EventFromHost{Event: "assistant_message", Text: text})
185	case core.EvTurnEnd:
186		ev := extproto.EventFromHost{Event: "turn_end", Stop: string(e.Stop)}
187		if e.Err != nil {
188			ev.Error = e.Err.Error()
189		}
190		mgr.EmitEvent(ev)
191	}
192}
193
194// Run is the top-level entrypoint for the zot binary.
195func Run(rawArgs []string, version string) error {
196	// Apply network configuration before any subcommand can make an HTTP
197	// request. Standard proxy environment variables retain precedence.
198	applyConfiguredHTTPProxy()
199
200	// Subcommand router: `zot bot ...` is handled separately so the
201	// generic flag parser doesn't reject "bot" as a positional arg.
202	if handled, err := runBotCommand(rawArgs, version); handled {
203		return err
204	}
205	if handled, err := runExtCommand(rawArgs, version); handled {
206		return err
207	}
208	if handled, err := runUpdateCommand(rawArgs, version); handled {
209		return err
210	}
211	if handled, err := runSessionsCommand(rawArgs); handled {
212		return err
213	}
214	if handled, err := runZotfileCommand(rawArgs, version); handled {
215		return err
216	}
217	return runWithArgsRaw(rawArgs, version)
218}
219
220func runWithArgsRaw(rawArgs []string, version string) error {
221	// `zot rpc` is shorthand for `zot --rpc` so third-party apps can
222	// spawn the binary with a clean argv. Strip the leading 'rpc'
223	// token and let the rest flow through the normal arg parser.
224	if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
225		rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
226	}
227
228	if fi, err := os.Stdin.Stat(); err == nil {
229		rawArgs = argsForStdin(rawArgs, fi.Mode())
230	}
231	args, err := ParseArgs(rawArgs)
232	if err != nil {
233		PrintHelp(version)
234		return err
235	}
236	if args.Help {
237		printHelp(os.Stdout, version)
238		return nil
239	}
240	if args.Version {
241		fmt.Println("zot", version)
242		return nil
243	}
244	// Model catalog: load any cached discovery data before we inspect
245	// the model list (list-models, print/json, interactive).
246	prepareRuntimeCatalog()
247
248	if args.ListModels {
249		printModels()
250		return nil
251	}
252
253	return runWithArgs(args, version)
254}
255
256func runWithArgs(args Args, version string) error {
257	ctx := context.Background()
258	switch args.Mode {
259	case ModePrint:
260		return runPrintMode(ctx, args, version)
261	case ModeStream:
262		return runStreamMode(ctx, args, version)
263	case ModeJSON:
264		return runJSONMode(ctx, args, version)
265	case ModeRPC:
266		return runRPCMode(ctx, args, version)
267	case ModeSwarmAgent:
268		return runSwarmAgentMode(ctx, args, version)
269	default:
270		return runInteractive(ctx, args, version)
271	}
272}
273
274// ---- print / json modes: require credentials, run single-shot ----
275
276// nonInteractiveExtHooks is the HostHooks impl used by print / json
277// modes. They have no TUI, so notify / display go to stderr and
278// submit / insert are no-ops (the extension can't steer a
279// single-shot run once it's in flight anyway).
280type nonInteractiveExtHooks struct{}
281
282func (nonInteractiveExtHooks) Notify(ext, level, message string) {
283	fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
284}
285func (nonInteractiveExtHooks) Submit(string)                                        {}
286func (nonInteractiveExtHooks) SubmitSlash(string)                                   {}
287func (nonInteractiveExtHooks) Insert(string)                                        {}
288func (nonInteractiveExtHooks) Display(string, string)                               {}
289func (nonInteractiveExtHooks) ClearNotes(string)                                    {}
290func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec)                 {}
291func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
292func (nonInteractiveExtHooks) ClosePanel(string, string)                            {}
293
294// setupNonInteractiveExtensions loads --ext paths and (unless
295// --no-ext) runs discovery. Returns the manager so the caller can
296// wire tools into the resolved registry, and a cleanup closure to
297// defer. Mirrors the interactive-mode setup minus the TUI hooks.
298func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) {
299	reportSkillDiagnostics(os.Stderr, r.SkillDiagnostics)
300	extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{})
301	for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
302		fmt.Fprintln(os.Stderr, "extension load:", e)
303	}
304	if !args.NoExt {
305		for _, e := range extMgr.Discover(ctx) {
306			fmt.Fprintln(os.Stderr, "extension load:", e)
307		}
308	}
309	extMgr.WaitForReady(3 * time.Second)
310	r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
311	extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
312	return extMgr, func() { extMgr.Stop(2 * time.Second) }
313}
314
315func reportSkillDiagnostics(w io.Writer, diagnostics []string) {
316	for _, diagnostic := range diagnostics {
317		fmt.Fprintln(w, "skill discovery:", diagnostic)
318	}
319}
320
321// wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
322// / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
323// interactive path wires up, so extensions get their normal
324// event-intercept surface in print / json / rpc flows too.
325func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) {
326	if ag == nil || extMgr == nil {
327		return
328	}
329	wireBeforeAgentStart(ag, extMgr, "")
330	ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
331		res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
332		if res.Block {
333			return false, res.Reason, nil
334		}
335		return true, "", res.ModifiedArgs
336	}
337	ag.BeforeTurn = func(step int) (bool, string) {
338		res := extMgr.InterceptTurnStart(ctx, step)
339		return !res.Block, res.Reason
340	}
341	ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
342		res := extMgr.InterceptAssistantMessage(ctx, text)
343		if res.Block {
344			return false, res.Reason, ""
345		}
346		return true, "", res.ReplaceText
347	}
348	ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
349}
350
351type printStats struct {
352	Provider              string `json:"provider"`
353	Model                 string `json:"model"`
354	PromptTokens          int    `json:"prompt_tokens"`
355	ReasoningTokens       *int   `json:"reasoning_tokens"`
356	GeneratedOutputTokens int    `json:"generated_output_tokens"`
357	ElapsedMS             int64  `json:"elapsed_ms"`
358}
359
360func writePrintStats(path, providerID, model string, usage provider.Usage, elapsed time.Duration) error {
361	var reasoning *int
362	generated := usage.OutputTokens
363	if usage.ReasoningTokensKnown {
364		count := usage.ReasoningTokens
365		reasoning = &count
366		generated -= count
367		if generated < 0 {
368			generated = 0
369		}
370	}
371	stats := printStats{
372		Provider:              providerID,
373		Model:                 model,
374		PromptTokens:          usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens,
375		ReasoningTokens:       reasoning,
376		GeneratedOutputTokens: generated,
377		ElapsedMS:             elapsed.Milliseconds(),
378	}
379	data, err := json.MarshalIndent(stats, "", "  ")
380	if err != nil {
381		return fmt.Errorf("encode stats: %w", err)
382	}
383	data = append(data, '\n')
384	if err := os.WriteFile(path, data, 0o644); err != nil {
385		return fmt.Errorf("write stats %q: %w", path, err)
386	}
387	return nil
388}
389
390func runPrintMode(ctx context.Context, args Args, version string) error {
391	if args.NoYolo {
392		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
393	}
394	r, err := Resolve(args, true)
395	if err != nil {
396		return err
397	}
398	extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
399	defer stopExt()
400
401	ag := r.NewAgent()
402	wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
403	sess, _ := openOrCreateSession(args, r, ag, version)
404	defer sess.Close()
405
406	piped, _ := readAllStdin()
407	prompt := combinePromptInput(piped, args.Prompt)
408	if prompt == "" {
409		return fmt.Errorf("print mode requires a prompt (arg or stdin)")
410	}
411
412	start := len(ag.Messages())
413	if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {
414		return err
415	}
416	reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
417	started := time.Now()
418	usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
419	elapsed := time.Since(started)
420	WriteNewTranscript(ag, sess, start)
421	if err != nil {
422		return err
423	}
424	if args.StatsPath != "" {
425		return writePrintStats(args.StatsPath, r.Provider, r.Model, usage, elapsed)
426	}
427	return nil
428}
429
430func runStreamMode(ctx context.Context, args Args, version string) error {
431	if args.NoYolo {
432		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
433	}
434	r, err := Resolve(args, true)
435	if err != nil {
436		return err
437	}
438	extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
439	defer stopExt()
440
441	ag := r.NewAgent()
442	wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
443	sess, _ := openOrCreateSession(args, r, ag, version)
444	defer sess.Close()
445
446	piped, _ := readAllStdin()
447	prompt := combinePromptInput(piped, args.Prompt)
448	if prompt == "" {
449		return fmt.Errorf("stream mode requires a prompt (arg or stdin)")
450	}
451
452	start := len(ag.Messages())
453	preSink, finishPre := newStreamTextSink(os.Stdout)
454	if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
455		finishPre()
456		return err
457	}
458	finishPre()
459	reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
460	err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
461	WriteNewTranscript(ag, sess, start)
462	return err
463}
464
465func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
466	var streamed, wroteText, lastWasNL bool
467	writeText := func(text string) {
468		if text == "" {
469			return
470		}
471		_, _ = fmt.Fprint(out, text)
472		if syncer, ok := out.(interface{ Sync() error }); ok {
473			_ = syncer.Sync()
474		}
475		wroteText = true
476		lastWasNL = strings.HasSuffix(text, "\n")
477	}
478	sink := func(ev core.AgentEvent) {
479		switch e := ev.(type) {
480		case core.EvAssistantStart:
481			streamed = false
482		case core.EvTextDelta:
483			streamed = true
484			writeText(e.Delta)
485		case core.EvAssistantMessage:
486			if streamed {
487				return
488			}
489			var text strings.Builder
490			for _, content := range e.Message.Content {
491				if block, ok := content.(provider.TextBlock); ok {
492					if text.Len() > 0 {
493						text.WriteString("\n")
494					}
495					text.WriteString(block.Text)
496				}
497			}
498			writeText(text.String())
499		}
500	}
501	finish := func() {
502		if wroteText && !lastWasNL {
503			writeText("\n")
504		}
505	}
506	return sink, finish
507}
508
509func runJSONMode(ctx context.Context, args Args, version string) error {
510	if args.NoYolo {
511		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
512	}
513	r, err := Resolve(args, true)
514	if err != nil {
515		return err
516	}
517	extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
518	defer stopExt()
519
520	ag := r.NewAgent()
521	wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
522	sess, _ := openOrCreateSession(args, r, ag, version)
523	defer sess.Close()
524
525	piped, _ := readAllStdin()
526	prompt := combinePromptInput(piped, args.Prompt)
527	if prompt == "" {
528		return fmt.Errorf("json mode requires a prompt (arg or stdin)")
529	}
530
531	start := len(ag.Messages())
532	enc := json.NewEncoder(os.Stdout)
533	preSink := func(ev core.AgentEvent) {
534		_ = enc.Encode(modes.EventToJSON(ev))
535	}
536	if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
537		_ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
538		return err
539	}
540	reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
541	err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
542	WriteNewTranscript(ag, sess, start)
543	return err
544}
545
546// runZotfileStartupPre runs entry.pre before the main non-interactive prompt.
547// "!command" values execute via BashTool; other values are sent as an agent turn.
548// shellOut receives live shell chunks (typically os.Stderr); sink receives
549// agent events for plain-text pre (stream mode wires a live text sink).
550func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error {
551	pre = strings.TrimSpace(pre)
552	if pre == "" {
553		return nil
554	}
555	if cmd, ok := modes.ShellEscapeCommand(pre); ok {
556		raw, err := json.Marshal(map[string]any{"command": cmd})
557		if err != nil {
558			return err
559		}
560		if shellOut != nil {
561			fmt.Fprintf(shellOut, "$ %s\n", cmd)
562			if f, ok := shellOut.(interface{ Sync() error }); ok {
563				_ = f.Sync()
564			}
565		}
566		progress := func(chunk string) {
567			if shellOut == nil || chunk == "" {
568				return
569			}
570			fmt.Fprint(shellOut, chunk)
571			if f, ok := shellOut.(interface{ Sync() error }); ok {
572				_ = f.Sync()
573			}
574		}
575		bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
576		res, err := bash.Execute(ctx, raw, progress)
577		if err != nil {
578			return fmt.Errorf("zotfile entry.pre: %w", err)
579		}
580		if res.IsError {
581			var sb strings.Builder
582			for _, c := range res.Content {
583				if tb, ok := c.(provider.TextBlock); ok {
584					sb.WriteString(tb.Text)
585				}
586			}
587			msg := strings.TrimSpace(sb.String())
588			if msg == "" {
589				msg = "command failed"
590			}
591			return fmt.Errorf("zotfile entry.pre: %s", msg)
592		}
593		return nil
594	}
595	if ag == nil {
596		return fmt.Errorf("zotfile entry.pre requires an agent for non-shell prompts")
597	}
598	if sink == nil {
599		sink = func(core.AgentEvent) {}
600	}
601	return ag.Prompt(ctx, pre, nil, sink)
602}
603
604// refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
605// skills and currently loaded extension tools) and updates the live
606// agent's registry and system prompt. Used after /reload-ext and after
607// zotfile entry.pre installs new skills or extensions.
608// mutateRegistry, if non-nil, can inject session-specific tools (e.g. swarm_spawn).
609func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) {
610	if ag == nil {
611		return
612	}
613	resolved, err := Resolve(args, true)
614	if err != nil {
615		return
616	}
617	if sharedSandbox != nil {
618		resolved.UseSandbox(sharedSandbox)
619	}
620	if extToolAdapter != nil {
621		resolved.MergeExtensionTools(extToolAdapter)
622	}
623	reg := resolved.ToolRegistry
624	if mutateRegistry != nil {
625		reg = mutateRegistry(reg)
626	}
627	ag.SetTools(reg)
628	ag.SetSystem(resolved.SystemPrompt)
629}
630
631// reloadResourcesAfterStartupPre reloads extensions (if any) and
632// refreshes the agent tool registry + system prompt so skills/extensions
633// installed by entry.pre are visible to the following turn.
634func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) {
635	if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
636		return
637	}
638	adapter := &extToolAdapter{mgr: extMgr}
639	if extMgr != nil {
640		_ = extMgr.Reload(ctx, 2*time.Second)
641	}
642	refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil)
643}
644
645// ---- interactive mode: opens the TUI even without credentials ----
646
647func runInteractive(ctx context.Context, args Args, version string) error {
648	// Resolve WITHOUT requiring credentials.
649	r, err := Resolve(args, false)
650	if err != nil {
651		return err
652	}
653
654	authStore := AuthStoreFor()
655	mgr := auth.NewManager(authStore)
656	defer mgr.Close()
657
658	// Keep the sandbox pointer stable across agent rebuilds (login / model
659	// switch). The Interactive UI toggles the lock via this pointer, and
660	// rebuilt tool instances must share the same one so the lock sticks.
661	sharedSandbox := r.Sandbox
662
663	// Build the extension manager BEFORE the agent so we can fold
664	// extension-defined tools into the registry. Forward-declare iv so
665	// the host hooks adapter can dereference it after construction.
666	var iv *modes.Interactive
667	extHooks := &interactiveExtHooks{ivPtr: &iv}
668	extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
669	startupExtensionErrors := make([]string, 0, len(r.SkillDiagnostics))
670	for _, diagnostic := range r.SkillDiagnostics {
671		startupExtensionErrors = append(startupExtensionErrors, "skill discovery: "+diagnostic)
672	}
673	// --ext paths first so they win against installed extensions of
674	// the same name (loadOne's first-write-wins semantics).
675	for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
676		startupExtensionErrors = append(startupExtensionErrors, e.Error())
677	}
678	// --no-ext skips the global + project-local discovery scan;
679	// explicit --ext paths above are still honoured so you can run
680	// "only this extension" with --no-ext --ext ./x.
681	if !args.NoExt {
682		for _, e := range extMgr.Discover(ctx) {
683			startupExtensionErrors = append(startupExtensionErrors, e.Error())
684		}
685	}
686	// Wait briefly for extensions to flush their initial register_tool
687	// frames before we build the agent's tool registry. Half a second
688	// is plenty for any extension that's actually well-behaved; ones
689	// that don't send a ready frame eat the full grace and proceed.
690	// 3s is the per-extension grace period for the ready frame.
691	// Native binaries are instant; runtimes like `npx tsx` take ~1.5s
692	// from cold cache. The wait is tight only for extensions that
693	// haven't sent ready by then; ones that signalled earlier release
694	// the wait immediately.
695	extMgr.WaitForReady(3 * time.Second)
696	defer extMgr.Stop(2 * time.Second)
697
698	extToolAdapter := &extToolAdapter{mgr: extMgr}
699	r.MergeExtensionTools(extToolAdapter)
700
701	// Build the swarm supervisor BEFORE the agent so the auto-swarm
702	// tool can reference it during tool-registry construction. State
703	// lives under ZotHome/swarm so per-agent meta/events survive
704	// restarts; the user can hunt orphaned agents down with
705	// `git worktree list` if anything misbehaves.
706	//
707	// swarmMgr is also captured by loadSession / changeCWD closures
708	// further down the function, which is why we keep the variable
709	// in this outer scope rather than scoping it tighter.
710	var swarmMgr *swarm.Swarm
711	swarmMgr = swarm.New(swarm.Config{
712		Root:     filepath.Join(ZotHome(), "swarm"),
713		RepoRoot: r.CWD,
714		ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) {
715			if providerID == "ollama" {
716				return swarm.Credential{Value: "ollama", Method: "apikey"}, nil
717			}
718			credential, method, accountID, err := ResolveCredentialFullContext(ctx, providerID, "")
719			if err != nil {
720				// Providers backed only by a local endpoint do not need a key.
721				// Leave stdin untouched and let the child resolve that endpoint.
722				if !CredentialAvailable(providerID) {
723					return swarm.Credential{}, nil
724				}
725				return swarm.Credential{}, err
726			}
727			return swarm.Credential{Value: credential, Method: method, AccountID: accountID}, nil
728		},
729	})
730	// Pull any previously-spawned agents off disk so the dashboard
731	// shows them as detached and the user can resume / remove them.
732	_, _ = swarmMgr.Reload()
733
734	// onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool
735	// fires after every successful spawn. It hands the agent off to
736	// the running Interactive so the watcher can flush a summary back
737	// into chat when all sub-agents finish. Reads `iv` lazily because
738	// the Interactive is constructed after the agent.
739	onSpawnedSwarm := func(a *swarm.Agent, task string) {
740		if iv != nil {
741			iv.TrackSwarmAgent(a, task)
742		}
743	}
744
745	// Inject the swarm_spawn auto-swarm tool only when /settings ->
746	// auto-swarm is currently enabled. Registering it unconditionally
747	// leaves the model trying to call it (and getting a polite error)
748	// even when the user has switched the feature off. The /settings
749	// toggle live-mutates the running agent's registry separately so
750	// flipping the flag mid-session takes effect on the next turn.
751	injectSwarmSpawn := func(reg core.Registry) core.Registry {
752		if reg == nil {
753			return reg
754		}
755		if !AutoSwarmEnabled() {
756			return reg
757		}
758		reg["swarm_spawn"] = &tools.SwarmSpawnTool{
759			Swarm:           swarmMgr,
760			Enabled:         AutoSwarmEnabled,
761			DefaultModel:    func() string { return r.Model },
762			DefaultProvider: func() string { return r.Provider },
763			OnSpawned:       onSpawnedSwarm,
764		}
765		return reg
766	}
767	injectSwarmSpawn(r.ToolRegistry)
768
769	// Confirmation gate: when --no-yolo is on, the agent must ask
770	// the user before every tool call. In interactive mode the TUI
771	// provides the Confirmer; in print/json/rpc modes there's no
772	// way to prompt, so the gate is constructed with a nil inner
773	// which auto-refuses every call with a helpful reason.
774	var confirmGate *core.ConfirmGate
775	if args.NoYolo {
776		confirmGate = core.NewConfirmGate(nil) // set below for interactive
777	}
778
779	// Capture current args in a closure so BuildAgent can re-resolve
780	// after a successful login (picks up the newly stored credential).
781	wireAgentExt := func(resolved Resolved) *core.Agent {
782		a := resolved.NewAgent()
783		wireBeforeAgentStart(a, extMgr, resolved.Provider)
784		a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
785			// Guards run before confirmation because they may rewrite the
786			// arguments. The user must approve the effective call that will
787			// actually execute, not the model's original arguments.
788			r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
789			if r.Block {
790				return false, r.Reason, nil
791			}
792			effectiveArgs := call.Arguments
793			if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
794				effectiveArgs = r.ModifiedArgs
795			}
796			if confirmGate != nil {
797				var content strings.Builder
798				if tool, err := a.Tools.Get(call.Name); err == nil {
799					if previewer, ok := tool.(core.ToolPreviewer); ok {
800						preview, err := previewer.Preview(ctx, effectiveArgs)
801						if err != nil {
802							return false, err.Error(), nil
803						}
804						for _, block := range preview.Content {
805							if text, ok := block.(provider.TextBlock); ok {
806								if content.Len() > 0 {
807									content.WriteString("\n")
808								}
809								content.WriteString(text.Text)
810							}
811						}
812					}
813				}
814				ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
815					ID:      call.ID,
816					Name:    call.Name,
817					Summary: core.BuildPreview(effectiveArgs, 120),
818					Content: content.String(),
819				})
820				if !ok {
821					return false, reason, nil
822				}
823			}
824			return true, "", r.ModifiedArgs
825		}
826		a.BeforeTurn = func(step int) (bool, string) {
827			r := extMgr.InterceptTurnStart(ctx, step)
828			return !r.Block, r.Reason
829		}
830		a.BeforeAssistantMessage = func(text string) (bool, string, string) {
831			r := extMgr.InterceptAssistantMessage(ctx, text)
832			if r.Block {
833				return false, r.Reason, ""
834			}
835			return true, "", r.ReplaceText
836		}
837		a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
838		return a
839	}
840
841	buildAgent := func() (*core.Agent, string, string, error) {
842		resolved, err := Resolve(args, true)
843		if err != nil {
844			return nil, "", "", err
845		}
846		resolved.UseSandbox(sharedSandbox)
847		resolved.MergeExtensionTools(extToolAdapter)
848		injectSwarmSpawn(resolved.ToolRegistry)
849		return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil
850	}
851
852	// Rebuild agent with an explicit provider/model override.
853	buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
854		next := args
855		if providerOverride != "" {
856			next.Provider = providerOverride
857		}
858		if modelOverride != "" {
859			next.Model = modelOverride
860		}
861		resolved, err := Resolve(next, true)
862		if err != nil {
863			return nil, "", "", err
864		}
865		resolved.UseSandbox(sharedSandbox)
866		resolved.MergeExtensionTools(extToolAdapter)
867		injectSwarmSpawn(resolved.ToolRegistry)
868		return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil
869	}
870
871	// Rebuild agent for the rescue picker after a recoverable failure.
872	// Unlike buildAgentFor, this drops launch-time --api-key and
873	// --base-url overrides because those are typically the cause of the
874	// rescue (a bad key, a typo'd base URL, or a corporate gateway that
875	// only the originally-picked provider needed). Re-resolving without
876	// them lets the rescue retry use env vars / auth.json / provider
877	// defaults the way zot would have without the overrides.
878	buildAgentForRescue := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
879		next := args
880		next.APIKey = ""
881		next.BaseURL = ""
882		if providerOverride != "" {
883			next.Provider = providerOverride
884		}
885		if modelOverride != "" {
886			next.Model = modelOverride
887		}
888		resolved, err := Resolve(next, true)
889		if err != nil {
890			return nil, "", "", err
891		}
892		resolved.UseSandbox(sharedSandbox)
893		resolved.MergeExtensionTools(extToolAdapter)
894		injectSwarmSpawn(resolved.ToolRegistry)
895		return wireAgentExt(resolved), resolved.Provider, resolved.Model, nil
896	}
897
898	var ag *core.Agent
899	if r.HasCredential() {
900		ag = wireAgentExt(r)
901	}
902
903	// /reload-ext callback: after the manager has respawned every
904	// extension, re-resolve the tool registry (built-ins + freshly-
905	// registered extension tools) and swap it onto the current
906	// agent in-place. The current agent may have been replaced by a
907	// /model swap since spawn, so re-read the live `ag` on each
908	// invocation.
909	extMgr.SetOnReload(func() {
910		current := liveInteractiveAgent(iv, ag)
911		if current == nil {
912			return
913		}
914		refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
915	})
916
917	// Fire session_start once we know the manager's running.
918	extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
919
920	var sess *core.Session
921	var sessBaselineMsgs int // messages already on disk when current session opened
922	// persistMu guards sess + sessBaselineMsgs against concurrent access
923	// from the agent loop's per-message persistence hook (runs on the
924	// agent goroutine) and the TUI's session swap / flush callbacks
925	// (run on the TUI goroutine). Without this, a /sessions swap that
926	// races with a finishing turn could double-write or lose messages.
927	var persistMu sync.Mutex
928	if !args.NoSess && ag != nil {
929		sess, _ = openOrCreateSession(args, r, ag, version)
930		if ag != nil {
931			sessBaselineMsgs = len(ag.Messages())
932		}
933	}
934	defer func() {
935		persistMu.Lock()
936		defer persistMu.Unlock()
937		if sess != nil {
938			sess.Close()
939		}
940	}()
941
942	// persistMessage is the per-message hook bound to the agent. It
943	// appends each new transcript message to the live session as soon
944	// as it lands, so a kill / closed terminal / OS crash costs at
945	// most the in-flight turn instead of the whole session. The
946	// baseline counter advances in lock-step so the exit-time flush
947	// doesn't double-write rows already on disk.
948	persistMessage := func(m provider.Message) {
949		persistMu.Lock()
950		defer persistMu.Unlock()
951		if sess == nil {
952			return
953		}
954		if err := sess.AppendMessage(m); err == nil {
955			sessBaselineMsgs++
956		}
957	}
958	persistUsage := func(cum provider.Usage) {
959		persistMu.Lock()
960		defer persistMu.Unlock()
961		if sess == nil {
962			return
963		}
964		_ = sess.AppendUsage(cum, cum)
965	}
966	persistCompaction := func(messages []provider.Message) {
967		persistMu.Lock()
968		defer persistMu.Unlock()
969		if sess == nil {
970			return
971		}
972		if err := sess.AppendCompaction(messages); err == nil {
973			sessBaselineMsgs = len(messages)
974		}
975	}
976	wireAgentPersist := func(a *core.Agent) *core.Agent {
977		if a == nil {
978			return a
979		}
980		a.OnMessageAppended = persistMessage
981		a.OnUsage = persistUsage
982		a.OnTranscriptCompacted = persistCompaction
983		return a
984	}
985	wireAgentPersist(ag)
986
987	// Re-wrap the build closures so any agent constructed by the TUI
988	// (login, /model swap to a different provider) also gets the
989	// persistence hooks. Without this, switching provider would
990	// silently revert to the old in-memory-only behaviour.
991	bindLiveAgentSession := func(a *core.Agent) {
992		persistMu.Lock()
993		s := sess
994		persistMu.Unlock()
995		bindAgentSession(a, s)
996	}
997	baseBuildAgent := buildAgent
998	buildAgent = func() (*core.Agent, string, string, error) {
999		a, p, m, err := baseBuildAgent()
1000		a = wireAgentPersist(a)
1001		bindLiveAgentSession(a)
1002		return a, p, m, err
1003	}
1004	baseBuildAgentFor := buildAgentFor
1005	buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1006		a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
1007		a = wireAgentPersist(a)
1008		bindLiveAgentSession(a)
1009		return a, p, m, err
1010	}
1011	baseBuildAgentForRescue := buildAgentForRescue
1012	buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1013		a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
1014		a = wireAgentPersist(a)
1015		bindLiveAgentSession(a)
1016		return a, p, m, err
1017	}
1018
1019	// loadSession replaces the current session with the one at path and
1020	// hands its messages to the agent. Used by the /sessions picker.
1021	loadSession := func(path string) error {
1022		currentAg := liveInteractiveAgent(iv, ag)
1023		if currentAg == nil {
1024			return fmt.Errorf("no agent running; log in first")
1025		}
1026		newSess, msgs, err := core.OpenSession(path)
1027		if err != nil {
1028			return err
1029		}
1030		fullMsgCount := len(msgs)
1031		msgs = trimMessagesForResume(msgs, 100)
1032		persistMu.Lock()
1033		// Flush any unsaved messages to the old session before swapping.
1034		// Per-message persistence keeps sessBaselineMsgs current, so
1035		// this is a defensive no-op in the common case; it still
1036		// matters for the rare race where a turn just finished and
1037		// hadn't fired its hook yet.
1038		if sess != nil {
1039			writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1040			_ = sess.Close()
1041		}
1042		sess = newSess
1043		currentAg.SetMessages(msgs)
1044		bindAgentSession(currentAg, sess)
1045		if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
1046			currentAg.SeedCost(cum)
1047			currentAg.SeedLastTurnUsage(last)
1048		}
1049		// The live agent only receives a compact resume window, but
1050		// the session file remains intact. Keep the persistence
1051		// baseline at the original on-disk message count so future
1052		// turns append after the full session instead of duplicating
1053		// the hydrated tail.
1054		sessBaselineMsgs = fullMsgCount
1055		persistMu.Unlock()
1056		// Re-scope the swarm dashboard to the new session so /swarm
1057		// only shows agents this session spawned. swarmMgr may be nil
1058		// here if we haven't reached the construction site yet (it
1059		// shouldn't be, since the interactive loop is what triggers
1060		// loadSession, but the nil check is cheap insurance).
1061		if swarmMgr != nil && newSess != nil {
1062			swarmMgr.SetActiveSession(newSess.ID)
1063		}
1064		return nil
1065	}
1066
1067	// changeCWD switches the running session to a new working directory.
1068	// Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
1069	// /cd slash command (which itself is only fired by the workspaces
1070	// extension's panel-key Enter handler today; the user can type /cd
1071	// directly but it's not in autocomplete / help / the README).
1072	//
1073	// Steps, in order:
1074	//   1. resolve + validate the new path (~ expansion, abs/rel)
1075	//   2. close the current session, flushing pending messages
1076	//   3. mutate captured args.CWD + r.CWD so future buildAgent
1077	//      calls bind to the new cwd
1078	//   4. re-root the shared sandbox («·«) so /jail follows the
1079	//      session into the new cwd instead of widening or silently
1080	//      dropping
1081	//   5. rebuild the agent via buildAgent() so tools, AGENTS.md
1082	//      addendum, system prompt, sessions dir all bind correctly
1083	//   6. open a fresh session in the new cwd's bucket
1084	//   7. push the new state into the running Interactive
1085	//   8. re-scope the swarm dashboard to the freshly-opened session
1086	//
1087	// The /jail state is preserved verbatim: if the sandbox was locked
1088	// to the old cwd, it stays locked, just re-pointed at the new one.
1089	changeCWD := func(path string) error {
1090		if path == "" {
1091			return fmt.Errorf("empty path")
1092		}
1093		// ~ expansion.
1094		if path == "~" || strings.HasPrefix(path, "~/") {
1095			home, herr := os.UserHomeDir()
1096			if herr != nil || home == "" {
1097				return fmt.Errorf("cannot expand ~: %v", herr)
1098			}
1099			if path == "~" {
1100				path = home
1101			} else {
1102				path = filepath.Join(home, path[2:])
1103			}
1104		}
1105		if !filepath.IsAbs(path) {
1106			path = filepath.Join(args.CWD, path)
1107		}
1108		absPath, err := filepath.Abs(path)
1109		if err != nil {
1110			return err
1111		}
1112		info, err := os.Stat(absPath)
1113		if err != nil {
1114			return err
1115		}
1116		if !info.IsDir() {
1117			return fmt.Errorf("not a directory: %s", absPath)
1118		}
1119
1120		currentAg := ag
1121		if currentAg == nil {
1122			return fmt.Errorf("no agent running; log in first")
1123		}
1124
1125		// Close the current session before we drop the reference.
1126		// Per-message persistence keeps it current already; this is
1127		// a defensive flush + final fsync via Close.
1128		persistMu.Lock()
1129		if sess != nil {
1130			writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1131			_ = sess.Close()
1132			sess = nil
1133		}
1134		sessBaselineMsgs = 0
1135		persistMu.Unlock()
1136
1137		// Mutate captured state so subsequent agent rebuilds and
1138		// session opens see the new cwd.
1139		wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
1140		args.CWD = absPath
1141		r.CWD = absPath
1142		if args.PermissionSet != nil {
1143			expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
1144			args.PermissionSet = &expanded
1145		}
1146		if sharedSandbox != nil {
1147			sharedSandbox.Root = absPath
1148			if wasJailed {
1149				sharedSandbox.Lock()
1150			} else {
1151				sharedSandbox.Unlock()
1152			}
1153		}
1154
1155		// Rebuild the agent so tools / AGENTS.md / system prompt
1156		// re-bind to the new cwd. buildAgent() reads from args + r.
1157		newAg, newProvider, newModel, berr := buildAgent()
1158		if berr != nil {
1159			return fmt.Errorf("rebuild agent: %v", berr)
1160		}
1161		ag = newAg
1162
1163		// Fresh session in the new cwd's bucket. We bypass
1164		// openOrCreateSession's --continue / --resume branches
1165		// because /cd's semantics are "start fresh here", matching
1166		// what relaunching `zot --cwd <path>` would do today.
1167		if !args.NoSess {
1168			newRoot := agentSessionsRoot(ZotHome(), args)
1169			core.PruneEmptySessions(newRoot, absPath)
1170			newSess, serr := core.NewSession(newRoot, absPath, newProvider, newModel, version)
1171			if serr != nil {
1172				return fmt.Errorf("open session in %s: %v", absPath, serr)
1173			}
1174			persistMu.Lock()
1175			sess = newSess
1176			sessBaselineMsgs = 0
1177			persistMu.Unlock()
1178			bindAgentSession(newAg, newSess)
1179		}
1180
1181		// Push the new state into the running Interactive.
1182		if iv != nil {
1183			startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))
1184			iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
1185		}
1186
1187		// Re-scope the swarm dashboard to the new session.
1188		if swarmMgr != nil && sess != nil {
1189			swarmMgr.SetActiveSession(sess.ID)
1190		}
1191		return nil
1192	}
1193
1194	term := tui.NewProcTerm()
1195
1196	// Kick off the async update check so the banner can appear when the
1197	// http response eventually arrives (usually <1s on cached DNS). Map
1198	// agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
1199	updateCh := make(chan modes.UpdateInfo, 1)
1200	go func() {
1201		defer close(updateCh)
1202		src := <-CheckForUpdateAsync(ZotHome(), version)
1203		updateCh <- modes.UpdateInfo{
1204			Current:   src.Current,
1205			Latest:    src.Latest,
1206			Available: src.Available,
1207			URL:       src.URL,
1208		}
1209	}()
1210
1211	// Changelog: when the running version differs from the last
1212	// version whose release notes the user dismissed, fetch the
1213	// release body from GitHub and have the TUI show it once. On
1214	// first-ever launch (no prior LastChangelogShown), seed the
1215	// stored version silently — don't dump release notes at someone
1216	// who just installed.
1217	changelogCh := make(chan modes.ChangelogPayload, 1)
1218	go func() {
1219		defer close(changelogCh)
1220		cfg, _ := LoadConfig()
1221		if cfg.LastChangelogShown == "" {
1222			SeedChangelogVersion(version)
1223			return
1224		}
1225		if !ShouldShowChangelog(version, cfg) {
1226			return
1227		}
1228		info := <-FetchChangelogAsync(version)
1229		if info.Body == "" {
1230			return
1231		}
1232		// For dev builds (0.0.0), skip if the latest release was
1233		// already shown (stored by the dismiss callback).
1234		if version == "0.0.0" && info.Version == cfg.LastChangelogShown {
1235			return
1236		}
1237		changelogCh <- modes.ChangelogPayload{
1238			Version: info.Version,
1239			Body:    info.Body,
1240			URL:     info.URL,
1241		}
1242	}()
1243
1244	initialCfg, _ := LoadConfig()
1245	quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
1246	for idx, s := range initialCfg.QuickModelShortcuts {
1247		quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
1248	}
1249	theme, _, themeErr := tui.DetectThemeWithCustom(ZotHome(), initialCfg.Theme, 80*time.Millisecond)
1250	if themeErr != nil {
1251		fmt.Fprintln(os.Stderr, "theme load:", themeErr)
1252		if initialCfg.Theme != "" && !tui.ThemeExists(ZotHome(), initialCfg.Theme) {
1253			initialCfg.Theme = ""
1254			_ = SaveConfig(initialCfg)
1255		}
1256	}
1257
1258	// swarmMgr was constructed and reloaded earlier (before the agent
1259	// build, so the auto-swarm tool could capture it). Here we just
1260	// scope the dashboard to the active host session so /swarm only
1261	// shows agents this session spawned (and any pre-upgrade unscoped
1262	// agents — see SnapshotAll docs). Updated again whenever the
1263	// user swaps sessions via loadSession below.
1264	if sess != nil {
1265		swarmMgr.SetActiveSession(sess.ID)
1266	}
1267	// Best-effort shutdown on interactive exit: stop all running
1268	// agents so they don't outlive their parent zot.
1269	defer swarmMgr.StopAll()
1270
1271	var startupSkills []*skills.Skill
1272	if r.SkillTool != nil {
1273		startupSkills = r.SkillTool.Skills()
1274	}
1275
1276	iv = modes.NewInteractive(modes.InteractiveConfig{
1277		Terminal:                      term,
1278		Theme:                         theme,
1279		InlineImagesEnabled:           initialCfg.InlineImagesEnabled,
1280		AutoSwarmEnabled:              initialCfg.AutoSwarmEnabled,
1281		AutoCompactThreshold:          initialCfg.AutoCompactThreshold,
1282		JailByDefault:                 initialCfg.JailByDefault,
1283		PowerShellEnabled:             initialCfg.PowerShellEnabled,
1284		ToolSelectionExplicit:         len(args.Tools) > 0,
1285		OpenRouterServerToolsEnabled:  initialCfg.OpenRouterServerToolsEnabled,
1286		OpenRouterServerToolCallLimit: initialCfg.OpenRouterServerToolCallLimit,
1287		NoTools:                       args.NoTools,
1288		QuickModelShortcuts:           quickModelShortcuts,
1289		RecursiveFileSuggest:          initialCfg.RecursiveFileSuggest,
1290		RespectGitignore:              initialCfg.RespectGitignore,
1291		CompactMode:                   initialCfg.CompactMode,
1292		CollapseToolCall:              initialCfg.CollapseToolCall,
1293		TUIInputStyle:                 initialCfg.TUIInputStyle,
1294		TUIStatusPosition:             initialCfg.TUIStatusPosition,
1295		TUIWorkingPosition:            initialCfg.TUIWorkingPosition,
1296		ThemeName:                     initialCfg.Theme,
1297		FlatTools:                     initialCfg.FlatToolRender(),
1298		CompactUser:                   initialCfg.CompactUserInput(),
1299		ExtensionThemes:               extMgr.ThemeOptions,
1300		AutoSwarmSystemAddendum:       AutoSwarmSystemAddendum,
1301		SettingsStore:                 configSettingsStore{},
1302		Model:                         r.Model,
1303		Provider:                      r.Provider,
1304		AuthMethod:                    r.AuthMethod,
1305		BaseURL:                       r.BaseURL,
1306		Reasoning:                     r.Reasoning,
1307		SystemPrompt:                  r.SystemPrompt,
1308		Tools:                         r.ToolRegistry,
1309		MaxSteps:                      r.MaxSteps,
1310		CWD:                           r.CWD,
1311		StartupAgentName:              args.AgentName,
1312		StartupContextPaths:           instructionContextPaths(r.ContextFiles),
1313		StartupExtensionNames:         startupExtensionNames(extMgr.All()),
1314		StartupExtensionErrors:        startupExtensionErrors,
1315		StartupSkillNames:             startupSkillNames(startupSkills),
1316		ShowInstructionsAtStartup:     initialCfg.ShowInstructionsAtStartup,
1317		ZotHome:                       ZotHome(),
1318		SessionsRoot:                  agentSessionsRoot(ZotHome(), args),
1319		Version:                       version,
1320		UpdateInfoChan:                updateCh,
1321		Sandbox:                       sharedSandbox,
1322		Agent:                         ag,
1323		InitialInput:                  args.Prompt,
1324		StartupPre:                    args.StartupPre,
1325		OnStartupPreDone: func() {
1326			// entry.pre often installs skills/extensions; rediscover them
1327			// before the user starts a model turn.
1328			current := liveInteractiveAgent(iv, ag)
1329			if extMgr != nil {
1330				_ = extMgr.Reload(context.Background(), 2*time.Second)
1331			}
1332			if current != nil {
1333				refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1334			}
1335		},
1336		AuthManager:                mgr,
1337		LlamaCPPConfig:             ResolveLlamaCPPConfig,
1338		RefreshLlamaCPPModels:      RefreshLlamaCPPModels,
1339		BuildAgent:                 buildAgent,
1340		SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
1341		BuildAgentFor:              buildAgentFor,
1342		BuildAgentForRescue:        buildAgentForRescue,
1343		LoggedInProviders: func() []string {
1344			var out []string
1345			seen := map[string]bool{}
1346			for _, p := range knownProviders {
1347				if CredentialAvailable(p) && !seen[p] {
1348					out = append(out, p)
1349					seen[p] = true
1350				}
1351			}
1352			// Include custom providers that have credentials stored.
1353			for p := range provider.CustomProviders() {
1354				if CredentialAvailable(p) && !seen[p] {
1355					out = append(out, p)
1356					seen[p] = true
1357				}
1358			}
1359			// Ollama models are always available (no auth needed).
1360			if !seen["ollama"] {
1361				out = append(out, "ollama")
1362			}
1363			return out
1364		},
1365		LoadSession: loadSession,
1366		ChangeCWD:   changeCWD,
1367		CurrentSessionPath: func() string {
1368			if sess == nil {
1369				return ""
1370			}
1371			return sess.Path
1372		},
1373		FlushSession: func() {
1374			// Append any not-yet-persisted agent messages to the
1375			// current session file, then advance the baseline so
1376			// the final WriteNewTranscript at exit doesn't write
1377			// duplicates. Per-message persistence keeps the on-
1378			// disk file current already, so this is mostly a
1379			// defensive flush — still needed for /session export
1380			// to guarantee the exported bytes include the very
1381			// last in-flight turn.
1382			currentAg := iv.Agent()
1383			if currentAg == nil {
1384				return
1385			}
1386			persistMu.Lock()
1387			defer persistMu.Unlock()
1388			if sess == nil {
1389				return
1390			}
1391			writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
1392			sessBaselineMsgs = len(currentAg.Messages())
1393		},
1394		Extensions:    extMgr,
1395		Swarm:         swarmMgr,
1396		ChangelogChan: changelogCh,
1397		OnChangelogDismiss: func() {
1398			// For dev builds (0.0.0) store the actual release version
1399			// so the same changelog doesn't show again next launch.
1400			// For real builds, store the binary version.
1401			v := version
1402			if v == "0.0.0" {
1403				if iv != nil && iv.ChangelogVersion() != "" {
1404					v = iv.ChangelogVersion()
1405				}
1406			}
1407			_ = MarkChangelogShown(v)
1408		},
1409		SkillSnapshot: func() []*skills.Skill {
1410			if args.NoSkill {
1411				// --no-skill: nothing for the picker to show.
1412				return nil
1413			}
1414			// Re-discover so the picker reflects edits made during
1415			// the session. Cheap; SKILL.md files are small. Filter
1416			// out built-in skills — they're hidden from user-facing
1417			// surfaces because they're implementation detail; the
1418			// model still sees them through the system-prompt
1419			// manifest and the skill tool.
1420			userHome, _ := os.UserHomeDir()
1421			var sources []skills.Source
1422			if args.WithSkills {
1423				sources = append(sources, skills.SearchSources(ZotHome(), r.CWD, userHome)...)
1424			}
1425			if !args.NoExt || len(args.Exts) > 0 {
1426				extSources, _ := extensions.PlanSkillSources(ZotHome(), r.CWD, args.Exts, !args.NoExt)
1427				sources = append(extSources, sources...)
1428			}
1429			list, _ := skills.DiscoverSources(sources, true)
1430			return skills.VisibleSkills(list)
1431		},
1432		NoYolo:      args.NoYolo,
1433		ConfirmGate: confirmGate,
1434		PersistModel: func(providerName, model string) {
1435			// Update config.json so next launch uses the same pick.
1436			cfg, _ := LoadConfig()
1437			cfg.Provider = providerName
1438			cfg.Model = model
1439			_ = SaveConfig(cfg)
1440			// Update the active session's meta so resume picks this up.
1441			if sess != nil {
1442				_ = sess.UpdateModel(providerName, model)
1443			}
1444		},
1445	})
1446
1447	// Bind the interactive TUI as the Confirmer. We deferred this
1448	// until now because the gate is constructed before the TUI
1449	// (the BeforeToolExecute closure captures it). SetConfirmer
1450	// is mutex-guarded on the gate so this is safe.
1451	if confirmGate != nil {
1452		confirmGate.SetConfirmer(&confirmationEventConfirmer{
1453			inner: iv,
1454			emit:  extMgr.EmitEvent,
1455		})
1456	}
1457
1458	// Signal-driven flush: a SIGTERM / SIGHUP to the zot process
1459	// (closed terminal window, system shutdown, kill) used to lose
1460	// the entire in-memory transcript because the deferred post-Run
1461	// flush below never ran. Per-message persistence above covers
1462	// most of it; this handler writes any in-flight remainder and
1463	// then exits the process so we don't double-paint over a
1464	// broken terminal that the TUI's restore deferreds can no
1465	// longer fix from a signal context.
1466	//
1467	// SIGINT is intentionally NOT handled here — the TUI consumes
1468	// Ctrl+C as a regular key event for cancel/clear semantics, and
1469	// installing a SIGINT notifier here would swallow it.
1470	sigCh := make(chan os.Signal, 1)
1471	signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
1472	defer signal.Stop(sigCh)
1473	go func() {
1474		_, ok := <-sigCh
1475		if !ok {
1476			return
1477		}
1478		if finalAg := iv.Agent(); finalAg != nil {
1479			persistMu.Lock()
1480			if sess != nil {
1481				writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
1482				sessBaselineMsgs = len(finalAg.Messages())
1483				_ = sess.Close()
1484				sess = nil
1485			}
1486			persistMu.Unlock()
1487		}
1488		// Exit cleanly. Re-raising the signal would skip os.Exit's
1489		// at-exit hooks; explicit exit is fine because we've already
1490		// flushed the only at-risk state (the session file).
1491		os.Exit(0)
1492	}()
1493
1494	runErr := iv.Run(ctx)
1495
1496	// Flush final transcript to session (only if we had / ended up with an agent).
1497	if finalAg := iv.Agent(); finalAg != nil {
1498		persistMu.Lock()
1499		if sess != nil {
1500			writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
1501			sessBaselineMsgs = len(finalAg.Messages())
1502		}
1503		persistMu.Unlock()
1504	}
1505	return runErr
1506}
1507
1508func agentSessionsRoot(root string, args Args) string {
1509	if args.AgentName == "" {
1510		return root
1511	}
1512	return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName))
1513}
1514
1515// openOrCreateSession returns a session for the run. sess may be nil
1516// with a nil error if session persistence is disabled. When a session
1517// exists, its id is bound onto ag so providers can sticky-route.
1518func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
1519	if args.NoSess {
1520		return nil, nil
1521	}
1522	// Sweep meta-only files left over from older zot versions (and from
1523	// any session that crashed before its first AppendMessage). Cheap;
1524	// reads the first few bytes of each file in the cwd's session dir.
1525	sessionsRoot := agentSessionsRoot(ZotHome(), args)
1526	core.PruneEmptySessions(sessionsRoot, args.CWD)
1527	var (
1528		s    *core.Session
1529		msgs []provider.Message
1530		err  error
1531	)
1532	switch {
1533	case args.Session != "":
1534		s, msgs, err = core.OpenSession(args.Session)
1535		// The swarm-agent child passes a fixed --session path that
1536		// may not exist yet on first Spawn. Treat ENOENT as "create
1537		// a fresh session AT THIS PATH" so the conversation actually
1538		// gets persisted; without this fallback the swarm child runs
1539		// with sess==nil and every Resume re-starts with no memory
1540		// of the prior turns. Other openers (--continue / --resume /
1541		// the picker) never see ENOENT here because they only choose
1542		// paths that already exist on disk.
1543		if err != nil && errors.Is(err, os.ErrNotExist) {
1544			s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
1545			msgs = nil
1546		}
1547	case args.Continue:
1548		latest := core.LatestSession(sessionsRoot, args.CWD)
1549		if latest != "" {
1550			s, msgs, err = core.OpenSession(latest)
1551		}
1552	case args.Resume:
1553		picked, perr := pickSession(sessionsRoot, args.CWD)
1554		if perr != nil {
1555			return nil, perr
1556		}
1557		if picked != "" {
1558			s, msgs, err = core.OpenSession(picked)
1559		}
1560	}
1561	if err != nil {
1562		return nil, err
1563	}
1564	if s == nil {
1565		s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
1566		if err != nil {
1567			return nil, err
1568		}
1569	} else {
1570		ag.SetMessages(msgs)
1571		if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
1572			ag.SeedCost(cum)
1573			ag.SeedLastTurnUsage(last)
1574		}
1575	}
1576	bindAgentSession(ag, s)
1577	return s, nil
1578}
1579
1580func pickSession(root, cwd string) (string, error) {
1581	files := core.ListSessions(root, cwd)
1582	if len(files) == 0 {
1583		fmt.Fprintln(os.Stderr, "no sessions for", cwd)
1584		return "", nil
1585	}
1586	for i, f := range files {
1587		fmt.Fprintf(os.Stderr, "  %2d) %s\n", i+1, f)
1588	}
1589	fmt.Fprint(os.Stderr, "pick #: ")
1590	rd := bufio.NewReader(os.Stdin)
1591	line, _ := rd.ReadString('\n')
1592	line = strings.TrimSpace(line)
1593	var n int
1594	if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
1595		return "", fmt.Errorf("invalid selection")
1596	}
1597	return files[n-1], nil
1598}
1599
1600// WriteNewTranscript appends only messages after index `from` from the
1601// agent's transcript to the session. Used by callers that don't hold
1602// the persistMu (non-interactive print/json modes which run a single
1603// turn under their own goroutine).
1604func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
1605	writeNewTranscriptLocked(ag, sess, from)
1606}
1607
1608// bindAgentSession stamps the zot session id onto the agent so providers
1609// that support sticky routing (OpenRouter session_id) can reuse it.
1610// No-op when persistence is disabled.
1611func bindAgentSession(ag *core.Agent, sess *core.Session) {
1612	if ag == nil || sess == nil {
1613		return
1614	}
1615	ag.SessionID = sess.ID
1616}
1617
1618// writeNewTranscriptLocked is the same as WriteNewTranscript. The
1619// suffix marks that interactive callers must hold persistMu when
1620// invoking it so concurrent appends from the agent loop don't race
1621// with this catch-up flush.
1622func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) {
1623	if sess == nil || ag == nil {
1624		return
1625	}
1626	msgs := ag.Messages()
1627	for i := from; i < len(msgs); i++ {
1628		_ = sess.AppendMessage(msgs[i])
1629	}
1630	cum := ag.Cost()
1631	_ = sess.AppendUsage(cum, cum)
1632}
1633
1634func argsForStdin(rawArgs []string, mode os.FileMode) []string {
1635	if mode&os.ModeCharDevice != 0 {
1636		return rawArgs
1637	}
1638	return append([]string{"--print"}, rawArgs...)
1639}
1640
1641func combinePromptInput(stdin, positional string) string {
1642	parts := make([]string, 0, 2)
1643	if stdin = strings.TrimSpace(stdin); stdin != "" {
1644		parts = append(parts, stdin)
1645	}
1646	if positional = strings.TrimSpace(positional); positional != "" {
1647		parts = append(parts, positional)
1648	}
1649	return strings.Join(parts, "\n")
1650}
1651
1652func readAllStdin() (string, error) {
1653	fi, err := os.Stdin.Stat()
1654	if err != nil {
1655		return "", err
1656	}
1657	if (fi.Mode() & os.ModeCharDevice) != 0 {
1658		return "", nil
1659	}
1660	b, err := io.ReadAll(os.Stdin)
1661	return string(b), err
1662}
1663
1664func printModels() {
1665	models := provider.Active()
1666
1667	// Compute column widths from actual data so wide providers (e.g.
1668	// xiaomi-token-plan-sgp) and long bedrock model ids don't force the
1669	// `name` column off-screen. Floors mirror the historical layout so
1670	// short catalogs look the same as before.
1671	provW, idW, srcW := len("provider"), len("model id"), len("source")
1672	for _, m := range models {
1673		if w := len(m.Provider); w > provW {
1674			provW = w
1675		}
1676		if w := len(m.ID); w > idW {
1677			idW = w
1678		}
1679		source := m.Source
1680		if source == "" {
1681			source = "catalog"
1682		}
1683		if m.Speculative {
1684			source = "speculative"
1685		}
1686		if w := len(source); w > srcW {
1687			srcW = w
1688		}
1689	}
1690
1691	header := fmt.Sprintf("%-*s  %-*s  %8s  %8s  %s  %-*s  %s",
1692		provW, "provider",
1693		idW, "model id",
1694		"context", "max-out", "reasoning",
1695		srcW, "source",
1696		"name")
1697	fmt.Println(header)
1698
1699	for _, m := range models {
1700		reason := " "
1701		if m.Reasoning {
1702			reason = "✓"
1703		}
1704		source := m.Source
1705		if source == "" {
1706			source = "catalog"
1707		}
1708		if m.Speculative {
1709			source = "speculative"
1710		}
1711		fmt.Printf("%-*s  %-*s  %8d  %8d     %s      %-*s  %s\n",
1712			provW, m.Provider,
1713			idW, m.ID,
1714			m.ContextWindow, m.MaxOutput,
1715			reason,
1716			srcW, source,
1717			m.DisplayName)
1718	}
1719}

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