v0.1.88 14ffdb6

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

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