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}