v0.1.50 471fdea

cli.go RawHistoryFolder

1package agent
2
3import (
4	"bufio"
5	"context"
6	"encoding/json"
7	"fmt"
8	"io"
9	"os"
10	"os/signal"
11	"strings"
12	"sync"
13	"syscall"
14	"time"
15
16	"github.com/patriceckhart/zot/internal/agent/extensions"
17	"github.com/patriceckhart/zot/internal/agent/modes"
18	"github.com/patriceckhart/zot/internal/auth"
19	"github.com/patriceckhart/zot/internal/core"
20	"github.com/patriceckhart/zot/internal/extproto"
21	"github.com/patriceckhart/zot/internal/provider"
22	"github.com/patriceckhart/zot/internal/skills"
23	"github.com/patriceckhart/zot/internal/tui"
24)
25
26// interactiveExtHooks is a tiny adapter that lets the extension
27// manager call back into the Interactive instance built later in
28// runInteractive. The forward-declared *modes.Interactive is filled
29// in immediately after manager construction.
30type interactiveExtHooks struct {
31	ivPtr **modes.Interactive
32}
33
34func (h *interactiveExtHooks) iv() *modes.Interactive {
35	if h == nil || h.ivPtr == nil {
36		return nil
37	}
38	return *h.ivPtr
39}
40
41func (h *interactiveExtHooks) Notify(extName, level, message string) {
42	if iv := h.iv(); iv != nil {
43		iv.Notify(extName, level, message)
44	}
45}
46func (h *interactiveExtHooks) Submit(text string) {
47	if iv := h.iv(); iv != nil {
48		iv.Submit(text)
49	}
50}
51func (h *interactiveExtHooks) Insert(text string) {
52	if iv := h.iv(); iv != nil {
53		iv.Insert(text)
54	}
55}
56func (h *interactiveExtHooks) Display(extName, text string) {
57	if iv := h.iv(); iv != nil {
58		iv.Display(extName, text)
59	}
60}
61func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
62	if iv := h.iv(); iv != nil {
63		iv.OpenPanel(extName, spec)
64	}
65}
66func (h *interactiveExtHooks) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
67	if iv := h.iv(); iv != nil {
68		iv.UpdatePanel(extName, panelID, title, lines, footer)
69	}
70}
71func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
72	if iv := h.iv(); iv != nil {
73		iv.ClosePanel(extName, panelID)
74	}
75}
76
77// extToolAdapter bridges *extensions.Manager to the
78// ExtensionToolSource interface declared in build.go (kept narrow to
79// avoid a build->extensions import cycle). One adapter instance per
80// run; used at every Resolve point so re-built agents pick up the
81// same set of extension tools.
82type extToolAdapter struct {
83	mgr *extensions.Manager
84}
85
86func (a *extToolAdapter) Tools() []ExtensionToolInfo {
87	infos := a.mgr.Tools()
88	out := make([]ExtensionToolInfo, len(infos))
89	for i, t := range infos {
90		out[i] = ExtensionToolInfo{
91			Extension:   t.Extension,
92			Name:        t.Name,
93			Description: t.Description,
94			Schema:      t.Schema,
95		}
96	}
97	return out
98}
99
100func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
101	return extensions.NewTool(a.mgr, extensions.ToolInfo{
102		Extension:   info.Extension,
103		Name:        info.Name,
104		Description: info.Description,
105		Schema:      info.Schema,
106	})
107}
108
109// fanoutAgentEvent translates a core.AgentEvent into the wire-format
110// EventFromHost and pushes it through the extension manager. Only
111// the events that have a clear extension-facing meaning are
112// forwarded; internal-only ones (text_delta, tool_progress) are
113// dropped to keep the per-extension stream sane.
114func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
115	if mgr == nil {
116		return
117	}
118	switch e := ev.(type) {
119	case core.EvTurnStart:
120		mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
121	case core.EvToolCall:
122		mgr.EmitEvent(extproto.EventFromHost{
123			Event: "tool_call", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args,
124		})
125	case core.EvAssistantMessage:
126		// Concat the visible text portions of the message; binary
127		// blocks (tool_use, etc.) are skipped because subscribers
128		// usually want a string they can grep / display.
129		var text string
130		for _, c := range e.Message.Content {
131			if tb, ok := c.(provider.TextBlock); ok {
132				text += tb.Text
133			}
134		}
135		mgr.EmitEvent(extproto.EventFromHost{Event: "assistant_message", Text: text})
136	case core.EvTurnEnd:
137		ev := extproto.EventFromHost{Event: "turn_end", Stop: string(e.Stop)}
138		if e.Err != nil {
139			ev.Error = e.Err.Error()
140		}
141		mgr.EmitEvent(ev)
142	}
143}
144
145// Run is the top-level entrypoint for the zot binary.
146func Run(rawArgs []string, version string) error {
147	// Subcommand router: `zot bot ...` is handled separately so the
148	// generic flag parser doesn't reject "bot" as a positional arg.
149	if handled, err := runBotCommand(rawArgs, version); handled {
150		return err
151	}
152	if handled, err := runExtCommand(rawArgs); handled {
153		return err
154	}
155	// `zot rpc` is shorthand for `zot --rpc` so third-party apps can
156	// spawn the binary with a clean argv. Strip the leading 'rpc'
157	// token and let the rest flow through the normal arg parser.
158	if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
159		rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
160	}
161
162	args, err := ParseArgs(rawArgs)
163	if err != nil {
164		PrintHelp(version)
165		return err
166	}
167	if args.Help {
168		PrintHelp(version)
169		return nil
170	}
171	if args.Version {
172		fmt.Println("zot", version)
173		return nil
174	}
175	// Model catalog: load any cached discovery data before we inspect
176	// the model list (list-models, print/json, interactive).
177	LoadCachedModels()
178	LoadUserModels()
179
180	if args.ListModels {
181		printModels()
182		return nil
183	}
184
185	ctx := context.Background()
186
187	// Kick an async refresh of the live model catalog. The first run of
188	// zot hits the network; subsequent runs within CacheTTL do nothing.
189	RefreshModelsAsync()
190
191	switch args.Mode {
192	case ModePrint:
193		return runPrintMode(ctx, args, version)
194	case ModeJSON:
195		return runJSONMode(ctx, args, version)
196	case ModeRPC:
197		return runRPCMode(ctx, args, version)
198	default:
199		return runInteractive(ctx, args, version)
200	}
201}
202
203// ---- print / json modes: require credentials, run single-shot ----
204
205// nonInteractiveExtHooks is the HostHooks impl used by print / json
206// modes. They have no TUI, so notify / display go to stderr and
207// submit / insert are no-ops (the extension can't steer a
208// single-shot run once it's in flight anyway).
209type nonInteractiveExtHooks struct{}
210
211func (nonInteractiveExtHooks) Notify(ext, level, message string) {
212	fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
213}
214func (nonInteractiveExtHooks) Submit(string)                                        {}
215func (nonInteractiveExtHooks) Insert(string)                                        {}
216func (nonInteractiveExtHooks) Display(string, string)                               {}
217func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec)                 {}
218func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
219func (nonInteractiveExtHooks) ClosePanel(string, string)                            {}
220
221// setupNonInteractiveExtensions loads --ext paths and (unless
222// --no-ext) runs discovery. Returns the manager so the caller can
223// wire tools into the resolved registry, and a cleanup closure to
224// defer. Mirrors the interactive-mode setup minus the TUI hooks.
225func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) {
226	extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{})
227	for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
228		fmt.Fprintln(os.Stderr, "extension load:", e)
229	}
230	if !args.NoExt {
231		for _, e := range extMgr.Discover(ctx) {
232			fmt.Fprintln(os.Stderr, "extension load:", e)
233		}
234	}
235	extMgr.WaitForReady(3 * time.Second)
236	r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
237	extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
238	return extMgr, func() { extMgr.Stop(2 * time.Second) }
239}
240
241// wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
242// / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
243// interactive path wires up, so extensions get their normal
244// event-intercept surface in print / json / rpc flows too.
245func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) {
246	if ag == nil || extMgr == nil {
247		return
248	}
249	ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
250		res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
251		if res.Block {
252			return false, res.Reason, nil
253		}
254		return true, "", res.ModifiedArgs
255	}
256	ag.BeforeTurn = func(step int) (bool, string) {
257		res := extMgr.InterceptTurnStart(ctx, step)
258		return !res.Block, res.Reason
259	}
260	ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
261		res := extMgr.InterceptAssistantMessage(ctx, text)
262		if res.Block {
263			return false, res.Reason, ""
264		}
265		return true, "", res.ReplaceText
266	}
267	ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
268}
269
270func runPrintMode(ctx context.Context, args Args, version string) error {
271	if args.NoYolo {
272		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
273	}
274	r, err := Resolve(args, true)
275	if err != nil {
276		return err
277	}
278	extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
279	defer stopExt()
280
281	ag := r.NewAgent()
282	wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
283	sess, _ := openOrCreateSession(args, r, ag, version)
284	defer sess.Close()
285
286	prompt := args.Prompt
287	if prompt == "" {
288		piped, _ := readAllStdin()
289		prompt = strings.TrimSpace(piped)
290	}
291	if prompt == "" {
292		return fmt.Errorf("print mode requires a prompt (arg or stdin)")
293	}
294
295	start := len(ag.Messages())
296	err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
297	WriteNewTranscript(ag, sess, start)
298	return err
299}
300
301func runJSONMode(ctx context.Context, args Args, version string) error {
302	if args.NoYolo {
303		fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
304	}
305	r, err := Resolve(args, true)
306	if err != nil {
307		return err
308	}
309	extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
310	defer stopExt()
311
312	ag := r.NewAgent()
313	wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
314	sess, _ := openOrCreateSession(args, r, ag, version)
315	defer sess.Close()
316
317	prompt := args.Prompt
318	if prompt == "" {
319		piped, _ := readAllStdin()
320		prompt = strings.TrimSpace(piped)
321	}
322	if prompt == "" {
323		return fmt.Errorf("json mode requires a prompt (arg or stdin)")
324	}
325
326	start := len(ag.Messages())
327	err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
328	WriteNewTranscript(ag, sess, start)
329	return err
330}
331
332// ---- interactive mode: opens the TUI even without credentials ----
333
334func runInteractive(ctx context.Context, args Args, version string) error {
335	// Resolve WITHOUT requiring credentials.
336	r, err := Resolve(args, false)
337	if err != nil {
338		return err
339	}
340
341	authStore := AuthStoreFor()
342	mgr := auth.NewManager(authStore)
343	defer mgr.Close()
344
345	// Keep the sandbox pointer stable across agent rebuilds (login / model
346	// switch). The Interactive UI toggles the lock via this pointer, and
347	// rebuilt tool instances must share the same one so the lock sticks.
348	sharedSandbox := r.Sandbox
349
350	// Build the extension manager BEFORE the agent so we can fold
351	// extension-defined tools into the registry. Forward-declare iv so
352	// the host hooks adapter can dereference it after construction.
353	var iv *modes.Interactive
354	extHooks := &interactiveExtHooks{ivPtr: &iv}
355	extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
356	// --ext paths first so they win against installed extensions of
357	// the same name (loadOne's first-write-wins semantics).
358	for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
359		fmt.Fprintln(os.Stderr, "extension load:", e)
360	}
361	// --no-ext skips the global + project-local discovery scan;
362	// explicit --ext paths above are still honoured so you can run
363	// "only this extension" with --no-ext --ext ./x.
364	if !args.NoExt {
365		for _, e := range extMgr.Discover(ctx) {
366			fmt.Fprintln(os.Stderr, "extension load:", e)
367		}
368	}
369	// Wait briefly for extensions to flush their initial register_tool
370	// frames before we build the agent's tool registry. Half a second
371	// is plenty for any extension that's actually well-behaved; ones
372	// that don't send a ready frame eat the full grace and proceed.
373	// 3s is the per-extension grace period for the ready frame.
374	// Native binaries are instant; runtimes like `npx tsx` take ~1.5s
375	// from cold cache. The wait is tight only for extensions that
376	// haven't sent ready by then; ones that signalled earlier release
377	// the wait immediately.
378	extMgr.WaitForReady(3 * time.Second)
379	defer extMgr.Stop(2 * time.Second)
380
381	extToolAdapter := &extToolAdapter{mgr: extMgr}
382	r.MergeExtensionTools(extToolAdapter)
383
384	// Confirmation gate: when --no-yolo is on, the agent must ask
385	// the user before every tool call. In interactive mode the TUI
386	// provides the Confirmer; in print/json/rpc modes there's no
387	// way to prompt, so the gate is constructed with a nil inner
388	// which auto-refuses every call with a helpful reason.
389	var confirmGate *core.ConfirmGate
390	if args.NoYolo {
391		confirmGate = core.NewConfirmGate(nil) // set below for interactive
392	}
393
394	// Capture current args in a closure so BuildAgent can re-resolve
395	// after a successful login (picks up the newly stored credential).
396	wireAgentExt := func(a *core.Agent) *core.Agent {
397		if a == nil {
398			return a
399		}
400		a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
401			// Confirm gate runs FIRST: if the user refused, we don't
402			// waste extension-intercept time or let guards see the call.
403			if confirmGate != nil {
404				ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
405				if !ok {
406					return false, reason, nil
407				}
408			}
409			r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
410			if r.Block {
411				return false, r.Reason, nil
412			}
413			return true, "", r.ModifiedArgs
414		}
415		a.BeforeTurn = func(step int) (bool, string) {
416			r := extMgr.InterceptTurnStart(ctx, step)
417			return !r.Block, r.Reason
418		}
419		a.BeforeAssistantMessage = func(text string) (bool, string, string) {
420			r := extMgr.InterceptAssistantMessage(ctx, text)
421			if r.Block {
422				return false, r.Reason, ""
423			}
424			return true, "", r.ReplaceText
425		}
426		a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
427		return a
428	}
429
430	buildAgent := func() (*core.Agent, string, string, error) {
431		resolved, err := Resolve(args, true)
432		if err != nil {
433			return nil, "", "", err
434		}
435		resolved.UseSandbox(sharedSandbox)
436		resolved.MergeExtensionTools(extToolAdapter)
437		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
438	}
439
440	// Rebuild agent with an explicit provider/model override.
441	buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
442		next := args
443		if providerOverride != "" {
444			next.Provider = providerOverride
445		}
446		if modelOverride != "" {
447			next.Model = modelOverride
448		}
449		resolved, err := Resolve(next, true)
450		if err != nil {
451			return nil, "", "", err
452		}
453		resolved.UseSandbox(sharedSandbox)
454		resolved.MergeExtensionTools(extToolAdapter)
455		return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
456	}
457
458	var ag *core.Agent
459	if r.HasCredential() {
460		ag = wireAgentExt(r.NewAgent())
461	}
462
463	// /reload-ext callback: after the manager has respawned every
464	// extension, re-resolve the tool registry (built-ins + freshly-
465	// registered extension tools) and swap it onto the current
466	// agent in-place. The current agent may have been replaced by a
467	// /model swap since spawn, so re-read the live `ag` on each
468	// invocation.
469	extMgr.SetOnReload(func() {
470		current := ag
471		if current == nil {
472			return
473		}
474		resolved, err := Resolve(args, true)
475		if err != nil {
476			return
477		}
478		resolved.UseSandbox(sharedSandbox)
479		resolved.MergeExtensionTools(extToolAdapter)
480		current.SetTools(resolved.ToolRegistry)
481	})
482
483	// Fire session_start once we know the manager's running.
484	extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
485
486	var sess *core.Session
487	var sessBaselineMsgs int // messages already on disk when current session opened
488	// persistMu guards sess + sessBaselineMsgs against concurrent access
489	// from the agent loop's per-message persistence hook (runs on the
490	// agent goroutine) and the TUI's session swap / flush callbacks
491	// (run on the TUI goroutine). Without this, a /sessions swap that
492	// races with a finishing turn could double-write or lose messages.
493	var persistMu sync.Mutex
494	if !args.NoSess && ag != nil {
495		sess, _ = openOrCreateSession(args, r, ag, version)
496		if ag != nil {
497			sessBaselineMsgs = len(ag.Messages())
498		}
499	}
500	defer func() {
501		persistMu.Lock()
502		defer persistMu.Unlock()
503		if sess != nil {
504			sess.Close()
505		}
506	}()
507
508	// persistMessage is the per-message hook bound to the agent. It
509	// appends each new transcript message to the live session as soon
510	// as it lands, so a kill / closed terminal / OS crash costs at
511	// most the in-flight turn instead of the whole session. The
512	// baseline counter advances in lock-step so the exit-time flush
513	// doesn't double-write rows already on disk.
514	persistMessage := func(m provider.Message) {
515		persistMu.Lock()
516		defer persistMu.Unlock()
517		if sess == nil {
518			return
519		}
520		if err := sess.AppendMessage(m); err == nil {
521			sessBaselineMsgs++
522		}
523	}
524	persistUsage := func(cum provider.Usage) {
525		persistMu.Lock()
526		defer persistMu.Unlock()
527		if sess == nil {
528			return
529		}
530		_ = sess.AppendUsage(cum, cum)
531	}
532	wireAgentPersist := func(a *core.Agent) *core.Agent {
533		if a == nil {
534			return a
535		}
536		a.OnMessageAppended = persistMessage
537		a.OnUsage = persistUsage
538		return a
539	}
540	wireAgentPersist(ag)
541
542	// Re-wrap the build closures so any agent constructed by the TUI
543	// (login, /model swap to a different provider) also gets the
544	// persistence hooks. Without this, switching provider would
545	// silently revert to the old in-memory-only behaviour.
546	baseBuildAgent := buildAgent
547	buildAgent = func() (*core.Agent, string, string, error) {
548		a, p, m, err := baseBuildAgent()
549		return wireAgentPersist(a), p, m, err
550	}
551	baseBuildAgentFor := buildAgentFor
552	buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
553		a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
554		return wireAgentPersist(a), p, m, err
555	}
556
557	// loadSession replaces the current session with the one at path and
558	// hands its messages to the agent. Used by the /sessions picker.
559	loadSession := func(path string) error {
560		currentAg := ag // captured
561		if currentAg == nil {
562			return fmt.Errorf("no agent running; log in first")
563		}
564		newSess, msgs, err := core.OpenSession(path)
565		if err != nil {
566			return err
567		}
568		persistMu.Lock()
569		// Flush any unsaved messages to the old session before swapping.
570		// Per-message persistence keeps sessBaselineMsgs current, so
571		// this is a defensive no-op in the common case; it still
572		// matters for the rare race where a turn just finished and
573		// hadn't fired its hook yet.
574		if sess != nil {
575			writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
576			_ = sess.Close()
577		}
578		sess = newSess
579		currentAg.SetMessages(msgs)
580		if usage, uerr := core.SessionUsage(path); uerr == nil {
581			currentAg.SeedCost(usage)
582		}
583		sessBaselineMsgs = len(msgs)
584		persistMu.Unlock()
585		return nil
586	}
587
588	term := tui.NewProcTerm()
589
590	// Kick off the async update check so the banner can appear when the
591	// http response eventually arrives (usually <1s on cached DNS). Map
592	// agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
593	updateCh := make(chan modes.UpdateInfo, 1)
594	go func() {
595		defer close(updateCh)
596		src := <-CheckForUpdateAsync(ZotHome(), version)
597		updateCh <- modes.UpdateInfo{
598			Current:   src.Current,
599			Latest:    src.Latest,
600			Available: src.Available,
601			URL:       src.URL,
602		}
603	}()
604
605	// Changelog: when the running version differs from the last
606	// version whose release notes the user dismissed, fetch the
607	// release body from GitHub and have the TUI show it once. On
608	// first-ever launch (no prior LastChangelogShown), seed the
609	// stored version silently — don't dump release notes at someone
610	// who just installed.
611	changelogCh := make(chan modes.ChangelogPayload, 1)
612	go func() {
613		defer close(changelogCh)
614		cfg, _ := LoadConfig()
615		if cfg.LastChangelogShown == "" {
616			SeedChangelogVersion(version)
617			return
618		}
619		if !ShouldShowChangelog(version, cfg) {
620			return
621		}
622		info := <-FetchChangelogAsync(version)
623		if info.Body == "" {
624			return
625		}
626		// For dev builds (0.0.0), skip if the latest release was
627		// already shown (stored by the dismiss callback).
628		if version == "0.0.0" && info.Version == cfg.LastChangelogShown {
629			return
630		}
631		changelogCh <- modes.ChangelogPayload{
632			Version: info.Version,
633			Body:    info.Body,
634			URL:     info.URL,
635		}
636	}()
637
638	iv = modes.NewInteractive(modes.InteractiveConfig{
639		Terminal:       term,
640		Theme:          tui.DetectThemeFromBackground(80 * time.Millisecond),
641		Model:          r.Model,
642		Provider:       r.Provider,
643		AuthMethod:     r.AuthMethod,
644		BaseURL:        r.BaseURL,
645		Reasoning:      r.Reasoning,
646		SystemPrompt:   r.SystemPrompt,
647		Tools:          r.ToolRegistry,
648		MaxSteps:       r.MaxSteps,
649		CWD:            r.CWD,
650		ZotHome:        ZotHome(),
651		Version:        version,
652		UpdateInfoChan: updateCh,
653		Sandbox:        sharedSandbox,
654		Agent:          ag,
655		InitialInput:   args.Prompt,
656		AuthManager:    mgr,
657		BuildAgent:     buildAgent,
658		BuildAgentFor:  buildAgentFor,
659		LoggedInProviders: func() []string {
660			var out []string
661			for _, p := range []string{"anthropic", "openai"} {
662				if _, _, err := ResolveCredential(p, ""); err == nil {
663					out = append(out, p)
664				}
665			}
666			// Ollama models are always available (no auth needed).
667			out = append(out, "ollama")
668			return out
669		},
670		LoadSession: loadSession,
671		CurrentSessionPath: func() string {
672			if sess == nil {
673				return ""
674			}
675			return sess.Path
676		},
677		FlushSession: func() {
678			// Append any not-yet-persisted agent messages to the
679			// current session file, then advance the baseline so
680			// the final WriteNewTranscript at exit doesn't write
681			// duplicates. Per-message persistence keeps the on-
682			// disk file current already, so this is mostly a
683			// defensive flush — still needed for /session export
684			// to guarantee the exported bytes include the very
685			// last in-flight turn.
686			currentAg := iv.Agent()
687			if currentAg == nil {
688				return
689			}
690			persistMu.Lock()
691			defer persistMu.Unlock()
692			if sess == nil {
693				return
694			}
695			writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
696			sessBaselineMsgs = len(currentAg.Messages())
697		},
698		Extensions:    extMgr,
699		ChangelogChan: changelogCh,
700		OnChangelogDismiss: func() {
701			// For dev builds (0.0.0) store the actual release version
702			// so the same changelog doesn't show again next launch.
703			// For real builds, store the binary version.
704			v := version
705			if v == "0.0.0" {
706				if iv != nil && iv.ChangelogVersion() != "" {
707					v = iv.ChangelogVersion()
708				}
709			}
710			_ = MarkChangelogShown(v)
711		},
712		SkillSnapshot: func() []*skills.Skill {
713			if args.NoSkill {
714				// --no-skill: nothing for the picker to show.
715				return nil
716			}
717			// Re-discover so the picker reflects edits made during
718			// the session. Cheap; SKILL.md files are small. Filter
719			// out built-in skills — they're hidden from user-facing
720			// surfaces because they're implementation detail; the
721			// model still sees them through the system-prompt
722			// manifest and the skill tool. User skills only appear
723			// when --with-skills is set; without it the picker shows
724			// nothing but the model still has the built-ins.
725			userHome, _ := os.UserHomeDir()
726			list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
727			return skills.VisibleSkills(list)
728		},
729		NoYolo:      args.NoYolo,
730		ConfirmGate: confirmGate,
731		PersistModel: func(providerName, model string) {
732			// Update config.json so next launch uses the same pick.
733			cfg, _ := LoadConfig()
734			cfg.Provider = providerName
735			cfg.Model = model
736			_ = SaveConfig(cfg)
737			// Update the active session's meta so resume picks this up.
738			if sess != nil {
739				_ = sess.UpdateModel(providerName, model)
740			}
741		},
742	})
743
744	// Bind the interactive TUI as the Confirmer. We deferred this
745	// until now because the gate is constructed before the TUI
746	// (the BeforeToolExecute closure captures it). SetConfirmer
747	// is mutex-guarded on the gate so this is safe.
748	if confirmGate != nil {
749		confirmGate.SetConfirmer(iv)
750	}
751
752	// Signal-driven flush: a SIGTERM / SIGHUP to the zot process
753	// (closed terminal window, system shutdown, kill) used to lose
754	// the entire in-memory transcript because the deferred post-Run
755	// flush below never ran. Per-message persistence above covers
756	// most of it; this handler writes any in-flight remainder and
757	// then exits the process so we don't double-paint over a
758	// broken terminal that the TUI's restore deferreds can no
759	// longer fix from a signal context.
760	//
761	// SIGINT is intentionally NOT handled here — the TUI consumes
762	// Ctrl+C as a regular key event for cancel/clear semantics, and
763	// installing a SIGINT notifier here would swallow it.
764	sigCh := make(chan os.Signal, 1)
765	signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
766	defer signal.Stop(sigCh)
767	go func() {
768		_, ok := <-sigCh
769		if !ok {
770			return
771		}
772		if finalAg := iv.Agent(); finalAg != nil {
773			persistMu.Lock()
774			if sess != nil {
775				writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
776				sessBaselineMsgs = len(finalAg.Messages())
777				_ = sess.Close()
778				sess = nil
779			}
780			persistMu.Unlock()
781		}
782		// Exit cleanly. Re-raising the signal would skip os.Exit's
783		// at-exit hooks; explicit exit is fine because we've already
784		// flushed the only at-risk state (the session file).
785		os.Exit(0)
786	}()
787
788	runErr := iv.Run(ctx)
789
790	// Flush final transcript to session (only if we had / ended up with an agent).
791	if finalAg := iv.Agent(); finalAg != nil {
792		persistMu.Lock()
793		if sess != nil {
794			writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
795			sessBaselineMsgs = len(finalAg.Messages())
796		}
797		persistMu.Unlock()
798	}
799	return runErr
800}
801
802// openOrCreateSession returns a session for the run. sess may be nil
803// with a nil error if session persistence is disabled.
804func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
805	if args.NoSess {
806		return nil, nil
807	}
808	// Sweep meta-only files left over from older zot versions (and from
809	// any session that crashed before its first AppendMessage). Cheap;
810	// reads the first few bytes of each file in the cwd's session dir.
811	core.PruneEmptySessions(ZotHome(), args.CWD)
812	var (
813		s    *core.Session
814		msgs []provider.Message
815		err  error
816	)
817	switch {
818	case args.Session != "":
819		s, msgs, err = core.OpenSession(args.Session)
820	case args.Continue:
821		latest := core.LatestSession(ZotHome(), args.CWD)
822		if latest != "" {
823			s, msgs, err = core.OpenSession(latest)
824		}
825	case args.Resume:
826		picked, perr := pickSession(args.CWD)
827		if perr != nil {
828			return nil, perr
829		}
830		if picked != "" {
831			s, msgs, err = core.OpenSession(picked)
832		}
833	}
834	if err != nil {
835		return nil, err
836	}
837	if s != nil {
838		ag.SetMessages(msgs)
839		if usage, uerr := core.SessionUsage(s.Path); uerr == nil {
840			ag.SeedCost(usage)
841		}
842		return s, nil
843	}
844	return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
845}
846
847func pickSession(cwd string) (string, error) {
848	files := core.ListSessions(ZotHome(), cwd)
849	if len(files) == 0 {
850		fmt.Fprintln(os.Stderr, "no sessions for", cwd)
851		return "", nil
852	}
853	for i, f := range files {
854		fmt.Fprintf(os.Stderr, "  %2d) %s\n", i+1, f)
855	}
856	fmt.Fprint(os.Stderr, "pick #: ")
857	rd := bufio.NewReader(os.Stdin)
858	line, _ := rd.ReadString('\n')
859	line = strings.TrimSpace(line)
860	var n int
861	if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
862		return "", fmt.Errorf("invalid selection")
863	}
864	return files[n-1], nil
865}
866
867// WriteNewTranscript appends only messages after index `from` from the
868// agent's transcript to the session. Used by callers that don't hold
869// the persistMu (non-interactive print/json modes which run a single
870// turn under their own goroutine).
871func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
872	writeNewTranscriptLocked(ag, sess, from)
873}
874
875// writeNewTranscriptLocked is the same as WriteNewTranscript. The
876// suffix marks that interactive callers must hold persistMu when
877// invoking it so concurrent appends from the agent loop don't race
878// with this catch-up flush.
879func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) {
880	if sess == nil || ag == nil {
881		return
882	}
883	msgs := ag.Messages()
884	for i := from; i < len(msgs); i++ {
885		_ = sess.AppendMessage(msgs[i])
886	}
887	cum := ag.Cost()
888	_ = sess.AppendUsage(cum, cum)
889}
890
891func readAllStdin() (string, error) {
892	fi, err := os.Stdin.Stat()
893	if err != nil {
894		return "", err
895	}
896	if (fi.Mode() & os.ModeCharDevice) != 0 {
897		return "", nil
898	}
899	b, err := io.ReadAll(os.Stdin)
900	return string(b), err
901}
902
903func printModels() {
904	fmt.Println("provider   model id                       context  max-out  reasoning  source        name")
905	for _, m := range provider.Active() {
906		reason := " "
907		if m.Reasoning {
908			reason = "✓"
909		}
910		source := m.Source
911		if source == "" {
912			source = "catalog"
913		}
914		if m.Speculative {
915			source = "speculative"
916		}
917		fmt.Printf("%-10s %-30s %8d %8d     %s        %-11s   %s\n",
918			m.Provider, m.ID, m.ContextWindow, m.MaxOutput, reason, source, m.DisplayName)
919	}
920}

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