1package agent
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "os"
10 "strings"
11 "time"
12
13 "github.com/patriceckhart/zot/internal/agent/extensions"
14 "github.com/patriceckhart/zot/internal/agent/modes"
15 "github.com/patriceckhart/zot/internal/auth"
16 "github.com/patriceckhart/zot/internal/core"
17 "github.com/patriceckhart/zot/internal/extproto"
18 "github.com/patriceckhart/zot/internal/provider"
19 "github.com/patriceckhart/zot/internal/skills"
20 "github.com/patriceckhart/zot/internal/tui"
21)
22
23// interactiveExtHooks is a tiny adapter that lets the extension
24// manager call back into the Interactive instance built later in
25// runInteractive. The forward-declared *modes.Interactive is filled
26// in immediately after manager construction.
27type interactiveExtHooks struct {
28 ivPtr **modes.Interactive
29}
30
31func (h *interactiveExtHooks) iv() *modes.Interactive {
32 if h == nil || h.ivPtr == nil {
33 return nil
34 }
35 return *h.ivPtr
36}
37
38func (h *interactiveExtHooks) Notify(extName, level, message string) {
39 if iv := h.iv(); iv != nil {
40 iv.Notify(extName, level, message)
41 }
42}
43func (h *interactiveExtHooks) Submit(text string) {
44 if iv := h.iv(); iv != nil {
45 iv.Submit(text)
46 }
47}
48func (h *interactiveExtHooks) Insert(text string) {
49 if iv := h.iv(); iv != nil {
50 iv.Insert(text)
51 }
52}
53func (h *interactiveExtHooks) Display(extName, text string) {
54 if iv := h.iv(); iv != nil {
55 iv.Display(extName, text)
56 }
57}
58func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
59 if iv := h.iv(); iv != nil {
60 iv.OpenPanel(extName, spec)
61 }
62}
63func (h *interactiveExtHooks) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
64 if iv := h.iv(); iv != nil {
65 iv.UpdatePanel(extName, panelID, title, lines, footer)
66 }
67}
68func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
69 if iv := h.iv(); iv != nil {
70 iv.ClosePanel(extName, panelID)
71 }
72}
73
74// extToolAdapter bridges *extensions.Manager to the
75// ExtensionToolSource interface declared in build.go (kept narrow to
76// avoid a build->extensions import cycle). One adapter instance per
77// run; used at every Resolve point so re-built agents pick up the
78// same set of extension tools.
79type extToolAdapter struct {
80 mgr *extensions.Manager
81}
82
83func (a *extToolAdapter) Tools() []ExtensionToolInfo {
84 infos := a.mgr.Tools()
85 out := make([]ExtensionToolInfo, len(infos))
86 for i, t := range infos {
87 out[i] = ExtensionToolInfo{
88 Extension: t.Extension,
89 Name: t.Name,
90 Description: t.Description,
91 Schema: t.Schema,
92 }
93 }
94 return out
95}
96
97func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
98 return extensions.NewTool(a.mgr, extensions.ToolInfo{
99 Extension: info.Extension,
100 Name: info.Name,
101 Description: info.Description,
102 Schema: info.Schema,
103 })
104}
105
106// fanoutAgentEvent translates a core.AgentEvent into the wire-format
107// EventFromHost and pushes it through the extension manager. Only
108// the events that have a clear extension-facing meaning are
109// forwarded; internal-only ones (text_delta, tool_progress) are
110// dropped to keep the per-extension stream sane.
111func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
112 if mgr == nil {
113 return
114 }
115 switch e := ev.(type) {
116 case core.EvTurnStart:
117 mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
118 case core.EvToolCall:
119 mgr.EmitEvent(extproto.EventFromHost{
120 Event: "tool_call", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args,
121 })
122 case core.EvAssistantMessage:
123 // Concat the visible text portions of the message; binary
124 // blocks (tool_use, etc.) are skipped because subscribers
125 // usually want a string they can grep / display.
126 var text string
127 for _, c := range e.Message.Content {
128 if tb, ok := c.(provider.TextBlock); ok {
129 text += tb.Text
130 }
131 }
132 mgr.EmitEvent(extproto.EventFromHost{Event: "assistant_message", Text: text})
133 case core.EvTurnEnd:
134 ev := extproto.EventFromHost{Event: "turn_end", Stop: string(e.Stop)}
135 if e.Err != nil {
136 ev.Error = e.Err.Error()
137 }
138 mgr.EmitEvent(ev)
139 }
140}
141
142// Run is the top-level entrypoint for the zot binary.
143func Run(rawArgs []string, version string) error {
144 // Subcommand router: `zot bot ...` is handled separately so the
145 // generic flag parser doesn't reject "bot" as a positional arg.
146 if handled, err := runBotCommand(rawArgs, version); handled {
147 return err
148 }
149 if handled, err := runExtCommand(rawArgs); handled {
150 return err
151 }
152 // `zot rpc` is shorthand for `zot --rpc` so third-party apps can
153 // spawn the binary with a clean argv. Strip the leading 'rpc'
154 // token and let the rest flow through the normal arg parser.
155 if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
156 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
157 }
158
159 args, err := ParseArgs(rawArgs)
160 if err != nil {
161 PrintHelp(version)
162 return err
163 }
164 if args.Help {
165 PrintHelp(version)
166 return nil
167 }
168 if args.Version {
169 fmt.Println("zot", version)
170 return nil
171 }
172 // Model catalog: load any cached discovery data before we inspect
173 // the model list (list-models, print/json, interactive).
174 LoadCachedModels()
175
176 if args.ListModels {
177 printModels()
178 return nil
179 }
180
181 ctx := context.Background()
182
183 // Kick an async refresh of the live model catalog. The first run of
184 // zot hits the network; subsequent runs within CacheTTL do nothing.
185 RefreshModelsAsync()
186
187 switch args.Mode {
188 case ModePrint:
189 return runPrintMode(ctx, args, version)
190 case ModeJSON:
191 return runJSONMode(ctx, args, version)
192 case ModeRPC:
193 return runRPCMode(ctx, args, version)
194 default:
195 return runInteractive(ctx, args, version)
196 }
197}
198
199// ---- print / json modes: require credentials, run single-shot ----
200
201// nonInteractiveExtHooks is the HostHooks impl used by print / json
202// modes. They have no TUI, so notify / display go to stderr and
203// submit / insert are no-ops (the extension can't steer a
204// single-shot run once it's in flight anyway).
205type nonInteractiveExtHooks struct{}
206
207func (nonInteractiveExtHooks) Notify(ext, level, message string) {
208 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
209}
210func (nonInteractiveExtHooks) Submit(string) {}
211func (nonInteractiveExtHooks) Insert(string) {}
212func (nonInteractiveExtHooks) Display(string, string) {}
213func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
214func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
215func (nonInteractiveExtHooks) ClosePanel(string, string) {}
216
217// setupNonInteractiveExtensions loads --ext paths and (unless
218// --no-ext) runs discovery. Returns the manager so the caller can
219// wire tools into the resolved registry, and a cleanup closure to
220// defer. Mirrors the interactive-mode setup minus the TUI hooks.
221func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) {
222 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{})
223 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
224 fmt.Fprintln(os.Stderr, "extension load:", e)
225 }
226 if !args.NoExt {
227 for _, e := range extMgr.Discover(ctx) {
228 fmt.Fprintln(os.Stderr, "extension load:", e)
229 }
230 }
231 extMgr.WaitForReady(3 * time.Second)
232 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
233 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
234 return extMgr, func() { extMgr.Stop(2 * time.Second) }
235}
236
237// wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
238// / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
239// interactive path wires up, so extensions get their normal
240// event-intercept surface in print / json / rpc flows too.
241func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) {
242 if ag == nil || extMgr == nil {
243 return
244 }
245 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
246 res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
247 if res.Block {
248 return false, res.Reason, nil
249 }
250 return true, "", res.ModifiedArgs
251 }
252 ag.BeforeTurn = func(step int) (bool, string) {
253 res := extMgr.InterceptTurnStart(ctx, step)
254 return !res.Block, res.Reason
255 }
256 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
257 res := extMgr.InterceptAssistantMessage(ctx, text)
258 if res.Block {
259 return false, res.Reason, ""
260 }
261 return true, "", res.ReplaceText
262 }
263 ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
264}
265
266func runPrintMode(ctx context.Context, args Args, version string) error {
267 if args.NoYolo {
268 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
269 }
270 r, err := Resolve(args, true)
271 if err != nil {
272 return err
273 }
274 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
275 defer stopExt()
276
277 ag := r.NewAgent()
278 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
279 sess, _ := openOrCreateSession(args, r, ag, version)
280 defer sess.Close()
281
282 prompt := args.Prompt
283 if prompt == "" {
284 piped, _ := readAllStdin()
285 prompt = strings.TrimSpace(piped)
286 }
287 if prompt == "" {
288 return fmt.Errorf("print mode requires a prompt (arg or stdin)")
289 }
290
291 start := len(ag.Messages())
292 err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
293 WriteNewTranscript(ag, sess, start)
294 return err
295}
296
297func runJSONMode(ctx context.Context, args Args, version string) error {
298 if args.NoYolo {
299 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
300 }
301 r, err := Resolve(args, true)
302 if err != nil {
303 return err
304 }
305 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
306 defer stopExt()
307
308 ag := r.NewAgent()
309 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
310 sess, _ := openOrCreateSession(args, r, ag, version)
311 defer sess.Close()
312
313 prompt := args.Prompt
314 if prompt == "" {
315 piped, _ := readAllStdin()
316 prompt = strings.TrimSpace(piped)
317 }
318 if prompt == "" {
319 return fmt.Errorf("json mode requires a prompt (arg or stdin)")
320 }
321
322 start := len(ag.Messages())
323 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
324 WriteNewTranscript(ag, sess, start)
325 return err
326}
327
328// ---- interactive mode: opens the TUI even without credentials ----
329
330func runInteractive(ctx context.Context, args Args, version string) error {
331 // Resolve WITHOUT requiring credentials.
332 r, err := Resolve(args, false)
333 if err != nil {
334 return err
335 }
336
337 authStore := AuthStoreFor()
338 mgr := auth.NewManager(authStore)
339 defer mgr.Close()
340
341 // Keep the sandbox pointer stable across agent rebuilds (login / model
342 // switch). The Interactive UI toggles the lock via this pointer, and
343 // rebuilt tool instances must share the same one so the lock sticks.
344 sharedSandbox := r.Sandbox
345
346 // Build the extension manager BEFORE the agent so we can fold
347 // extension-defined tools into the registry. Forward-declare iv so
348 // the host hooks adapter can dereference it after construction.
349 var iv *modes.Interactive
350 extHooks := &interactiveExtHooks{ivPtr: &iv}
351 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
352 // --ext paths first so they win against installed extensions of
353 // the same name (loadOne's first-write-wins semantics).
354 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
355 fmt.Fprintln(os.Stderr, "extension load:", e)
356 }
357 // --no-ext skips the global + project-local discovery scan;
358 // explicit --ext paths above are still honoured so you can run
359 // "only this extension" with --no-ext --ext ./x.
360 if !args.NoExt {
361 for _, e := range extMgr.Discover(ctx) {
362 fmt.Fprintln(os.Stderr, "extension load:", e)
363 }
364 }
365 // Wait briefly for extensions to flush their initial register_tool
366 // frames before we build the agent's tool registry. Half a second
367 // is plenty for any extension that's actually well-behaved; ones
368 // that don't send a ready frame eat the full grace and proceed.
369 // 3s is the per-extension grace period for the ready frame.
370 // Native binaries are instant; runtimes like `npx tsx` take ~1.5s
371 // from cold cache. The wait is tight only for extensions that
372 // haven't sent ready by then; ones that signalled earlier release
373 // the wait immediately.
374 extMgr.WaitForReady(3 * time.Second)
375 defer extMgr.Stop(2 * time.Second)
376
377 extToolAdapter := &extToolAdapter{mgr: extMgr}
378 r.MergeExtensionTools(extToolAdapter)
379
380 // Confirmation gate: when --no-yolo is on, the agent must ask
381 // the user before every tool call. In interactive mode the TUI
382 // provides the Confirmer; in print/json/rpc modes there's no
383 // way to prompt, so the gate is constructed with a nil inner
384 // which auto-refuses every call with a helpful reason.
385 var confirmGate *core.ConfirmGate
386 if args.NoYolo {
387 confirmGate = core.NewConfirmGate(nil) // set below for interactive
388 }
389
390 // Capture current args in a closure so BuildAgent can re-resolve
391 // after a successful login (picks up the newly stored credential).
392 wireAgentExt := func(a *core.Agent) *core.Agent {
393 if a == nil {
394 return a
395 }
396 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
397 // Confirm gate runs FIRST: if the user refused, we don't
398 // waste extension-intercept time or let guards see the call.
399 if confirmGate != nil {
400 ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
401 if !ok {
402 return false, reason, nil
403 }
404 }
405 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
406 if r.Block {
407 return false, r.Reason, nil
408 }
409 return true, "", r.ModifiedArgs
410 }
411 a.BeforeTurn = func(step int) (bool, string) {
412 r := extMgr.InterceptTurnStart(ctx, step)
413 return !r.Block, r.Reason
414 }
415 a.BeforeAssistantMessage = func(text string) (bool, string, string) {
416 r := extMgr.InterceptAssistantMessage(ctx, text)
417 if r.Block {
418 return false, r.Reason, ""
419 }
420 return true, "", r.ReplaceText
421 }
422 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
423 return a
424 }
425
426 buildAgent := func() (*core.Agent, string, string, error) {
427 resolved, err := Resolve(args, true)
428 if err != nil {
429 return nil, "", "", err
430 }
431 resolved.UseSandbox(sharedSandbox)
432 resolved.MergeExtensionTools(extToolAdapter)
433 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
434 }
435
436 // Rebuild agent with an explicit provider/model override.
437 buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
438 next := args
439 if providerOverride != "" {
440 next.Provider = providerOverride
441 }
442 if modelOverride != "" {
443 next.Model = modelOverride
444 }
445 resolved, err := Resolve(next, true)
446 if err != nil {
447 return nil, "", "", err
448 }
449 resolved.UseSandbox(sharedSandbox)
450 resolved.MergeExtensionTools(extToolAdapter)
451 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
452 }
453
454 var ag *core.Agent
455 if r.HasCredential() {
456 ag = wireAgentExt(r.NewAgent())
457 }
458
459 // /reload-ext callback: after the manager has respawned every
460 // extension, re-resolve the tool registry (built-ins + freshly-
461 // registered extension tools) and swap it onto the current
462 // agent in-place. The current agent may have been replaced by a
463 // /model swap since spawn, so re-read the live `ag` on each
464 // invocation.
465 extMgr.SetOnReload(func() {
466 current := ag
467 if current == nil {
468 return
469 }
470 resolved, err := Resolve(args, true)
471 if err != nil {
472 return
473 }
474 resolved.UseSandbox(sharedSandbox)
475 resolved.MergeExtensionTools(extToolAdapter)
476 current.SetTools(resolved.ToolRegistry)
477 })
478
479 // Fire session_start once we know the manager's running.
480 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
481
482 var sess *core.Session
483 var sessBaselineMsgs int // messages already on disk when current session opened
484 if !args.NoSess && ag != nil {
485 sess, _ = openOrCreateSession(args, r, ag, version)
486 if ag != nil {
487 sessBaselineMsgs = len(ag.Messages())
488 }
489 }
490 defer func() {
491 if sess != nil {
492 sess.Close()
493 }
494 }()
495
496 // loadSession replaces the current session with the one at path and
497 // hands its messages to the agent. Used by the /sessions picker.
498 loadSession := func(path string) error {
499 currentAg := ag // captured
500 if currentAg == nil {
501 return fmt.Errorf("no agent running; log in first")
502 }
503 newSess, msgs, err := core.OpenSession(path)
504 if err != nil {
505 return err
506 }
507 // Flush any unsaved messages to the old session before swapping.
508 if sess != nil {
509 WriteNewTranscript(currentAg, sess, sessBaselineMsgs)
510 _ = sess.Close()
511 }
512 sess = newSess
513 currentAg.SetMessages(msgs)
514 sessBaselineMsgs = len(msgs)
515 return nil
516 }
517
518 term := tui.NewProcTerm()
519
520 // Kick off the async update check so the banner can appear when the
521 // http response eventually arrives (usually <1s on cached DNS). Map
522 // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
523 updateCh := make(chan modes.UpdateInfo, 1)
524 go func() {
525 defer close(updateCh)
526 src := <-CheckForUpdateAsync(ZotHome(), version)
527 updateCh <- modes.UpdateInfo{
528 Current: src.Current,
529 Latest: src.Latest,
530 Available: src.Available,
531 URL: src.URL,
532 }
533 }()
534
535 // Changelog: when the running version differs from the last
536 // version whose release notes the user dismissed, fetch the
537 // release body from GitHub and have the TUI show it once. On
538 // first-ever launch (no prior LastChangelogShown), seed the
539 // stored version silently — don't dump release notes at someone
540 // who just installed.
541 changelogCh := make(chan modes.ChangelogPayload, 1)
542 go func() {
543 defer close(changelogCh)
544 cfg, _ := LoadConfig()
545 if cfg.LastChangelogShown == "" {
546 SeedChangelogVersion(version)
547 return
548 }
549 if !ShouldShowChangelog(version, cfg) {
550 return
551 }
552 info := <-FetchChangelogAsync(version)
553 if info.Body == "" {
554 return
555 }
556 changelogCh <- modes.ChangelogPayload{
557 Version: info.Version,
558 Body: info.Body,
559 URL: info.URL,
560 }
561 }()
562
563 iv = modes.NewInteractive(modes.InteractiveConfig{
564 Terminal: term,
565 Theme: tui.Dark,
566 Model: r.Model,
567 Provider: r.Provider,
568 AuthMethod: r.AuthMethod,
569 BaseURL: r.BaseURL,
570 Reasoning: r.Reasoning,
571 SystemPrompt: r.SystemPrompt,
572 Tools: r.ToolRegistry,
573 MaxSteps: r.MaxSteps,
574 CWD: r.CWD,
575 ZotHome: ZotHome(),
576 Version: version,
577 UpdateInfoChan: updateCh,
578 Sandbox: sharedSandbox,
579 Agent: ag,
580 InitialInput: args.Prompt,
581 AuthManager: mgr,
582 BuildAgent: buildAgent,
583 BuildAgentFor: buildAgentFor,
584 LoadSession: loadSession,
585 CurrentSessionPath: func() string {
586 if sess == nil {
587 return ""
588 }
589 return sess.Path
590 },
591 FlushSession: func() {
592 // Append any not-yet-persisted agent messages to the
593 // current session file, then advance the baseline so
594 // the final WriteNewTranscript at exit doesn't write
595 // duplicates. Called by /session export so the
596 // exported bytes include everything the user has
597 // said in the running TUI, not just whatever got
598 // flushed at startup.
599 if sess == nil {
600 return
601 }
602 currentAg := iv.Agent()
603 if currentAg == nil {
604 return
605 }
606 WriteNewTranscript(currentAg, sess, sessBaselineMsgs)
607 sessBaselineMsgs = len(currentAg.Messages())
608 },
609 Extensions: extMgr,
610 ChangelogChan: changelogCh,
611 OnChangelogDismiss: func() {
612 _ = MarkChangelogShown(version)
613 },
614 SkillSnapshot: func() []*skills.Skill {
615 if args.NoSkill {
616 // --no-skill: nothing for the picker to show.
617 return nil
618 }
619 // Re-discover so the picker reflects edits made during
620 // the session. Cheap; SKILL.md files are small. Filter
621 // out built-in skills — they're hidden from user-facing
622 // surfaces because they're implementation detail; the
623 // model still sees them through the system-prompt
624 // manifest and the skill tool. User skills only appear
625 // when --with-skills is set; without it the picker shows
626 // nothing but the model still has the built-ins.
627 userHome, _ := os.UserHomeDir()
628 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
629 return skills.VisibleSkills(list)
630 },
631 NoYolo: args.NoYolo,
632 ConfirmGate: confirmGate,
633 PersistModel: func(providerName, model string) {
634 // Update config.json so next launch uses the same pick.
635 cfg, _ := LoadConfig()
636 cfg.Provider = providerName
637 cfg.Model = model
638 _ = SaveConfig(cfg)
639 // Update the active session's meta so resume picks this up.
640 if sess != nil {
641 _ = sess.UpdateModel(providerName, model)
642 }
643 },
644 })
645
646 // Bind the interactive TUI as the Confirmer. We deferred this
647 // until now because the gate is constructed before the TUI
648 // (the BeforeToolExecute closure captures it). SetConfirmer
649 // is mutex-guarded on the gate so this is safe.
650 if confirmGate != nil {
651 confirmGate.SetConfirmer(iv)
652 }
653
654 runErr := iv.Run(ctx)
655
656 // Flush final transcript to session (only if we had / ended up with an agent).
657 if finalAg := iv.Agent(); finalAg != nil && sess != nil {
658 WriteNewTranscript(finalAg, sess, sessBaselineMsgs)
659 }
660 return runErr
661}
662
663// openOrCreateSession returns a session for the run. sess may be nil
664// with a nil error if session persistence is disabled.
665func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
666 if args.NoSess {
667 return nil, nil
668 }
669 // Sweep meta-only files left over from older zot versions (and from
670 // any session that crashed before its first AppendMessage). Cheap;
671 // reads the first few bytes of each file in the cwd's session dir.
672 core.PruneEmptySessions(ZotHome(), args.CWD)
673 var (
674 s *core.Session
675 msgs []provider.Message
676 err error
677 )
678 switch {
679 case args.Session != "":
680 s, msgs, err = core.OpenSession(args.Session)
681 case args.Continue:
682 latest := core.LatestSession(ZotHome(), args.CWD)
683 if latest != "" {
684 s, msgs, err = core.OpenSession(latest)
685 }
686 case args.Resume:
687 picked, perr := pickSession(args.CWD)
688 if perr != nil {
689 return nil, perr
690 }
691 if picked != "" {
692 s, msgs, err = core.OpenSession(picked)
693 }
694 }
695 if err != nil {
696 return nil, err
697 }
698 if s != nil {
699 ag.SetMessages(msgs)
700 return s, nil
701 }
702 return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
703}
704
705func pickSession(cwd string) (string, error) {
706 files := core.ListSessions(ZotHome(), cwd)
707 if len(files) == 0 {
708 fmt.Fprintln(os.Stderr, "no sessions for", cwd)
709 return "", nil
710 }
711 for i, f := range files {
712 fmt.Fprintf(os.Stderr, " %2d) %s\n", i+1, f)
713 }
714 fmt.Fprint(os.Stderr, "pick #: ")
715 rd := bufio.NewReader(os.Stdin)
716 line, _ := rd.ReadString('\n')
717 line = strings.TrimSpace(line)
718 var n int
719 if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
720 return "", fmt.Errorf("invalid selection")
721 }
722 return files[n-1], nil
723}
724
725// WriteNewTranscript appends only messages after index `from` from the
726// agent's transcript to the session.
727func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
728 if sess == nil || ag == nil {
729 return
730 }
731 msgs := ag.Messages()
732 for i := from; i < len(msgs); i++ {
733 _ = sess.AppendMessage(msgs[i])
734 }
735 cum := ag.Cost()
736 _ = sess.AppendUsage(cum, cum)
737}
738
739func readAllStdin() (string, error) {
740 fi, err := os.Stdin.Stat()
741 if err != nil {
742 return "", err
743 }
744 if (fi.Mode() & os.ModeCharDevice) != 0 {
745 return "", nil
746 }
747 b, err := io.ReadAll(os.Stdin)
748 return string(b), err
749}
750
751func printModels() {
752 fmt.Println("provider model id context max-out reasoning source name")
753 for _, m := range provider.Active() {
754 reason := " "
755 if m.Reasoning {
756 reason = "✓"
757 }
758 source := m.Source
759 if source == "" {
760 source = "catalog"
761 }
762 if m.Speculative {
763 source = "speculative"
764 }
765 fmt.Printf("%-10s %-30s %8d %8d %s %-11s %s\n",
766 m.Provider, m.ID, m.ContextWindow, m.MaxOutput, reason, source, m.DisplayName)
767 }
768}