v0.1.81 1aea23e

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

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