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}