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