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 "slices"
14 "strings"
15 "sync"
16 "syscall"
17 "time"
18
19 "github.com/bnema/zut/packages/agent/extensions"
20 "github.com/bnema/zut/packages/agent/extproto"
21 "github.com/bnema/zut/packages/agent/modes"
22 "github.com/bnema/zut/packages/agent/skills"
23 "github.com/bnema/zut/packages/agent/subagents"
24 "github.com/bnema/zut/packages/agent/tools"
25 "github.com/bnema/zut/packages/core"
26 "github.com/bnema/zut/packages/provider"
27 "github.com/bnema/zut/packages/provider/auth"
28 "github.com/bnema/zut/packages/tui"
29)
30
31// interactiveExtHooks is a tiny adapter that lets the extension
32// manager call back into the Interactive instance built later in
33// runInteractive. Alerts are buffered until the instance is attached so
34// an extension that signals attention during startup is not silently lost.
35// Keep the startup buffer bounded because extensions can flood their host.
36const maxBufferedInteractiveAlerts = 64
37
38type interactiveExtHooks struct {
39 mu sync.Mutex
40 interactive *modes.Interactive
41 pendingAlerts []interactiveAlert
42 pendingStatuses map[string]interactiveStatus
43 pendingWidgets map[string]interactiveWidget
44}
45
46type interactiveAlert struct {
47 extName string
48 alert extproto.AlertRequest
49}
50
51type interactiveStatus struct {
52 extName string
53 key string
54 level string
55 text string
56}
57
58type interactiveWidget struct {
59 extName string
60 id string
61 position string
62 title string
63 lines []string
64}
65
66func (h *interactiveExtHooks) iv() *modes.Interactive {
67 if h == nil {
68 return nil
69 }
70 h.mu.Lock()
71 defer h.mu.Unlock()
72 return h.interactive
73}
74
75func (h *interactiveExtHooks) attachInteractive(iv *modes.Interactive) {
76 if h == nil || iv == nil {
77 return
78 }
79 h.mu.Lock()
80 h.interactive = iv
81 pendingAlerts := h.pendingAlerts
82 pendingStatuses := h.pendingStatuses
83 pendingWidgets := h.pendingWidgets
84 h.pendingAlerts = nil
85 h.pendingStatuses = nil
86 h.pendingWidgets = nil
87 h.mu.Unlock()
88 for _, item := range pendingAlerts {
89 iv.Alert(item.extName, item.alert)
90 }
91 for _, item := range pendingStatuses {
92 iv.SetStatus(item.extName, item.key, item.level, item.text)
93 }
94 for _, item := range pendingWidgets {
95 iv.SetWidget(item.extName, item.id, item.position, item.title, item.lines)
96 }
97}
98
99func (h *interactiveExtHooks) Notify(extName, level, message string) {
100 if iv := h.iv(); iv != nil {
101 iv.Notify(extName, level, message)
102 }
103}
104func (h *interactiveExtHooks) Alert(extName string, alert extproto.AlertRequest) {
105 if h == nil {
106 return
107 }
108 h.mu.Lock()
109 if h.interactive == nil {
110 if len(h.pendingAlerts) < maxBufferedInteractiveAlerts {
111 h.pendingAlerts = append(h.pendingAlerts, interactiveAlert{extName: extName, alert: alert})
112 }
113 h.mu.Unlock()
114 return
115 }
116 iv := h.interactive
117 h.mu.Unlock()
118 iv.Alert(extName, alert)
119}
120func (h *interactiveExtHooks) Submit(text string) {
121 if iv := h.iv(); iv != nil {
122 iv.SubmitOrQueue(text, nil)
123 }
124}
125func (h *interactiveExtHooks) SubmitSlash(text string) {
126 if iv := h.iv(); iv != nil {
127 iv.SubmitSlash(text)
128 }
129}
130func (h *interactiveExtHooks) Insert(text string) {
131 if iv := h.iv(); iv != nil {
132 iv.Insert(text)
133 }
134}
135func (h *interactiveExtHooks) Display(extName, text string) {
136 if iv := h.iv(); iv != nil {
137 iv.Display(extName, text)
138 }
139}
140func (h *interactiveExtHooks) ClearNotes(extName string) {
141 if iv := h.iv(); iv != nil {
142 iv.ClearNotes(extName)
143 }
144}
145func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
146 if iv := h.iv(); iv != nil {
147 iv.OpenPanel(extName, spec)
148 }
149}
150func (h *interactiveExtHooks) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
151 if iv := h.iv(); iv != nil {
152 iv.UpdatePanel(extName, panelID, title, lines, footer)
153 }
154}
155func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
156 if iv := h.iv(); iv != nil {
157 iv.ClosePanel(extName, panelID)
158 }
159}
160func pendingChromeKey(extName, key string) string { return extName + "\x00" + key }
161
162func (h *interactiveExtHooks) SetStatus(extName, key, level, text string) {
163 if h == nil {
164 return
165 }
166 h.mu.Lock()
167 if h.interactive == nil {
168 if h.pendingStatuses == nil {
169 h.pendingStatuses = map[string]interactiveStatus{}
170 }
171 pendingKey := pendingChromeKey(extName, key)
172 if strings.TrimSpace(text) == "" {
173 delete(h.pendingStatuses, pendingKey)
174 } else {
175 h.pendingStatuses[pendingKey] = interactiveStatus{extName: extName, key: key, level: level, text: text}
176 }
177 h.mu.Unlock()
178 return
179 }
180 iv := h.interactive
181 h.mu.Unlock()
182 iv.SetStatus(extName, key, level, text)
183}
184func (h *interactiveExtHooks) SetWidget(extName, id, position, title string, lines []string) {
185 if h == nil {
186 return
187 }
188 h.mu.Lock()
189 if h.interactive == nil {
190 if h.pendingWidgets == nil {
191 h.pendingWidgets = map[string]interactiveWidget{}
192 }
193 h.pendingWidgets[pendingChromeKey(extName, id)] = interactiveWidget{
194 extName: extName, id: id, position: position, title: title, lines: append([]string(nil), lines...),
195 }
196 h.mu.Unlock()
197 return
198 }
199 iv := h.interactive
200 h.mu.Unlock()
201 iv.SetWidget(extName, id, position, title, lines)
202}
203func (h *interactiveExtHooks) ClearWidget(extName, id string) {
204 if h == nil {
205 return
206 }
207 h.mu.Lock()
208 if h.interactive == nil {
209 if h.pendingWidgets != nil {
210 delete(h.pendingWidgets, pendingChromeKey(extName, id))
211 }
212 h.mu.Unlock()
213 return
214 }
215 iv := h.interactive
216 h.mu.Unlock()
217 iv.ClearWidget(extName, id)
218}
219
220func (h *interactiveExtHooks) ClearExtensionChrome(extName string) {
221 if h == nil {
222 return
223 }
224 h.mu.Lock()
225 if h.interactive == nil {
226 prefix := extName + "\x00"
227 for key := range h.pendingStatuses {
228 if strings.HasPrefix(key, prefix) {
229 delete(h.pendingStatuses, key)
230 }
231 }
232 for key := range h.pendingWidgets {
233 if strings.HasPrefix(key, prefix) {
234 delete(h.pendingWidgets, key)
235 }
236 }
237 h.mu.Unlock()
238 return
239 }
240 iv := h.interactive
241 h.mu.Unlock()
242 iv.ClearExtensionChrome(extName)
243}
244
245// extToolAdapter bridges *extensions.Manager to the
246// ExtensionToolSource interface declared in build.go (kept narrow to
247// avoid a build->extensions import cycle). One adapter instance per
248// run; used at every Resolve point so re-built agents pick up the
249// same set of extension tools.
250type extToolAdapter struct {
251 mgr *extensions.Manager
252}
253
254var _ ExtensionToolSource = (*extToolAdapter)(nil)
255var _ ExtensionSkillSource = (*extToolAdapter)(nil)
256
257func (a *extToolAdapter) Skills() []*skills.Skill {
258 if a == nil || a.mgr == nil {
259 return nil
260 }
261 return a.mgr.Skills()
262}
263
264func (a *extToolAdapter) Tools() []ExtensionToolInfo {
265 infos := a.mgr.Tools()
266 out := make([]ExtensionToolInfo, len(infos))
267 for i, t := range infos {
268 out[i] = ExtensionToolInfo{
269 Extension: t.Extension,
270 Name: t.Name,
271 Description: t.Description,
272 Schema: t.Schema,
273 Deferred: t.Deferred,
274 }
275 }
276 return out
277}
278
279func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
280 return extensions.NewTool(a.mgr, extensions.ToolInfo{
281 Extension: info.Extension,
282 Name: info.Name,
283 Description: info.Description,
284 Schema: info.Schema,
285 Deferred: info.Deferred,
286 })
287}
288
289// liveInteractiveAgent returns the agent currently owned by the TUI. The
290// fallback is only used before the Interactive has been constructed.
291//
292// Agent rebuilds, such as cross-provider model switches, replace the TUI's
293// agent without replacing the startup pointer held by runInteractive. Session
294// operations must therefore resolve the live agent at the time of the action.
295func liveInteractiveAgent(iv *modes.Interactive, fallback *core.Agent) *core.Agent {
296 if iv != nil {
297 return iv.Agent()
298 }
299 return fallback
300}
301
302func newSessionTransition(mu *sync.RWMutex) func(func()) {
303 return func(fn func()) {
304 mu.Lock()
305 defer mu.Unlock()
306 fn()
307 }
308}
309
310// newPersistModelCallback returns the callback used by the TUI while its
311// session transition write lock is held. It must not acquire that lock again
312// because sync.RWMutex is not reentrant.
313func newPersistModelCallback(persistMu *sync.Mutex, sess **core.Session, activeProvider, activeModel *string, notify func(string)) func(string, string) {
314 return func(providerName, model string) {
315 providerName = strings.TrimSpace(providerName)
316 model = strings.TrimSpace(model)
317
318 persistMu.Lock()
319 var sessionErr error
320 if *sess != nil {
321 sessionErr = (*sess).UpdateModel(providerName, model)
322 }
323 *activeProvider = providerName
324 *activeModel = model
325 persistMu.Unlock()
326 if sessionErr != nil {
327 if notify != nil {
328 notify("model change was not persisted: " + sessionErr.Error())
329 }
330 return
331 }
332
333 cfg, err := LoadConfig()
334 if err != nil {
335 if notify != nil {
336 notify("model changed for this run, but config could not be read: " + err.Error())
337 }
338 return
339 }
340 cfg.Provider = providerName
341 cfg.Model = model
342 if err := SaveConfig(cfg); err != nil && notify != nil {
343 notify("model changed for this run, but config could not be saved: " + err.Error())
344 }
345 }
346}
347
348func closeAgentLSP(ag *core.Agent) {
349 if ag != nil {
350 _ = tools.CloseLSPManagers(ag.ToolsSnapshot())
351 }
352}
353
354func joinSessionCloseError(err error, sess *core.Session) error {
355 if sess == nil {
356 return err
357 }
358 if closeErr := sess.Close(); closeErr != nil {
359 return errors.Join(err, fmt.Errorf("close session: %w", closeErr))
360 }
361 return err
362}
363
364func closeResolvedLSP(r Resolved) {
365 _ = tools.CloseLSPManagers(r.ToolRegistry)
366}
367
368func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
369 if keepTail <= 0 || len(msgs) <= keepTail {
370 return provider.RepairOrphanedToolResults(msgs)
371 }
372 var out []provider.Message
373 start := len(msgs) - keepTail
374 var carriedToolNames []string
375 addToolNames := func(names []string) {
376 for _, name := range names {
377 if name == "" || slices.Contains(carriedToolNames, name) {
378 continue
379 }
380 carriedToolNames = append(carriedToolNames, name)
381 }
382 }
383 // Activation markers in the discarded prefix still affect the retained
384 // provider request. Carry them onto its first message rather than dropping
385 // deferred-tool availability when the resume tail is shortened.
386 for _, msg := range msgs[:start] {
387 addToolNames(msg.AddedToolNames)
388 }
389 // Preserve the synthetic compaction summary when present so an
390 // already-compacted session stays compacted after resume. Count it
391 // within the tail budget instead of dropping it at the exact boundary.
392 if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 0 {
393 out = append(out, msgs[0])
394 start++
395 }
396 // Avoid hydrating a tail that starts with orphan tool_result rows;
397 // provider APIs require those to be paired with an earlier tool_use.
398 for start < len(msgs) && msgs[start].Role == provider.RoleTool {
399 start++
400 }
401 out = append(out, msgs[start:]...)
402 if len(carriedToolNames) > 0 && len(out) > 0 {
403 first := out[0]
404 for _, name := range first.AddedToolNames {
405 addToolNames([]string{name})
406 }
407 first.AddedToolNames = append([]string(nil), carriedToolNames...)
408 out[0] = first
409 }
410 return provider.RepairOrphanedToolResults(out)
411}
412
413// sessionResumeCandidate contains all state needed to resume a session.
414// It is deliberately assembled before the caller changes any live state:
415// opening the transcript, reading usage, and rebuilding a provider/model
416// must all succeed before the candidate can replace the current session.
417type sessionResumeCandidate struct {
418 session *core.Session
419 agent *core.Agent
420 messages []provider.Message
421 fullMessageCount int
422 cumulative provider.Usage
423 lastTurn provider.Usage
424 provider string
425 model string
426 rebuilt bool
427}
428
429// prepareSessionResume opens and validates a selected session without
430// touching the current agent. Missing provider/model metadata is treated as
431// legacy metadata: the current agent is reused, preserving compatibility
432// with sessions written before those fields were persisted.
433//
434// The returned candidate owns session until its caller commits it. On any
435// error this function closes the candidate session and leaves the current
436// agent untouched.
437func prepareSessionResume(path string, current *core.Agent, currentProvider, currentModel string, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) {
438 return prepareSessionResumeWithOptions(path, current, currentProvider, currentModel, false, false, buildAgentFor)
439}
440
441// prepareSessionResumeWithOptions is the provider/model-aware implementation.
442// An explicit CLI provider or model keeps that field authoritative; an empty
443// field falls back to the selected session's metadata when it is complete.
444func prepareSessionResumeWithOptions(path string, current *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (candidate sessionResumeCandidate, err error) {
445 if current == nil {
446 return candidate, fmt.Errorf("no agent running; log in first")
447 }
448
449 sess, msgs, err := core.OpenSession(path)
450 if err != nil {
451 return candidate, err
452 }
453 keepSession := false
454 defer func() {
455 if !keepSession {
456 err = joinSessionCloseError(err, sess)
457 }
458 }()
459
460 cumulative, lastTurn, err := core.SessionUsageDetail(path)
461 if err != nil {
462 return candidate, fmt.Errorf("read session usage: %w", err)
463 }
464
465 fullMessageCount := len(msgs)
466 resumedMessages := trimMessagesForResume(msgs, 100)
467 storedProvider := strings.TrimSpace(sess.Meta.Provider)
468 storedModel := strings.TrimSpace(sess.Meta.Model)
469 currentProvider = strings.TrimSpace(currentProvider)
470 currentModel = strings.TrimSpace(currentModel)
471
472 candidate = sessionResumeCandidate{
473 session: sess,
474 agent: current,
475 messages: resumedMessages,
476 fullMessageCount: fullMessageCount,
477 cumulative: cumulative,
478 lastTurn: lastTurn,
479 provider: currentProvider,
480 model: currentModel,
481 }
482
483 // Old sessions may have either field absent. Only a complete stored
484 // selection is actionable; otherwise resume exactly as older zut did.
485 if storedProvider == "" || storedModel == "" {
486 keepSession = true
487 return candidate, nil
488 }
489
490 resumeProvider := storedProvider
491 if explicitProvider {
492 resumeProvider = currentProvider
493 }
494 resumeModel := storedModel
495 if explicitModel {
496 resumeModel = currentModel
497 }
498 if resumeProvider == currentProvider && resumeModel == currentModel {
499 keepSession = true
500 return candidate, nil
501 }
502 if buildAgentFor == nil {
503 return sessionResumeCandidate{}, fmt.Errorf("cannot resume session with provider %q and model %q: no agent builder configured", resumeProvider, resumeModel)
504 }
505
506 rebuilt, resolvedProvider, resolvedModel, err := buildAgentFor(resumeProvider, resumeModel)
507 if err != nil {
508 return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q: %w", resumeProvider, resumeModel, err)
509 }
510 if rebuilt == nil {
511 return sessionResumeCandidate{}, fmt.Errorf("rebuild agent for session provider %q/model %q returned no agent", resumeProvider, resumeModel)
512 }
513 resolvedProvider = strings.TrimSpace(resolvedProvider)
514 resolvedModel = strings.TrimSpace(resolvedModel)
515 if resolvedProvider == "" {
516 resolvedProvider = storedProvider
517 }
518 if resolvedModel == "" {
519 resolvedModel = storedModel
520 }
521
522 // Hydrate and seed the replacement while it is still private. The live
523 // agent is not modified until the caller commits the complete candidate.
524 rebuilt.SetMessages(resumedMessages)
525 rebuilt.SeedCost(cumulative)
526 rebuilt.SeedLastTurnUsage(lastTurn)
527 candidate.agent = rebuilt
528 candidate.provider = resolvedProvider
529 candidate.model = resolvedModel
530 candidate.rebuilt = true
531 keepSession = true
532 return candidate, nil
533}
534
535// buildNonInteractiveSessionAgent constructs a provider/model-specific agent
536// for startup resume while preserving the cwd sandbox and extension tools that
537// were attached to the initial resolution.
538func buildNonInteractiveSessionAgent(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, providerName, model string) (*core.Agent, string, string, error) {
539 next := args
540 next.Provider = providerName
541 next.Model = model
542 resolved, err := Resolve(next, true)
543 if err != nil {
544 return nil, "", "", err
545 }
546 resolved.UseSandbox(base.Sandbox)
547 if extMgr != nil {
548 resolved.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
549 }
550 ag := resolved.NewAgent()
551 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
552 return ag, resolved.Provider, resolved.Model, nil
553}
554
555// applySessionResume shares the startup gate, candidate hydration, and
556// commit order used by interactive and non-interactive session opening. The
557// old append handle is closed only after the candidate has been prepared and
558// seeded successfully.
559func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, currentModel string, explicitProvider, explicitModel bool, buildAgentFor func(string, string) (*core.Agent, string, string, error)) (sessionResumeCandidate, error) {
560 candidate := sessionResumeCandidate{
561 session: sess,
562 agent: ag,
563 fullMessageCount: 0,
564 provider: currentProvider,
565 model: currentModel,
566 }
567 if ag != nil {
568 candidate.fullMessageCount = len(ag.Messages())
569 }
570 if sess == nil || ag == nil {
571 return candidate, nil
572 }
573 // A newly created meta-only session is already on the active pair. Avoid
574 // reopening and closing it through the resume path: Close intentionally
575 // removes fresh empty sessions, and a second append handle would otherwise
576 // keep an unlinked file alive.
577 if len(ag.Messages()) == 0 &&
578 strings.TrimSpace(sess.Meta.Provider) == strings.TrimSpace(currentProvider) &&
579 strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) {
580 return candidate, nil
581 }
582 candidate, err := prepareSessionResumeWithOptions(sess.Path, ag, currentProvider, currentModel, explicitProvider, explicitModel, buildAgentFor)
583 if err != nil {
584 return sessionResumeCandidate{}, joinSessionCloseError(err, sess)
585 }
586 if !candidate.rebuilt {
587 candidate.agent.SetMessages(candidate.messages)
588 candidate.agent.SeedCost(candidate.cumulative)
589 candidate.agent.SeedLastTurnUsage(candidate.lastTurn)
590 }
591 if closeErr := sess.Close(); closeErr != nil {
592 return sessionResumeCandidate{}, errors.Join(fmt.Errorf("close session: %w", closeErr), joinSessionCloseError(nil, candidate.session))
593 }
594 return candidate, nil
595}
596
597// applyInitialSessionResume applies the same provider/model-aware candidate
598// contract used by interactive session switching to non-interactive startup
599// modes. The returned session owns the replacement append handle.
600func applyInitialSessionResume(ctx context.Context, args Args, base Resolved, extMgr *extensions.Manager, sess *core.Session, ag *core.Agent) (*core.Session, *core.Agent, string, string, error) {
601 candidate, err := applySessionResume(sess, ag, base.Provider, base.Model,
602 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", func(providerName, model string) (*core.Agent, string, string, error) {
603 return buildNonInteractiveSessionAgent(ctx, args, base, extMgr, providerName, model)
604 })
605 if err != nil {
606 return nil, ag, "", "", err
607 }
608 return candidate.session, candidate.agent, candidate.provider, candidate.model, nil
609}
610
611// fanoutAgentEvent translates a core.AgentEvent into the wire-format
612// EventFromHost and pushes it through the extension manager. Only
613// the events that have a clear extension-facing meaning are
614// forwarded; internal-only ones (text_delta, tool_progress) are
615// dropped to keep the per-extension stream sane.
616func sessionContext(sess *core.Session) *extproto.SessionContext {
617 if sess == nil {
618 return nil
619 }
620 return &extproto.SessionContext{
621 ID: sess.Meta.ID,
622 ParentID: sess.Meta.Parent,
623 Path: sess.Path,
624 CWD: sess.Meta.CWD,
625 ForkPoint: sess.Meta.ForkPoint,
626 }
627}
628
629func announceSession(mgr *extensions.Manager, sess *core.Session) {
630 if mgr == nil {
631 return
632 }
633 var states map[string]json.RawMessage
634 if sess != nil {
635 states = copyExtensionStates(sess.ExtensionState)
636 }
637 mgr.EmitSessionEvent("session_start", sessionContext(sess), states)
638 if sess != nil {
639 mgr.EmitSessionEvent("session_opened", sessionContext(sess), states)
640 }
641}
642
643func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawMessage {
644 if len(states) == 0 {
645 return nil
646 }
647 out := make(map[string]json.RawMessage, len(states))
648 for name, state := range states {
649 out[name] = append(json.RawMessage(nil), state...)
650 }
651 return out
652}
653
654func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) {
655 if sess == nil {
656 return
657 }
658 details, ok := result.Details.(extensions.ToolResultDetails)
659 if !ok || details.Extension == "" || len(details.State) == 0 {
660 return
661 }
662 if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {
663 fmt.Fprintf(os.Stderr, "extension state: %v\n", err)
664 return
665 }
666 if mgr != nil {
667 mgr.UpdateSessionState(details.Extension, details.State)
668 }
669}
670
671func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
672 if mgr == nil {
673 return
674 }
675 switch e := ev.(type) {
676 case core.EvTurnStart:
677 mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
678 case core.EvToolCall:
679 mgr.EmitEvent(extproto.EventFromHost{
680 Event: "tool_call", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args,
681 })
682 case core.EvAssistantMessage:
683 // Concat the visible text portions of the message; binary
684 // blocks (tool_use, etc.) are skipped because subscribers
685 // usually want a string they can grep / display.
686 var text string
687 for _, c := range e.Message.Content {
688 if tb, ok := c.(provider.TextBlock); ok {
689 text += tb.Text
690 }
691 }
692 mgr.EmitEvent(extproto.EventFromHost{Event: "assistant_message", Text: text})
693 case core.EvTurnEnd:
694 ev := extproto.EventFromHost{Event: "turn_end", Stop: string(e.Stop)}
695 if e.Err != nil {
696 ev.Error = e.Err.Error()
697 }
698 mgr.EmitEvent(ev)
699 }
700}
701
702// Run is the top-level entrypoint for the zut binary.
703func Run(rawArgs []string, version string) error {
704 // Extension installs can invoke external build and clone processes.
705 // Give those processes a cancellation context without changing signal
706 // handling for the interactive modes below.
707 extCtx := context.Background()
708 stopExtContext := func() {}
709 if len(rawArgs) > 0 && rawArgs[0] == "ext" {
710 extCtx, stopExtContext = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
711 }
712 defer stopExtContext()
713
714 // Subcommand router: `zut bot ...` is handled separately so the
715 // generic flag parser doesn't reject "bot" as a positional arg.
716 if handled, err := runBotCommand(rawArgs, version); handled {
717 return err
718 }
719 if handled, err := runExtCommand(extCtx, rawArgs, version); handled {
720 return err
721 }
722 if handled, err := runUpdateCommand(rawArgs, version); handled {
723 return err
724 }
725 if handled, err := runZutfileCommand(rawArgs, version); handled {
726 return err
727 }
728 return runWithArgsRaw(rawArgs, version)
729}
730
731func runWithArgsRaw(rawArgs []string, version string) error {
732 // `zut rpc` is shorthand for `zut --rpc` so third-party apps can
733 // spawn the binary with a clean argv. Strip the leading 'rpc'
734 // token and let the rest flow through the normal arg parser.
735 if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
736 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
737 }
738
739 args, err := ParseArgs(rawArgs)
740 if err != nil {
741 PrintHelp(version)
742 return err
743 }
744 if args.Help {
745 PrintHelp(version)
746 return nil
747 }
748 if args.Version {
749 fmt.Println("zut", version)
750 return nil
751 }
752 // Model catalog: load any cached discovery data before we inspect
753 // the model list (list-models, print/json, interactive).
754 prepareRuntimeCatalog()
755
756 if args.ListModels {
757 printModels()
758 return nil
759 }
760
761 return runWithArgs(args, version)
762}
763
764func runWithArgs(args Args, version string) error {
765 ctx := context.Background()
766 switch args.Mode {
767 case ModePrint:
768 return runPrintMode(ctx, args, version)
769 case ModeStream:
770 return runStreamMode(ctx, args, version)
771 case ModeJSON:
772 return runJSONMode(ctx, args, version)
773 case ModeRPC:
774 return runRPCMode(ctx, args, version)
775 case ModeSubagentWorker:
776 return runSubagentWorkerMode(ctx, args, version)
777 default:
778 return runInteractive(ctx, args, version)
779 }
780}
781
782// ---- print / json modes: require credentials, run single-shot ----
783
784// nonInteractiveExtHooks is the HostHooks impl used by print / json
785// modes. They have no TUI, so notify / display go to stderr and
786// submit / insert are no-ops (the extension can't steer a
787// single-shot run once it's in flight anyway).
788type nonInteractiveExtHooks struct{}
789
790func (nonInteractiveExtHooks) Notify(ext, level, message string) {
791 fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
792}
793func (nonInteractiveExtHooks) Alert(string, extproto.AlertRequest) {}
794func (nonInteractiveExtHooks) Submit(string) {}
795func (nonInteractiveExtHooks) SubmitSlash(string) {}
796func (nonInteractiveExtHooks) Insert(string) {}
797func (nonInteractiveExtHooks) Display(string, string) {}
798func (nonInteractiveExtHooks) ClearNotes(string) {}
799func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
800func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
801func (nonInteractiveExtHooks) ClosePanel(string, string) {}
802func (nonInteractiveExtHooks) SetStatus(string, string, string, string) {}
803func (nonInteractiveExtHooks) SetWidget(string, string, string, string, []string) {}
804func (nonInteractiveExtHooks) ClearWidget(string, string) {}
805
806// setupNonInteractiveExtensions loads --ext paths and (unless
807// --no-ext) runs discovery. Returns the manager so the caller can
808// wire tools into the resolved registry, and a cleanup closure to
809// defer. Mirrors the interactive-mode setup minus the TUI hooks.
810func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) {
811 extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{})
812 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
813 fmt.Fprintln(os.Stderr, "extension load:", e)
814 }
815 if !args.NoExt {
816 for _, e := range extMgr.Discover(ctx) {
817 fmt.Fprintln(os.Stderr, "extension load:", e)
818 }
819 }
820 extMgr.WaitForReady(3 * time.Second)
821 r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
822 return extMgr, func() { extMgr.Stop(2 * time.Second) }
823}
824
825// wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
826// / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
827// interactive path wires up, so extensions get their normal
828// event-intercept surface in print / json / rpc flows too.
829func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) {
830 if ag == nil || extMgr == nil {
831 return
832 }
833 ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
834 res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
835 if res.Block {
836 return false, res.Reason, nil
837 }
838 return true, "", res.ModifiedArgs
839 }
840 ag.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
841 res := extMgr.InterceptTurnStart(turnCtx, step)
842 return !res.Block, res.Reason, res.Context
843 }
844 ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
845 res := extMgr.InterceptAssistantMessage(ctx, text)
846 if res.Block {
847 return false, res.Reason, ""
848 }
849 return true, "", res.ReplaceText
850 }
851 ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
852}
853
854type printStats struct {
855 Provider string `json:"provider"`
856 Model string `json:"model"`
857 PromptTokens int `json:"prompt_tokens"`
858 ReasoningTokens *int `json:"reasoning_tokens"`
859 GeneratedOutputTokens int `json:"generated_output_tokens"`
860 ElapsedMS int64 `json:"elapsed_ms"`
861}
862
863func writePrintStats(path, providerID, model string, usage provider.Usage, elapsed time.Duration) error {
864 var reasoning *int
865 generated := usage.OutputTokens
866 if usage.ReasoningTokensKnown {
867 count := usage.ReasoningTokens
868 reasoning = &count
869 generated -= count
870 if generated < 0 {
871 generated = 0
872 }
873 }
874 stats := printStats{
875 Provider: providerID,
876 Model: model,
877 PromptTokens: usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens,
878 ReasoningTokens: reasoning,
879 GeneratedOutputTokens: generated,
880 ElapsedMS: elapsed.Milliseconds(),
881 }
882 data, err := json.MarshalIndent(stats, "", " ")
883 if err != nil {
884 return fmt.Errorf("encode stats: %w", err)
885 }
886 data = append(data, '\n')
887 if err := os.WriteFile(path, data, 0o644); err != nil {
888 return fmt.Errorf("write stats %q: %w", path, err)
889 }
890 return nil
891}
892
893func runPrintMode(ctx context.Context, args Args, version string) (runErr error) {
894 if args.NoYolo {
895 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
896 }
897 r, err := Resolve(args, true)
898 if err != nil {
899 return err
900 }
901 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
902 defer stopExt()
903
904 ag := r.NewAgent()
905 initialAg := ag
906 defer func() {
907 closeAgentLSP(ag)
908 if ag != initialAg {
909 closeAgentLSP(initialAg)
910 }
911 }()
912 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
913 sess, err := openOrCreateSession(ctx, args, r, ag, version)
914 if err != nil {
915 return err
916 }
917 if sess != nil {
918 var providerName, model string
919 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
920 if err != nil {
921 return err
922 }
923 r.Provider, r.Model = providerName, model
924 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
925 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
926 }
927 announceSession(extMgr, sess)
928
929 prompt := args.Prompt
930 if prompt == "" {
931 piped, _ := readAllStdin()
932 prompt = strings.TrimSpace(piped)
933 }
934 if prompt == "" {
935 return fmt.Errorf("print mode requires a prompt (arg or stdin)")
936 }
937
938 start := len(ag.Messages())
939 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {
940 return err
941 }
942 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
943 return err
944 }
945 var persistCompaction func([]provider.Message) error
946 if sess != nil {
947 persistCompaction = sess.AppendCompaction
948 }
949 started := time.Now()
950 usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
951 elapsed := time.Since(started)
952 transcriptStart := start
953 if recovery.Compacted {
954 transcriptStart = recovery.OutputStart
955 }
956 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
957 if err != nil {
958 return errors.Join(err, persistErr)
959 }
960 return persistErr
961 }
962 if err != nil {
963 return err
964 }
965 if args.StatsPath != "" {
966 return writePrintStats(args.StatsPath, r.Provider, r.Model, usage, elapsed)
967 }
968 return nil
969}
970
971func runStreamMode(ctx context.Context, args Args, version string) (runErr error) {
972 if args.NoYolo {
973 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation")
974 }
975 r, err := Resolve(args, true)
976 if err != nil {
977 return err
978 }
979 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
980 defer stopExt()
981
982 ag := r.NewAgent()
983 initialAg := ag
984 defer func() {
985 closeAgentLSP(ag)
986 if ag != initialAg {
987 closeAgentLSP(initialAg)
988 }
989 }()
990 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
991 sess, err := openOrCreateSession(ctx, args, r, ag, version)
992 if err != nil {
993 return err
994 }
995 if sess != nil {
996 var providerName, model string
997 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
998 if err != nil {
999 return err
1000 }
1001 r.Provider, r.Model = providerName, model
1002 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
1003 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1004 }
1005 announceSession(extMgr, sess)
1006
1007 prompt := args.Prompt
1008 if prompt == "" {
1009 piped, _ := readAllStdin()
1010 prompt = strings.TrimSpace(piped)
1011 }
1012 if prompt == "" {
1013 return fmt.Errorf("stream mode requires a prompt (arg or stdin)")
1014 }
1015
1016 start := len(ag.Messages())
1017 preSink, finishPre := newStreamTextSink(os.Stdout)
1018 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
1019 finishPre()
1020 return err
1021 }
1022 finishPre()
1023 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1024 return err
1025 }
1026 var persistCompaction func([]provider.Message) error
1027 if sess != nil {
1028 persistCompaction = sess.AppendCompaction
1029 }
1030 recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction)
1031 transcriptStart := start
1032 if recovery.Compacted {
1033 transcriptStart = recovery.OutputStart
1034 }
1035 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1036 if err != nil {
1037 return errors.Join(err, persistErr)
1038 }
1039 return persistErr
1040 }
1041 return err
1042}
1043
1044func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
1045 var streamed, wroteText, lastWasNL bool
1046 writeText := func(text string) {
1047 if text == "" {
1048 return
1049 }
1050 _, _ = fmt.Fprint(out, text)
1051 if syncer, ok := out.(interface{ Sync() error }); ok {
1052 _ = syncer.Sync()
1053 }
1054 wroteText = true
1055 lastWasNL = strings.HasSuffix(text, "\n")
1056 }
1057 sink := func(ev core.AgentEvent) {
1058 switch e := ev.(type) {
1059 case core.EvAssistantStart:
1060 streamed = false
1061 case core.EvTextDelta:
1062 streamed = true
1063 writeText(e.Delta)
1064 case core.EvAssistantMessage:
1065 if streamed {
1066 return
1067 }
1068 var text strings.Builder
1069 for _, content := range e.Message.Content {
1070 if block, ok := content.(provider.TextBlock); ok {
1071 if text.Len() > 0 {
1072 text.WriteString("\n")
1073 }
1074 text.WriteString(block.Text)
1075 }
1076 }
1077 writeText(text.String())
1078 }
1079 }
1080 finish := func() {
1081 if wroteText && !lastWasNL {
1082 writeText("\n")
1083 }
1084 }
1085 return sink, finish
1086}
1087
1088func runJSONMode(ctx context.Context, args Args, version string) (runErr error) {
1089 if args.NoYolo {
1090 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
1091 }
1092 r, err := Resolve(args, true)
1093 if err != nil {
1094 return err
1095 }
1096 extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
1097 defer stopExt()
1098
1099 ag := r.NewAgent()
1100 initialAg := ag
1101 defer func() {
1102 closeAgentLSP(ag)
1103 if ag != initialAg {
1104 closeAgentLSP(initialAg)
1105 }
1106 }()
1107 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1108 sess, err := openOrCreateSession(ctx, args, r, ag, version)
1109 if err != nil {
1110 return err
1111 }
1112 if sess != nil {
1113 var providerName, model string
1114 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
1115 if err != nil {
1116 return err
1117 }
1118 r.Provider, r.Model = providerName, model
1119 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
1120 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1121 }
1122 announceSession(extMgr, sess)
1123
1124 prompt := args.Prompt
1125 if prompt == "" {
1126 piped, _ := readAllStdin()
1127 prompt = strings.TrimSpace(piped)
1128 }
1129 if prompt == "" {
1130 return fmt.Errorf("json mode requires a prompt (arg or stdin)")
1131 }
1132
1133 start := len(ag.Messages())
1134 enc := json.NewEncoder(os.Stdout)
1135 preSink := func(ev core.AgentEvent) {
1136 _ = enc.Encode(modes.EventToJSON(ev))
1137 }
1138 if err := runZutfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
1139 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1140 return err
1141 }
1142 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1143 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1144 return err
1145 }
1146 var persistCompaction func([]provider.Message) error
1147 if sess != nil {
1148 persistCompaction = sess.AppendCompaction
1149 }
1150 recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
1151 transcriptStart := start
1152 if recovery.Compacted {
1153 transcriptStart = recovery.OutputStart
1154 }
1155 if persistErr := WriteNewTranscript(ag, sess, transcriptStart); persistErr != nil {
1156 finalErr := persistErr
1157 if err != nil {
1158 finalErr = errors.Join(err, persistErr)
1159 }
1160 _ = enc.Encode(map[string]any{"type": "error", "message": finalErr.Error()})
1161 return finalErr
1162 }
1163 return err
1164}
1165
1166// runZutfileStartupPre runs entry.pre before the main non-interactive prompt.
1167// "!command" values execute via BashTool; other values are sent as an agent turn.
1168// shellOut receives live shell chunks (typically os.Stderr); sink receives
1169// agent events for plain-text pre (stream mode wires a live text sink).
1170func runZutfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error {
1171 pre = strings.TrimSpace(pre)
1172 if pre == "" {
1173 return nil
1174 }
1175 if cmd, ok := modes.ShellEscapeCommand(pre); ok {
1176 raw, err := json.Marshal(map[string]any{"command": cmd})
1177 if err != nil {
1178 return err
1179 }
1180 if shellOut != nil {
1181 fmt.Fprintf(shellOut, "$ %s\n", cmd)
1182 if f, ok := shellOut.(interface{ Sync() error }); ok {
1183 _ = f.Sync()
1184 }
1185 }
1186 progress := func(chunk string) {
1187 if shellOut == nil || chunk == "" {
1188 return
1189 }
1190 fmt.Fprint(shellOut, chunk)
1191 if f, ok := shellOut.(interface{ Sync() error }); ok {
1192 _ = f.Sync()
1193 }
1194 }
1195 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
1196 res, err := bash.Execute(ctx, raw, progress)
1197 if err != nil {
1198 return fmt.Errorf("zutfile entry.pre: %w", err)
1199 }
1200 if res.IsError {
1201 var sb strings.Builder
1202 for _, c := range res.Content {
1203 if tb, ok := c.(provider.TextBlock); ok {
1204 sb.WriteString(tb.Text)
1205 }
1206 }
1207 msg := strings.TrimSpace(sb.String())
1208 if msg == "" {
1209 msg = "command failed"
1210 }
1211 return fmt.Errorf("zutfile entry.pre: %s", msg)
1212 }
1213 return nil
1214 }
1215 if ag == nil {
1216 return fmt.Errorf("zutfile entry.pre requires an agent for non-shell prompts")
1217 }
1218 if sink == nil {
1219 sink = func(core.AgentEvent) {}
1220 }
1221 return ag.Prompt(ctx, pre, nil, sink)
1222}
1223
1224var errInteractiveAgentChanged = errors.New("interactive agent or web-search policy changed during prompt refresh")
1225
1226// refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
1227// skills and currently loaded extension tools) and updates the live
1228// agent's registry and system prompt. Used after /reload-ext and after
1229// zutfile entry.pre installs new skills or extensions.
1230// mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn).
1231// interactive, when non-nil, serializes the final commit with agent replacement.
1232func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry, interactive *modes.Interactive) (subagents.WebSearchPolicy, error) {
1233 if ag == nil {
1234 return subagents.WebSearchDeny, nil
1235 }
1236 var webSearchPolicyGeneration uint64
1237 if interactive != nil {
1238 // Capture before Resolve: the final commit must fail if settings,
1239 // Telegram attachment, or a newer registry commit changes policy while
1240 // filesystem/config/extension discovery is in flight.
1241 webSearchPolicyGeneration = interactive.WebSearchPolicyGeneration()
1242 }
1243 resolved, err := Resolve(args, true)
1244 if err != nil {
1245 return subagents.WebSearchDeny, fmt.Errorf("refresh agent prompt and tools: %w", err)
1246 }
1247 if sharedSandbox != nil {
1248 resolved.UseSandbox(sharedSandbox)
1249 }
1250 if extToolAdapter != nil {
1251 resolved.MergeExtensionTools(extToolAdapter)
1252 }
1253 reg := resolved.ToolRegistry
1254 if resolved.WebSearchPolicy != subagents.WebSearchAllow {
1255 delete(reg, "web_search")
1256 }
1257 if mutateRegistry != nil {
1258 reg = mutateRegistry(reg)
1259 }
1260 if interactive != nil {
1261 oldTools, applied := interactive.ApplyAgentPromptConfigAtWebSearchGeneration(ag, resolved.SystemPrompt, reg, webSearchPolicyGeneration)
1262 if !applied {
1263 _ = tools.CloseLSPManagers(reg)
1264 return subagents.WebSearchDeny, errInteractiveAgentChanged
1265 }
1266 interactive.DeferUntilIdle(func() {
1267 _ = tools.CloseLSPManagers(oldTools)
1268 })
1269 return webSearchPolicyForRegistry(resolved.WebSearchPolicy, ag.ToolsSnapshot()), nil
1270 }
1271 oldTools := ag.SetPromptConfig(resolved.SystemPrompt, reg)
1272 _ = tools.CloseLSPManagers(oldTools)
1273 return webSearchPolicyForRegistry(resolved.WebSearchPolicy, reg), nil
1274}
1275
1276func webSearchPolicyForRegistry(policy subagents.WebSearchPolicy, reg core.Registry) subagents.WebSearchPolicy {
1277 if policy == subagents.WebSearchAllow {
1278 if _, ok := reg["web_search"]; ok {
1279 return subagents.WebSearchAllow
1280 }
1281 }
1282 return subagents.WebSearchDeny
1283}
1284
1285func webSearchToolAllowedForSession(args Args) bool {
1286 if args.NoTools || args.PermissionSet != nil {
1287 return false
1288 }
1289 if args.ToolsSet || len(args.Tools) > 0 {
1290 return toolListContains(args.Tools, "web_search")
1291 }
1292 return true
1293}
1294
1295func webSearchExplicitlyEnabledForSession(args Args) bool {
1296 return !args.NoTools && args.PermissionSet == nil && (args.ToolsSet || len(args.Tools) > 0) && toolListContains(args.Tools, "web_search")
1297}
1298
1299// reloadResourcesAfterStartupPre reloads extensions (if any) and
1300// refreshes the agent tool registry + system prompt so skills/extensions
1301// installed by entry.pre are visible to the following turn.
1302func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) error {
1303 if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
1304 return nil
1305 }
1306 adapter := &extToolAdapter{mgr: extMgr}
1307 if extMgr != nil {
1308 _ = extMgr.Reload(ctx, 2*time.Second)
1309 }
1310 _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil, nil)
1311 return err
1312}
1313
1314// ---- interactive mode: opens the TUI even without credentials ----
1315
1316func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
1317 parseDuration := func(value string) time.Duration {
1318 if strings.TrimSpace(value) == "" {
1319 return 0
1320 }
1321 parsed, err := time.ParseDuration(strings.TrimSpace(value))
1322 if err != nil || parsed <= 0 {
1323 return 0
1324 }
1325 return parsed
1326 }
1327 return subagents.SubagentPolicy{
1328 MaxConcurrent: cfg.MaxConcurrent,
1329 MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
1330 QueueTimeout: parseDuration(cfg.QueueTimeout),
1331 DefaultTimeout: parseDuration(cfg.DefaultTimeout),
1332 MaxTurns: cfg.MaxTurns,
1333 MaxOutputBytes: cfg.MaxOutputBytes,
1334 MaxOutputLines: cfg.MaxOutputLines,
1335 AllowedTools: append([]string(nil), cfg.AllowedTools...),
1336 AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
1337 HeartbeatInterval: parseDuration(cfg.HeartbeatInterval),
1338 IdleTimeout: parseDuration(cfg.IdleTimeout),
1339 ReconnectTimeout: parseDuration(cfg.ReconnectTimeout),
1340 CancelGracePeriod: parseDuration(cfg.CancelGracePeriod),
1341 }
1342}
1343
1344func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
1345 initialCfg, _ := LoadConfig()
1346 // Resolve WITHOUT requiring credentials.
1347 r, err := Resolve(args, false)
1348 if err != nil {
1349 return err
1350 }
1351
1352 authStore := AuthStoreFor()
1353 mgr := auth.NewManager(authStore)
1354 defer mgr.Close()
1355
1356 // Keep the sandbox pointer stable across agent rebuilds (login / model
1357 // switch). The Interactive UI toggles the lock via this pointer, and
1358 // rebuilt tool instances must share the same one so the lock sticks.
1359 sharedSandbox := r.Sandbox
1360
1361 // persistMu guards the active session and selection across the agent
1362 // loop, TUI callbacks, and session swaps. Declare it before tool
1363 // construction because subagent defaults must read the live selection too.
1364 var persistMu sync.Mutex
1365 // Serialize session transitions with persistence callbacks so a candidate
1366 // is read only after the old agent is flushed and cannot be overwritten by
1367 // an in-flight append before commit.
1368 var sessionTransitionMu sync.RWMutex
1369 activeProvider := r.Provider
1370 activeModel := r.Model
1371
1372 // Build the extension manager BEFORE the agent so we can fold
1373 // extension-defined tools into the registry. Attach the interactive
1374 // host after constructing it below so startup alerts can be buffered.
1375 var iv *modes.Interactive
1376 extHooks := &interactiveExtHooks{}
1377 extMgr := extensions.New(ZutHome(), r.CWD, version, r.Provider, r.Model, extHooks)
1378 var startupExtensionErrors []string
1379 // --ext paths first so they win against installed extensions of
1380 // the same name (loadOne's first-write-wins semantics).
1381 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
1382 startupExtensionErrors = append(startupExtensionErrors, e.Error())
1383 }
1384 // --no-ext skips the global + project-local discovery scan;
1385 // explicit --ext paths above are still honoured so you can run
1386 // "only this extension" with --no-ext --ext ./x.
1387 if !args.NoExt {
1388 for _, e := range extMgr.Discover(ctx) {
1389 startupExtensionErrors = append(startupExtensionErrors, e.Error())
1390 }
1391 }
1392 // Wait briefly for extensions to flush their initial register_tool
1393 // frames before we build the agent's tool registry. Half a second
1394 // is plenty for any extension that's actually well-behaved; ones
1395 // that don't send a ready frame eat the full grace and proceed.
1396 // 3s is the per-extension grace period for the ready frame.
1397 // Native binaries are instant; runtimes like `npx tsx` take ~1.5s
1398 // from cold cache. The wait is tight only for extensions that
1399 // haven't sent ready by then; ones that signalled earlier release
1400 // the wait immediately.
1401 extMgr.WaitForReady(3 * time.Second)
1402 defer extMgr.Stop(2 * time.Second)
1403
1404 extToolAdapter := &extToolAdapter{mgr: extMgr}
1405 r.MergeExtensionTools(extToolAdapter)
1406 webSearchGuard := &webSearchSessionGuard{}
1407
1408 // Build the subagent supervisor BEFORE the agent so the auto-subagents
1409 // tool can reference it during tool-registry construction. State
1410 // lives under ZutHome/subagents so per-agent meta/events survive
1411 // restarts; the user can hunt orphaned agents down with
1412 // `git worktree list` if anything misbehaves.
1413 //
1414 // subagentsMgr is also captured by loadSession / changeCWD closures
1415 // further down the function, which is why we keep the variable
1416 // in this outer scope rather than scoping it tighter.
1417 var subagentsMgr *subagents.Supervisor
1418 subagentsMgr = subagents.New(subagents.Config{
1419 Context: ctx,
1420 Root: filepath.Join(ZutHome(), "subagents"),
1421 RepoRoot: r.CWD,
1422 Provider: r.Provider,
1423 FastMode: r.FastMode,
1424 WebSearchPolicy: webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry),
1425 BaseURL: r.BaseURL,
1426 InsecureTLS: r.InsecureTLS,
1427 Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1428 // Keep an explicit launch key in this callback only. Supervisor persists
1429 // connection settings, but never stores credentials in Agent metadata
1430 // or puts them in the worker's argv/environment.
1431 ResolveCredential: func(ctx context.Context, providerID string) (subagents.Credential, error) {
1432 childProvider := canonicalProvider(providerID)
1433 persistMu.Lock()
1434 hostProvider := canonicalProvider(activeProvider)
1435 persistMu.Unlock()
1436 if childProvider == "" {
1437 childProvider = hostProvider
1438 }
1439 explicit := ""
1440 // A launch-time API key is scoped to the provider selected at
1441 // startup. Never send it to an explicitly different child
1442 // provider; resolve that provider's own credential instead.
1443 if args.APIKey != "" && childProvider == hostProvider {
1444 explicit = args.APIKey
1445 }
1446 if childProvider == "ollama" && explicit == "" {
1447 return subagents.Credential{Value: "ollama", Method: "apikey"}, nil
1448 }
1449 credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
1450 if err != nil {
1451 // Providers backed only by a local endpoint do not need a key.
1452 // Leave stdin untouched and let the child resolve that endpoint.
1453 if !CredentialAvailable(childProvider) {
1454 return subagents.Credential{}, nil
1455 }
1456 return subagents.Credential{}, err
1457 }
1458 return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
1459 },
1460 })
1461 if args.PermissionSet != nil {
1462 // Packaged zutfile agents have an explicit capability ceiling. A
1463 // child process would otherwise rebuild a fresh unrestricted sandbox,
1464 // so do not expose either slash or tool-based delegation from this
1465 // restricted host until worker capability propagation exists.
1466 subagentsMgr = nil
1467 } else {
1468 // Replaying every historical event log can take seconds for users with
1469 // many completed subagents. The manager is safe for concurrent snapshots,
1470 // so populate the detached-agent dashboard in the background instead of
1471 // blocking the first interactive paint.
1472 go func() { _, _ = subagentsMgr.Reload() }()
1473 }
1474
1475 // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool
1476 // fires after every successful spawn. It hands the agent off to
1477 // the running Interactive so the watcher can flush a summary back
1478 // into chat when all sub-agents finish. Reads `iv` lazily because
1479 // the Interactive is constructed after the agent.
1480 onSpawnedSupervisor := func(a *subagents.Agent, task string) {
1481 if iv != nil {
1482 iv.TrackSubagentWorker(a, task)
1483 }
1484 }
1485 onResumedSupervisor := func(a *subagents.Agent, prompt string) {
1486 if iv != nil {
1487 iv.TrackResumedSubagentWorker(a, prompt)
1488 }
1489 }
1490 onStopRequestedSupervisor := func(a *subagents.Agent) {
1491 if iv != nil {
1492 iv.TrackStoppedSubagentWorker(a)
1493 }
1494 }
1495 resolveSubagent := func(name string) (*subagents.Profile, error) {
1496 return findSubagentProfile(r.CWD, name)
1497 }
1498
1499 // Register the canonical subagent tools whenever launch-time policy permits
1500 // them. The auto-subagents setting controls strict orchestration guidance,
1501 // not explicit user-requested delegation.
1502 injectSubagentTools := func(reg core.Registry) core.Registry {
1503 if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) {
1504 return reg
1505 }
1506 if autoSubagentsToolAllowed(args) {
1507 canonical := &tools.SubagentSpawnTool{
1508 Supervisor: subagentsMgr,
1509 Enabled: func() bool { return true },
1510 DefaultModel: func() string {
1511 persistMu.Lock()
1512 defer persistMu.Unlock()
1513 return activeModel
1514 },
1515 DefaultProvider: func() string {
1516 persistMu.Lock()
1517 defer persistMu.Unlock()
1518 return activeProvider
1519 },
1520 DefaultReasoning: func() string { return r.Reasoning },
1521 ResolveSubagent: resolveSubagent,
1522 OnSpawned: onSpawnedSupervisor,
1523 }
1524 reg[canonical.Name()] = canonical
1525 }
1526 if autoSubagentsStatusToolAllowed(args) {
1527 statusTool := &tools.SubagentStatusTool{
1528 Supervisor: subagentsMgr,
1529 Enabled: func() bool { return true },
1530 }
1531 reg[statusTool.Name()] = statusTool
1532 }
1533 if autoSubagentsStopToolAllowed(args) {
1534 stopTool := &tools.SubagentStopTool{
1535 Supervisor: subagentsMgr,
1536 Enabled: func() bool { return true },
1537 OnStopRequested: onStopRequestedSupervisor,
1538 }
1539 reg[stopTool.Name()] = stopTool
1540 }
1541 if autoSubagentsResumeToolAllowed(args) {
1542 resumeTool := &tools.SubagentResumeTool{
1543 Supervisor: subagentsMgr,
1544 Enabled: func() bool { return true },
1545 OnResumed: onResumedSupervisor,
1546 }
1547 reg[resumeTool.Name()] = resumeTool
1548 }
1549 return reg
1550 }
1551 prepareInteractiveRegistry := func(reg core.Registry) core.Registry {
1552 reg = injectSubagentTools(reg)
1553 return webSearchGuard.wrapRegistry(reg)
1554 }
1555 prepareResolvedInteractiveRegistry := func(reg core.Registry, policy subagents.WebSearchPolicy) core.Registry {
1556 // The resolved policy is a ceiling. In particular, an extension named
1557 // web_search must not reintroduce the capability when --no-tools,
1558 // --tools, a packaged PermissionSet, or persisted settings deny it.
1559 if policy != subagents.WebSearchAllow {
1560 delete(reg, "web_search")
1561 }
1562 return prepareInteractiveRegistry(reg)
1563 }
1564 prepareResolvedInteractiveRegistry(r.ToolRegistry, r.WebSearchPolicy)
1565 initialWebSearchPolicy := webSearchPolicyForRegistry(r.WebSearchPolicy, r.ToolRegistry)
1566 webSearchGuard.setAvailable(initialWebSearchPolicy == subagents.WebSearchAllow)
1567 if subagentsMgr != nil {
1568 subagentsMgr.SetWebSearchPolicy(initialWebSearchPolicy)
1569 }
1570
1571 // Confirmation gate: when --no-yolo is on, the agent must ask
1572 // the user before every tool call. In interactive mode the TUI
1573 // provides the Confirmer; in print/json/rpc modes there's no
1574 // way to prompt, so the gate is constructed with a nil inner
1575 // which auto-refuses every call with a helpful reason.
1576 var confirmGate *core.ConfirmGate
1577 if args.NoYolo {
1578 confirmGate = core.NewConfirmGate(nil) // set below for interactive
1579 }
1580
1581 // Capture current args in a closure so BuildAgent can re-resolve
1582 // after a successful login (picks up the newly stored credential).
1583 wireAgentExt := func(a *core.Agent) *core.Agent {
1584 if a == nil {
1585 return a
1586 }
1587 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
1588 // Guards run before confirmation because they may rewrite the
1589 // arguments. The user must approve the effective call that will
1590 // actually execute, not the model's original arguments.
1591 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
1592 if r.Block {
1593 return false, r.Reason, nil
1594 }
1595 effectiveArgs := call.Arguments
1596 if len(r.ModifiedArgs) > 0 && json.Valid(r.ModifiedArgs) {
1597 effectiveArgs = r.ModifiedArgs
1598 }
1599 if confirmGate != nil {
1600 var content strings.Builder
1601 _, currentTools := a.PromptConfig()
1602 if tool, err := currentTools.Get(call.Name); err == nil {
1603 if previewer, ok := tool.(core.ToolPreviewer); ok {
1604 preview, err := previewer.Preview(ctx, effectiveArgs)
1605 if err != nil {
1606 return false, err.Error(), nil
1607 }
1608 for _, block := range preview.Content {
1609 if text, ok := block.(provider.TextBlock); ok {
1610 if content.Len() > 0 {
1611 content.WriteString("\n")
1612 }
1613 content.WriteString(text.Text)
1614 }
1615 }
1616 }
1617 }
1618 ok, reason, _ := confirmGate.CheckToolCall(core.ToolCallConfirmation{
1619 ID: call.ID,
1620 Name: call.Name,
1621 Summary: core.BuildPreview(effectiveArgs, 120),
1622 Content: content.String(),
1623 Origin: call.Origin,
1624 })
1625 if !ok {
1626 return false, reason, nil
1627 }
1628 }
1629 return true, "", r.ModifiedArgs
1630 }
1631 a.BeforeTurnContext = func(turnCtx context.Context, step int) (bool, string, string) {
1632 r := extMgr.InterceptTurnStart(turnCtx, step)
1633 return !r.Block, r.Reason, r.Context
1634 }
1635 a.BeforeAssistantMessage = func(text string) (bool, string, string) {
1636 r := extMgr.InterceptAssistantMessage(ctx, text)
1637 if r.Block {
1638 return false, r.Reason, ""
1639 }
1640 return true, "", r.ReplaceText
1641 }
1642 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
1643 return a
1644 }
1645
1646 var ag *core.Agent
1647 buildAgent := func() (*core.Agent, string, string, error) {
1648 resolved, err := Resolve(args, true)
1649 if err != nil {
1650 return nil, "", "", err
1651 }
1652 resolved.UseSandbox(sharedSandbox)
1653 if subagentsMgr != nil {
1654 subagentsMgr.SetProvider(resolved.Provider)
1655 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1656 }
1657 resolved.MergeExtensionTools(extToolAdapter)
1658 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1659 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1660 }
1661
1662 // Rebuild agent with an explicit provider/model override.
1663 buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1664 next := args
1665 if providerOverride != "" {
1666 next.Provider = providerOverride
1667 }
1668 if modelOverride != "" {
1669 next.Model = modelOverride
1670 }
1671 resolved, err := Resolve(next, true)
1672 if err != nil {
1673 return nil, "", "", err
1674 }
1675 resolved.UseSandbox(sharedSandbox)
1676 if subagentsMgr != nil {
1677 subagentsMgr.SetProvider(resolved.Provider)
1678 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1679 }
1680 resolved.MergeExtensionTools(extToolAdapter)
1681 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1682 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1683 }
1684
1685 // Rebuild agent for the rescue picker after a recoverable failure.
1686 // Unlike buildAgentFor, this drops launch-time --api-key and
1687 // --base-url overrides because those are typically the cause of the
1688 // rescue (a bad key, a typo'd base URL, or a corporate gateway that
1689 // only the originally-picked provider needed). Re-resolving without
1690 // them lets the rescue retry use env vars / auth.json / provider
1691 // defaults the way zut would have without the overrides.
1692 buildAgentForRescue := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1693 next := args
1694 next.APIKey = ""
1695 next.BaseURL = ""
1696 if providerOverride != "" {
1697 next.Provider = providerOverride
1698 }
1699 if modelOverride != "" {
1700 next.Model = modelOverride
1701 }
1702 resolved, err := Resolve(next, true)
1703 if err != nil {
1704 return nil, "", "", err
1705 }
1706 resolved.UseSandbox(sharedSandbox)
1707 if subagentsMgr != nil {
1708 subagentsMgr.SetProvider(resolved.Provider)
1709 subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1710 }
1711 resolved.MergeExtensionTools(extToolAdapter)
1712 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1713 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1714 }
1715
1716 if r.HasCredential() {
1717 ag = wireAgentExt(r.NewAgent())
1718 }
1719
1720 // /reload-ext callback: after the manager has respawned every
1721 // extension, re-resolve the tool registry (built-ins + freshly-
1722 // registered extension tools) and swap it onto the current
1723 // agent in-place. The current agent may have been replaced by a
1724 // /model swap since spawn, so re-read the live `ag` on each
1725 // invocation.
1726 extMgr.SetOnReload(func() {
1727 current := liveInteractiveAgent(iv, ag)
1728 if current == nil {
1729 return
1730 }
1731 if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil && iv != nil {
1732 iv.ReportError(err)
1733 }
1734 })
1735
1736 var sess *core.Session
1737 var sessBaselineMsgs int // messages already on disk when current session opened
1738 var sessionTitlePending bool
1739 if !args.NoSess && ag != nil {
1740 var sessErr error
1741 sess, sessErr = openOrCreateSession(ctx, args, r, ag, version)
1742 if sessErr != nil {
1743 return sessErr
1744 }
1745 sessBaselineMsgs = len(ag.Messages())
1746 if sess != nil {
1747 // Startup resume uses the same provider/model-aware preparation as
1748 // the interactive session picker. This matters for --continue,
1749 // --resume, and explicit --session paths whose latest meta row was
1750 // written by another provider/model.
1751 candidate, resumeErr := applySessionResume(sess, ag, r.Provider, r.Model,
1752 strings.TrimSpace(args.Provider) != "", strings.TrimSpace(args.Model) != "", buildAgentFor)
1753 if resumeErr != nil {
1754 return resumeErr
1755 }
1756 sess = candidate.session
1757 ag = candidate.agent
1758 sessBaselineMsgs = candidate.fullMessageCount
1759 activeProvider = candidate.provider
1760 activeModel = candidate.model
1761 r.Provider = candidate.provider
1762 r.Model = candidate.model
1763 }
1764 if sess != nil && ag != nil {
1765 sessionTitlePending = sess.Meta.Parent != "" && sess.Title == "" && len(ag.Messages()) <= sess.Meta.ForkPoint
1766 }
1767 }
1768 announceSession(extMgr, sess)
1769 // Print the hint after the session close defer below and all terminal/TUI
1770 // cleanup defers. Close removes a fresh empty session, so only a session
1771 // that remains discoverable through the hinted command gets a hint.
1772 defer func() {
1773 if !iv.ExitedViaCtrlC() {
1774 return
1775 }
1776 persistMu.Lock()
1777 current := sess
1778 persistMu.Unlock()
1779 if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" {
1780 fmt.Print(resumeSessionHint(resumeID))
1781 }
1782 }()
1783 defer func() {
1784 persistMu.Lock()
1785 defer persistMu.Unlock()
1786 runErr = joinSessionCloseError(runErr, sess)
1787 }()
1788
1789 // persistMessage is the per-message hook bound to the agent. It
1790 // appends each new transcript message to the live session as soon
1791 // as it lands, so a kill / closed terminal / OS crash costs at
1792 // most the in-flight turn instead of the whole session. The
1793 // baseline counter advances in lock-step so the exit-time flush
1794 // doesn't double-write rows already on disk.
1795 persistMessage := func(m provider.Message) {
1796 sessionTransitionMu.RLock()
1797 defer sessionTransitionMu.RUnlock()
1798 persistMu.Lock()
1799 defer persistMu.Unlock()
1800 if sess == nil {
1801 return
1802 }
1803 if err := sess.AppendMessage(m); err == nil {
1804 sessBaselineMsgs++
1805 }
1806 }
1807 persistUsage := func(cum provider.Usage) {
1808 sessionTransitionMu.RLock()
1809 defer sessionTransitionMu.RUnlock()
1810 persistMu.Lock()
1811 defer persistMu.Unlock()
1812 if sess == nil {
1813 return
1814 }
1815 _ = sess.AppendUsage(cum, cum)
1816 }
1817 persistCompaction := func(messages []provider.Message) {
1818 sessionTransitionMu.RLock()
1819 var session *extproto.SessionContext
1820 var states map[string]json.RawMessage
1821 persistMu.Lock()
1822 if sess != nil {
1823 if err := sess.AppendCompaction(messages); err == nil {
1824 sessBaselineMsgs = len(messages)
1825 session = sessionContext(sess)
1826 states = copyExtensionStates(sess.ExtensionState)
1827 }
1828 }
1829 persistMu.Unlock()
1830 sessionTransitionMu.RUnlock()
1831 if session != nil {
1832 extMgr.EmitSessionEvent("session_compacted", session, states)
1833 }
1834 }
1835 persistCompactHandoff := func(state json.RawMessage) error {
1836 sessionTransitionMu.RLock()
1837 defer sessionTransitionMu.RUnlock()
1838 persistMu.Lock()
1839 defer persistMu.Unlock()
1840 if sess == nil {
1841 return nil
1842 }
1843 return sess.UpdateCompactHandoff(state)
1844 }
1845 currentCompactHandoff := func() json.RawMessage {
1846 persistMu.Lock()
1847 defer persistMu.Unlock()
1848 if sess == nil {
1849 return nil
1850 }
1851 return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
1852 }
1853 persistToolResult := func(_ string, result core.ToolResult) {
1854 persistMu.Lock()
1855 defer persistMu.Unlock()
1856 persistExtensionToolResult(extMgr, sess, result)
1857 }
1858 wireAgentPersist := func(a *core.Agent) *core.Agent {
1859 if a == nil {
1860 return a
1861 }
1862 a.OnMessageAppended = persistMessage
1863 a.OnUsage = persistUsage
1864 a.OnTranscriptCompacted = persistCompaction
1865 a.OnToolResult = persistToolResult
1866 return a
1867 }
1868 wireAgentPersist(ag)
1869 defer func() {
1870 closeAgentLSP(liveInteractiveAgent(iv, ag))
1871 closeResolvedLSP(r)
1872 }()
1873
1874 // Re-wrap the build closures so any agent constructed by the TUI
1875 // (login, /model swap to a different provider) also gets the
1876 // persistence hooks. Without this, switching provider would
1877 // silently revert to the old in-memory-only behaviour. Selection state
1878 // is published by the successful transition itself, not by preparation.
1879 baseBuildAgent := buildAgent
1880 buildAgent = func() (*core.Agent, string, string, error) {
1881 a, p, m, err := baseBuildAgent()
1882 return wireAgentPersist(a), p, m, err
1883 }
1884 baseBuildAgentFor := buildAgentFor
1885 buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1886 a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
1887 return wireAgentPersist(a), p, m, err
1888 }
1889 baseBuildAgentForRescue := buildAgentForRescue
1890 buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
1891 a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
1892 return wireAgentPersist(a), p, m, err
1893 }
1894
1895 // loadSession replaces the current session with the one at path and
1896 // hands its messages to the agent. Used by the /sessions picker.
1897 loadSession := func(path string) (loadErr error) {
1898 // Hold the transition lock from the pre-flush through the commit.
1899 // Persistence callbacks take the read side, so an active session
1900 // cannot be snapshotted before its lazy writes land or overwritten by
1901 // a callback while the candidate is being installed.
1902 sessionTransitionMu.Lock()
1903 defer sessionTransitionMu.Unlock()
1904
1905 currentAg := liveInteractiveAgent(iv, ag)
1906 if currentAg == nil {
1907 return fmt.Errorf("no agent running; log in first")
1908 }
1909
1910 persistMu.Lock()
1911 currentProvider := activeProvider
1912 currentModel := activeModel
1913 oldSess := sess
1914 // Flush before reading the candidate. Use the locked form rather
1915 // than the public callback, which would acquire the read lock held
1916 // by this transition.
1917 if oldSess != nil {
1918 next, flushErr := writeNewTranscriptLocked(currentAg, oldSess, sessBaselineMsgs)
1919 sessBaselineMsgs = next
1920 if flushErr != nil {
1921 persistMu.Unlock()
1922 return fmt.Errorf("flush current session: %w", flushErr)
1923 }
1924 }
1925 currentMessageCount := len(currentAg.Messages())
1926 currentCost := currentAg.Cost()
1927 persistMu.Unlock()
1928
1929 // A candidate build must not publish its provider/model as the live
1930 // selection before the candidate commits.
1931 candidate, err := prepareSessionResume(path, currentAg, currentProvider, currentModel, baseBuildAgentFor)
1932 if err != nil {
1933 // prepareSessionResume closes its append handle on failure;
1934 // no live agent or session has been changed.
1935 return err
1936 }
1937 committed := false
1938 defer func() {
1939 if !committed {
1940 loadErr = joinSessionCloseError(loadErr, candidate.session)
1941 }
1942 }()
1943
1944 // Wire the private candidate before it can become visible. This is
1945 // also required for legacy same-provider resumes, where the current
1946 // agent is reused and only its transcript/usage are refreshed.
1947 wireAgentPersist(candidate.agent)
1948
1949 persistMu.Lock()
1950 // A provider rebuild ran outside persistMu. Refuse to install it if
1951 // the live agent, session, selection, transcript, or cost changed in
1952 // the meantime; otherwise a slow resume could discard a newer turn or
1953 // model swap.
1954 live := liveInteractiveAgent(iv, ag)
1955 changed := sess != oldSess || activeProvider != currentProvider || activeModel != currentModel || live != currentAg
1956 if !changed && live != nil {
1957 changed = len(live.Messages()) != currentMessageCount || live.Cost() != currentCost
1958 }
1959 if changed {
1960 persistMu.Unlock()
1961 return fmt.Errorf("session changed while loading; please try again")
1962 }
1963 newStates := copyExtensionStates(candidate.session.ExtensionState)
1964 var oldCloseErr error
1965 if oldSess != nil {
1966 oldCloseErr = oldSess.Close()
1967 }
1968 sess = candidate.session
1969 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
1970 // The live agent only receives a compact resume window, but the
1971 // session file remains intact. Keep the persistence baseline at
1972 // the original on-disk message count so future turns append after
1973 // the full session instead of duplicating the hydrated tail.
1974 sessBaselineMsgs = candidate.fullMessageCount
1975
1976 if candidate.rebuilt {
1977 // Commit the TUI agent while persistMu still protects the session
1978 // writer, so no new message can land on the old file between the
1979 // two state changes.
1980 if iv != nil {
1981 iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, candidate.session.Meta.CompactHandoff)
1982 }
1983 ag = candidate.agent
1984 } else {
1985 currentAg.SetMessages(candidate.messages)
1986 currentAg.SeedCost(candidate.cumulative)
1987 currentAg.SeedLastTurnUsage(candidate.lastTurn)
1988 ag = currentAg
1989 }
1990 activeProvider = candidate.provider
1991 activeModel = candidate.model
1992 if subagentsMgr != nil && candidate.session != nil {
1993 // Keep the dashboard scope in the same commit as the session,
1994 // agent, usage, and persistence baseline.
1995 if iv != nil {
1996 iv.SetSubagentSessionScope(candidate.session.ID)
1997 } else {
1998 subagentsMgr.SetActiveSession(candidate.session.ID)
1999 }
2000 }
2001 committed = true
2002 persistMu.Unlock()
2003 if candidate.session.Meta.Parent != "" {
2004 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
2005 }
2006 extMgr.EmitSessionEvent("session_switched", sessionContext(candidate.session), newStates)
2007 if oldCloseErr != nil {
2008 return fmt.Errorf("close current session: %w", oldCloseErr)
2009 }
2010 return nil
2011 }
2012
2013 // changeCWD switches the running session to a new working directory.
2014 // Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
2015 // /cd slash command (which itself is only fired by the workspaces
2016 // extension's panel-key Enter handler today; the user can type /cd
2017 // directly but it's not in autocomplete / help / the README).
2018 //
2019 // Steps, in order:
2020 // 1. resolve + validate the new path (~ expansion, abs/rel)
2021 // 2. stage the candidate cwd, sandbox, and subagent state
2022 // 3. rebuild the agent and allocate its new session
2023 // 4. commit all new state together, closing the old session last
2024 // 5. push the committed state into the running Interactive
2025 //
2026 // The /jail state is preserved verbatim: if the sandbox was locked
2027 // to the old cwd, it stays locked, just re-pointed at the new one.
2028 changeCWD := func(path string) error {
2029 sessionTransitionMu.Lock()
2030 defer sessionTransitionMu.Unlock()
2031 if path == "" {
2032 return fmt.Errorf("empty path")
2033 }
2034 // ~ expansion.
2035 if path == "~" || strings.HasPrefix(path, "~/") {
2036 home, herr := os.UserHomeDir()
2037 if herr != nil || home == "" {
2038 return fmt.Errorf("cannot expand ~: %v", herr)
2039 }
2040 if path == "~" {
2041 path = home
2042 } else {
2043 path = filepath.Join(home, path[2:])
2044 }
2045 }
2046 if !filepath.IsAbs(path) {
2047 path = filepath.Join(args.CWD, path)
2048 }
2049 absPath, err := filepath.Abs(path)
2050 if err != nil {
2051 return err
2052 }
2053 info, err := os.Stat(absPath)
2054 if err != nil {
2055 return err
2056 }
2057 if !info.IsDir() {
2058 return fmt.Errorf("not a directory: %s", absPath)
2059 }
2060
2061 currentAg := liveInteractiveAgent(iv, ag)
2062 if currentAg == nil {
2063 return fmt.Errorf("no agent running; log in first")
2064 }
2065
2066 // Stage the candidate state while retaining a complete rollback
2067 // snapshot. A failed rebuild or session allocation must leave the
2068 // current agent, session, sandbox, and subagent root usable.
2069 oldCWD := args.CWD
2070 oldResolvedCWD := r.CWD
2071 oldPermissionSet := args.PermissionSet
2072 persistMu.Lock()
2073 oldActiveProvider := activeProvider
2074 oldActiveModel := activeModel
2075 persistMu.Unlock()
2076 oldSupervisorRoot := ""
2077 if subagentsMgr != nil {
2078 oldSupervisorRoot = subagentsMgr.RepoRoot()
2079 }
2080 oldSandboxRoot := ""
2081 oldSandboxLocked := false
2082 if sharedSandbox != nil {
2083 oldSandboxRoot = sharedSandbox.Root
2084 oldSandboxLocked = sharedSandbox.Locked()
2085 }
2086 rollback := func() {
2087 args.CWD = oldCWD
2088 r.CWD = oldResolvedCWD
2089 args.PermissionSet = oldPermissionSet
2090 persistMu.Lock()
2091 activeProvider = oldActiveProvider
2092 activeModel = oldActiveModel
2093 persistMu.Unlock()
2094 if subagentsMgr != nil {
2095 subagentsMgr.SetRepoRoot(oldSupervisorRoot)
2096 }
2097 if sharedSandbox != nil {
2098 sharedSandbox.Root = oldSandboxRoot
2099 if oldSandboxLocked {
2100 sharedSandbox.Lock()
2101 } else {
2102 sharedSandbox.Unlock()
2103 }
2104 }
2105 }
2106
2107 args.CWD = absPath
2108 r.CWD = absPath
2109 if subagentsMgr != nil {
2110 subagentsMgr.SetRepoRoot(absPath)
2111 }
2112 if args.PermissionSet != nil {
2113 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
2114 args.PermissionSet = &expanded
2115 }
2116 if sharedSandbox != nil {
2117 sharedSandbox.Root = absPath
2118 if oldSandboxLocked {
2119 sharedSandbox.Lock()
2120 } else {
2121 sharedSandbox.Unlock()
2122 }
2123 }
2124
2125 // Rebuild the agent so tools / AGENTS.md / system prompt
2126 // re-bind to the new cwd. buildAgent() reads from args + r.
2127 newAg, newProvider, newModel, berr := buildAgent()
2128 if berr != nil {
2129 rollback()
2130 return fmt.Errorf("rebuild agent: %w", berr)
2131 }
2132
2133 // Fresh session in the new cwd's bucket. We bypass
2134 // openOrCreateSession's --continue / --resume branches
2135 // because /cd's semantics are "start fresh here", matching
2136 // what relaunching `zut --cwd <path>` would do today.
2137 var newSess *core.Session
2138 if !args.NoSess {
2139 newRoot := agentSessionsRoot(ZutHome(), args)
2140 core.PruneEmptySessions(newRoot, absPath)
2141 var serr error
2142 newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version)
2143 if serr != nil {
2144 rollback()
2145 return fmt.Errorf("open session in %s: %w", absPath, serr)
2146 }
2147 }
2148
2149 // Invalidate any title request before swapping the host-owned session
2150 // pointer. The interactive state reset below happens after the swap,
2151 // so this guard closes the window where a late result could otherwise
2152 // be written to the new session.
2153 if iv != nil {
2154 iv.CancelPendingSessionTitle()
2155 }
2156
2157 // Commit only after every fallible operation has succeeded. Close
2158 // the old session last so rollback never has to resurrect it.
2159 var newStates map[string]json.RawMessage
2160 var oldCloseErr error
2161 persistMu.Lock()
2162 if sess != nil {
2163 next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2164 sessBaselineMsgs = next
2165 if flushErr != nil {
2166 persistMu.Unlock()
2167 rollbackErr := joinSessionCloseError(fmt.Errorf("flush current session: %w", flushErr), newSess)
2168 rollback()
2169 return rollbackErr
2170 }
2171 oldCloseErr = sess.Close()
2172 }
2173 sess = newSess
2174 if newSess != nil {
2175 newStates = copyExtensionStates(newSess.ExtensionState)
2176 }
2177 sessBaselineMsgs = 0
2178 sessionTitlePending = false
2179 activeProvider = newProvider
2180 activeModel = newModel
2181 persistMu.Unlock()
2182 ag = newAg
2183 closeAgentLSP(currentAg)
2184
2185 // Push the new state into the running Interactive.
2186 if iv != nil {
2187 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
2188 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
2189 }
2190
2191 // Re-scope the subagent dashboard to the new session.
2192 if subagentsMgr != nil && newSess != nil {
2193 if iv != nil {
2194 iv.SetSubagentSessionScope(newSess.ID)
2195 } else {
2196 subagentsMgr.SetActiveSession(newSess.ID)
2197 }
2198 }
2199 if newSess != nil {
2200 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
2201 }
2202 if oldCloseErr != nil {
2203 return fmt.Errorf("close current session: %w", oldCloseErr)
2204 }
2205 return nil
2206 }
2207
2208 devBuild := isDevVersion(version)
2209 if devBuild {
2210 fmt.Fprintln(os.Stderr, "zut:", devVersionNotice)
2211 }
2212 term := tui.NewProcTerm()
2213
2214 var updateCh chan modes.UpdateInfo
2215 var changelogCh chan modes.ChangelogPayload
2216 if !devBuild {
2217 // Kick off the async update check so the banner can appear when the
2218 // http response eventually arrives (usually <1s on cached DNS). Map
2219 // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
2220 updateCh = make(chan modes.UpdateInfo, 1)
2221 go func() {
2222 defer close(updateCh)
2223 src := <-CheckForUpdateAsync(ZutHome(), version)
2224 updateCh <- modes.UpdateInfo{
2225 Current: src.Current,
2226 Latest: src.Latest,
2227 Available: src.Available,
2228 URL: src.URL,
2229 }
2230 }()
2231
2232 // Changelog: when the running version differs from the last
2233 // version whose release notes the user dismissed, fetch the
2234 // release body from GitHub and have the TUI show it once. On
2235 // first-ever launch (no prior LastChangelogShown), seed the
2236 // stored version silently — don't dump release notes at someone
2237 // who just installed.
2238 changelogCh = make(chan modes.ChangelogPayload, 1)
2239 go func() {
2240 defer close(changelogCh)
2241 cfg, _ := LoadConfig()
2242 if cfg.LastChangelogShown == "" {
2243 SeedChangelogVersion(version)
2244 return
2245 }
2246 if !ShouldShowChangelog(version, cfg) {
2247 return
2248 }
2249 info := <-FetchChangelogAsync(version)
2250 if info.Body == "" {
2251 return
2252 }
2253 changelogCh <- modes.ChangelogPayload{
2254 Version: info.Version,
2255 Body: info.Body,
2256 URL: info.URL,
2257 }
2258 }()
2259 }
2260
2261 fastMode := r.FastMode
2262 quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
2263 for idx, s := range initialCfg.QuickModelShortcuts {
2264 quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
2265 }
2266 theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), initialCfg.Theme, 80*time.Millisecond)
2267 if themeErr != nil {
2268 fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2269 if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {
2270 initialCfg.Theme = ""
2271 _ = SaveConfig(initialCfg)
2272 }
2273 }
2274
2275 // subagentsMgr was constructed and reloaded earlier (before the agent
2276 // build, so the auto-subagents tool could capture it). Here we just
2277 // scope the dashboard to the active host session so /subagents only
2278 // shows agents this session spawned (and any pre-upgrade unscoped
2279 // agents — see SnapshotAll docs). Updated again whenever the
2280 // user swaps sessions via loadSession below.
2281 if sess != nil && subagentsMgr != nil {
2282 subagentsMgr.SetActiveSession(sess.ID)
2283 }
2284 // Best-effort shutdown on normal interactive exit: stop only workers
2285 // owned by the active host session. Other session scopes can share this
2286 // supervisor and must remain alive.
2287 if subagentsMgr != nil {
2288 defer func() {
2289 rootSessionID := subagentsMgr.ActiveSession()
2290 if rootSessionID != "" {
2291 _ = subagentsMgr.StopByRootSession(rootSessionID)
2292 }
2293 }()
2294 }
2295
2296 var startupSkills []*skills.Skill
2297 if r.SkillTool != nil {
2298 startupSkills = r.SkillTool.Skills()
2299 }
2300 homeDir, _ := os.UserHomeDir()
2301 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
2302 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2303 autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
2304 autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
2305 autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args)
2306 autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args)
2307 webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)
2308 webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
2309 setWebSearchAvailable := func(available bool) {
2310 webSearchGuard.setAvailable(available)
2311 if subagentsMgr == nil {
2312 return
2313 }
2314 policy := subagents.WebSearchDeny
2315 if available {
2316 policy = subagents.WebSearchAllow
2317 }
2318 subagentsMgr.SetWebSearchPolicy(policy)
2319 }
2320
2321 iv = modes.NewInteractive(modes.InteractiveConfig{
2322 Terminal: term,
2323 Theme: theme,
2324 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
2325 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2326 TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2327 AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2328 PonytailEnabled: initialCfg.PonytailEnabled,
2329 WebSearchEnabled: initialCfg.WebSearchEnabled,
2330 WebSearchToolAllowed: &webSearchToolAllowedForInvocation,
2331 WebSearchInvocationOverride: webSearchInvocationOverride,
2332 AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2333 AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2334 AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession,
2335 AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,
2336 FastMode: &fastMode,
2337 LSPEnabled: initialCfg.LSPEnabled,
2338 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2339 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2340 JailByDefault: initialCfg.JailByDefault,
2341 QuickModelShortcuts: quickModelShortcuts,
2342 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
2343 RespectGitignore: initialCfg.RespectGitignore,
2344 CompactMode: initialCfg.CompactMode,
2345 TUIInputStyle: initialCfg.TUIInputStyle,
2346 TUIStatusPosition: initialCfg.TUIStatusPosition,
2347 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2348 TUISubagentPosition: initialCfg.TUISubagentPosition,
2349 ThemeName: initialCfg.Theme,
2350 FlatTools: initialCfg.FlatToolRender(),
2351 CompactUser: initialCfg.CompactUserInput(),
2352 ExtensionThemes: extMgr.ThemeOptions,
2353 AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(
2354 autoSubagentsToolAllowedForSession,
2355 autoSubagentsStopToolAllowedForSession,
2356 autoSubagentsResumeToolAllowedForSession,
2357 ),
2358 OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,
2359 SubagentsSystemAddendum: subagentsAddendum,
2360 SettingsStore: configSettingsStore{},
2361 Model: r.Model,
2362 Provider: r.Provider,
2363 AuthMethod: r.AuthMethod,
2364 BaseURL: r.BaseURL,
2365 Reasoning: r.Reasoning,
2366 SystemPrompt: r.SystemPrompt,
2367 Tools: r.ToolRegistry,
2368 MaxSteps: r.MaxSteps,
2369 CWD: r.CWD,
2370 StartupAgentName: args.AgentName,
2371 StartupContextPaths: instructionContextPaths(r.ContextFiles),
2372 StartupExtensionNames: startupExtensionNames(extMgr.All()),
2373 StartupExtensionErrors: startupExtensionErrors,
2374 StartupSkillNames: startupSkillNames(startupSkills),
2375 ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2376 ZutHome: ZutHome(),
2377 SessionsRoot: agentSessionsRoot(ZutHome(), args),
2378 Version: version,
2379 UpdateInfoChan: updateCh,
2380 Sandbox: sharedSandbox,
2381 Agent: ag,
2382 InitialCompactHandoff: currentCompactHandoff(),
2383 InitialSessionTitle: func() string {
2384 if sess == nil || sessionTitlePending {
2385 return ""
2386 }
2387 return sess.Title
2388 }(),
2389 InitialSessionTitlePending: sessionTitlePending,
2390 InitialInput: args.Prompt,
2391 StartupPre: args.StartupPre,
2392 OnStartupPreDone: func() {
2393 // entry.pre often installs skills/extensions; rediscover them
2394 // before the user starts a model turn.
2395 current := liveInteractiveAgent(iv, ag)
2396 if extMgr != nil {
2397 _ = extMgr.Reload(context.Background(), 2*time.Second)
2398 }
2399 if current != nil {
2400 if _, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv); err != nil {
2401 iv.ReportError(err)
2402 }
2403 }
2404 },
2405 RefreshTools: func() error {
2406 current := liveInteractiveAgent(iv, ag)
2407 webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2408 if err != nil {
2409 if !errors.Is(err, errInteractiveAgentChanged) {
2410 setWebSearchAvailable(false)
2411 }
2412 return err
2413 }
2414 setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2415 return nil
2416 },
2417 SetWebSearchAvailable: setWebSearchAvailable,
2418 RefreshPrompt: func() error {
2419 current := liveInteractiveAgent(iv, ag)
2420 webSearchPolicy, err := refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, prepareInteractiveRegistry, iv)
2421 if err != nil {
2422 return err
2423 }
2424 setWebSearchAvailable(webSearchPolicy == subagents.WebSearchAllow)
2425 return nil
2426 },
2427 AuthManager: mgr,
2428 LlamaCPPConfig: ResolveLlamaCPPConfig,
2429 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
2430 BuildAgent: buildAgent,
2431 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
2432 BuildAgentFor: buildAgentFor,
2433 BuildAgentForRescue: buildAgentForRescue,
2434 LoggedInProviders: func() []string {
2435 var out []string
2436 seen := map[string]bool{}
2437 for _, p := range knownProviders {
2438 if CredentialAvailable(p) && !seen[p] {
2439 out = append(out, p)
2440 seen[p] = true
2441 }
2442 }
2443 // Include custom providers that have credentials stored.
2444 for p := range provider.CustomProviders() {
2445 if CredentialAvailable(p) && !seen[p] {
2446 out = append(out, p)
2447 seen[p] = true
2448 }
2449 }
2450 // Ollama models are always available (no auth needed).
2451 if !seen["ollama"] {
2452 out = append(out, "ollama")
2453 }
2454 return out
2455 },
2456 LoadSession: loadSession,
2457 ChangeCWD: changeCWD,
2458 PersistCompactHandoff: persistCompactHandoff,
2459 CurrentCompactHandoff: currentCompactHandoff,
2460 CurrentSessionPath: func() string {
2461 persistMu.Lock()
2462 defer persistMu.Unlock()
2463 if sess == nil {
2464 return ""
2465 }
2466 return sess.Path
2467 },
2468 FlushSession: func() {
2469 sessionTransitionMu.RLock()
2470 defer sessionTransitionMu.RUnlock()
2471 // Append any not-yet-persisted agent messages to the
2472 // current session file, then advance the baseline so
2473 // the final WriteNewTranscript at exit doesn't write
2474 // duplicates. Per-message persistence keeps the on-
2475 // disk file current already, so this is mostly a
2476 // defensive flush — still needed for /session export
2477 // to guarantee the exported bytes include the very
2478 // last in-flight turn.
2479 currentAg := iv.Agent()
2480 if currentAg == nil {
2481 return
2482 }
2483 persistMu.Lock()
2484 defer persistMu.Unlock()
2485 if sess == nil {
2486 return
2487 }
2488 next, flushErr := writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
2489 sessBaselineMsgs = next
2490 if flushErr != nil {
2491 iv.ReportError(fmt.Errorf("flush session: %w", flushErr))
2492 return
2493 }
2494 },
2495 SessionTransition: newSessionTransition(&sessionTransitionMu),
2496 Extensions: extMgr,
2497 Supervisor: subagentsMgr,
2498 ResolveSubagent: resolveSubagent,
2499 ChangelogChan: changelogCh,
2500 OnChangelogDismiss: func() {
2501 // For development builds, store the actual release version
2502 // so the same changelog doesn't show again next launch.
2503 // For real builds, store the binary version.
2504 v := version
2505 if devBuild {
2506 if iv != nil && iv.ChangelogVersion() != "" {
2507 v = iv.ChangelogVersion()
2508 }
2509 }
2510 _ = MarkChangelogShown(v)
2511 },
2512 SkillSnapshot: func() []*skills.Skill {
2513 if args.NoSkill {
2514 // --no-skill: nothing for the picker to show.
2515 return nil
2516 }
2517 // Re-discover so the picker reflects edits made during
2518 // the session. Cheap; SKILL.md files are small. Filter
2519 // out built-in skills — they're hidden from user-facing
2520 // surfaces because they're implementation detail; the
2521 // model still sees them through the system-prompt
2522 // manifest and the skill tool.
2523 userHome, _ := os.UserHomeDir()
2524 list, _ := skills.Discover(ZutHome(), r.CWD, userHome, args.WithSkills)
2525 list = mergeExtensionSkills(skills.NewTool(list), extMgr.Skills())
2526 return skills.VisibleSkills(list)
2527 },
2528 NoYolo: args.NoYolo,
2529 ConfirmGate: confirmGate,
2530 PersistModel: newPersistModelCallback(&persistMu, &sess, &activeProvider, &activeModel, func(message string) {
2531 if iv != nil {
2532 iv.Notify("session", "error", message)
2533 }
2534 }),
2535 CurrentSessionTitle: func() string {
2536 sessionTransitionMu.RLock()
2537 defer sessionTransitionMu.RUnlock()
2538 persistMu.Lock()
2539 defer persistMu.Unlock()
2540 if sess == nil || sessionTitlePending {
2541 return ""
2542 }
2543 return sess.Title
2544 },
2545 CurrentSessionTitlePending: func() bool {
2546 sessionTransitionMu.RLock()
2547 defer sessionTransitionMu.RUnlock()
2548 persistMu.Lock()
2549 defer persistMu.Unlock()
2550 return sessionTitlePending
2551 },
2552 PersistTitle: func(title string) error {
2553 persistMu.Lock()
2554 defer persistMu.Unlock()
2555 if sess == nil {
2556 return nil
2557 }
2558 if err := sess.UpdateTitle(title); err != nil {
2559 return err
2560 }
2561 sessionTitlePending = false
2562 return nil
2563 },
2564 OnSessionTitleChanged: func(title string) {
2565 sessionTransitionMu.RLock()
2566 defer sessionTransitionMu.RUnlock()
2567 persistMu.Lock()
2568 defer persistMu.Unlock()
2569 if sess != nil {
2570 sess.Title = title
2571 sessionTitlePending = false
2572 }
2573 },
2574 })
2575 extHooks.attachInteractive(iv)
2576
2577 // Bind the interactive TUI as the Confirmer. We deferred this
2578 // until now because the gate is constructed before the TUI
2579 // (the BeforeToolExecute closure captures it). SetConfirmer
2580 // is mutex-guarded on the gate so this is safe.
2581 if confirmGate != nil {
2582 confirmGate.SetConfirmer(&confirmationEventConfirmer{
2583 inner: iv,
2584 emit: extMgr.EmitEvent,
2585 })
2586 }
2587
2588 // Signal-driven flush: a SIGTERM / SIGHUP to the zut process
2589 // (closed terminal window, system shutdown, kill) used to lose
2590 // the entire in-memory transcript because the deferred post-Run
2591 // flush below never ran. Per-message persistence above covers
2592 // most of it; this handler writes any in-flight remainder and
2593 // then exits the process so we don't double-paint over a
2594 // broken terminal that the TUI's restore deferreds can no
2595 // longer fix from a signal context.
2596 //
2597 // SIGINT is intentionally NOT handled here — the TUI consumes
2598 // Ctrl+C as a regular key event for cancel/clear semantics, and
2599 // installing a SIGINT notifier here would swallow it.
2600 sigCh := make(chan os.Signal, 1)
2601 signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
2602 defer signal.Stop(sigCh)
2603 go func() {
2604 _, ok := <-sigCh
2605 if !ok {
2606 return
2607 }
2608 finalAg := iv.Agent()
2609 if finalAg != nil {
2610 persistMu.Lock()
2611 if sess != nil {
2612 next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2613 sessBaselineMsgs = next
2614 if flushErr != nil {
2615 fmt.Fprintf(os.Stderr, "flush session: %v\n", flushErr)
2616 }
2617 if closeErr := sess.Close(); closeErr != nil {
2618 fmt.Fprintf(os.Stderr, "close session: %v\n", closeErr)
2619 }
2620 sess = nil
2621 }
2622 persistMu.Unlock()
2623 }
2624 closeAgentLSP(finalAg)
2625 closeResolvedLSP(r)
2626 // Stop supervised workers before exiting. os.Exit skips deferred
2627 // cleanup, so relying only on the normal runInteractive defer would
2628 // leave child daemons (and their descendants) alive after a TERM or
2629 // HUP delivered to the host.
2630 if subagentsMgr != nil {
2631 subagentsMgr.StopAll()
2632 }
2633 // Exit cleanly. Re-raising the signal would skip os.Exit's
2634 // at-exit hooks; explicit exit is fine because we've already
2635 // flushed the only at-risk state (the session file) and stopped
2636 // supervised workers.
2637 os.Exit(0)
2638 }()
2639
2640 runErr = iv.Run(ctx)
2641
2642 // Flush final transcript to session (only if we had / ended up with an agent).
2643 if finalAg := iv.Agent(); finalAg != nil {
2644 persistMu.Lock()
2645 if sess != nil {
2646 next, flushErr := writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
2647 sessBaselineMsgs = next
2648 if flushErr != nil {
2649 runErr = errors.Join(runErr, fmt.Errorf("flush session: %w", flushErr))
2650 }
2651 }
2652 persistMu.Unlock()
2653 }
2654 return runErr
2655}
2656
2657func resumableSessionID(root string, sess *core.Session) string {
2658 if sess == nil || sess.ID == "" || sess.Path == "" {
2659 return ""
2660 }
2661 path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID)
2662 if err != nil || !sameSessionPath(path, sess.Path) {
2663 return ""
2664 }
2665 snapshot, err := core.ReadSessionSnapshot(path)
2666 if err != nil || len(snapshot.Messages) == 0 {
2667 return ""
2668 }
2669 return sess.ID
2670}
2671
2672func sameSessionPath(left, right string) bool {
2673 leftAbs, leftErr := filepath.Abs(left)
2674 rightAbs, rightErr := filepath.Abs(right)
2675 if leftErr == nil && rightErr == nil {
2676 return filepath.Clean(leftAbs) == filepath.Clean(rightAbs)
2677 }
2678 return filepath.Clean(left) == filepath.Clean(right)
2679}
2680
2681func resumeSessionHint(id string) string {
2682 return fmt.Sprintf("Resume this session with: zut --resume %s\n", id)
2683}
2684
2685func agentSessionsRoot(root string, args Args) string {
2686 if args.AgentName == "" {
2687 return root
2688 }
2689 return filepath.Join(root, "sessions", "agents", safeAgentName(args.AgentName))
2690}
2691
2692// openOrCreateSession returns a session for the run. sess may be nil
2693// with a nil error if session persistence is disabled.
2694func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
2695 if args.NoSess {
2696 return nil, nil
2697 }
2698 // Sweep meta-only files left over from older zut versions (and from
2699 // any session that crashed before its first AppendMessage) for the
2700 // cwd-scoped picker paths. Explicit UUID lookup stays strict so it
2701 // can report metadata failures instead of turning them into misses.
2702 sessionsRoot := agentSessionsRoot(ZutHome(), args)
2703 if args.ResumeSessionID == "" {
2704 core.PruneEmptySessions(sessionsRoot, args.CWD)
2705 }
2706 var (
2707 s *core.Session
2708 msgs []provider.Message
2709 err error
2710 )
2711 switch {
2712 case args.Session != "":
2713 s, msgs, err = core.OpenSession(args.Session)
2714 // The subagent-worker child passes a fixed --session path that
2715 // may not exist yet on first Spawn. Treat ENOENT as "create
2716 // a fresh session AT THIS PATH" so the conversation actually
2717 // gets persisted; without this fallback the subagent child runs
2718 // with sess==nil and every Resume re-starts with no memory
2719 // of the prior turns. Other openers (--continue / --resume /
2720 // the picker) never see ENOENT here because they only choose
2721 // paths that already exist on disk.
2722 if err != nil && errors.Is(err, os.ErrNotExist) {
2723 s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
2724 msgs = nil
2725 }
2726 case args.Continue:
2727 latest := core.LatestSession(sessionsRoot, args.CWD)
2728 if latest != "" {
2729 s, msgs, err = core.OpenSession(latest)
2730 }
2731 case args.Resume:
2732 if args.ResumeSessionID != "" {
2733 picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID)
2734 if lookupErr != nil {
2735 return nil, lookupErr
2736 }
2737 if picked == "" {
2738 return nil, fmt.Errorf("session %q not found", args.ResumeSessionID)
2739 }
2740 s, msgs, err = core.OpenSession(picked)
2741 break
2742 }
2743 picked, perr := pickSession(sessionsRoot, args.CWD)
2744 if perr != nil {
2745 return nil, perr
2746 }
2747 if picked != "" {
2748 s, msgs, err = core.OpenSession(picked)
2749 }
2750 }
2751 if err != nil {
2752 return nil, err
2753 }
2754 if s != nil {
2755 ag.SetMessages(msgs)
2756 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
2757 ag.SeedCost(cum)
2758 ag.SeedLastTurnUsage(last)
2759 }
2760 return s, nil
2761 }
2762 return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2763}
2764
2765func pickSession(root, cwd string) (string, error) {
2766 files := core.ListSessions(root, cwd)
2767 if len(files) == 0 {
2768 fmt.Fprintln(os.Stderr, "no sessions for", cwd)
2769 return "", nil
2770 }
2771 for i, f := range files {
2772 fmt.Fprintf(os.Stderr, " %2d) %s\n", i+1, f)
2773 }
2774 fmt.Fprint(os.Stderr, "pick #: ")
2775 rd := bufio.NewReader(os.Stdin)
2776 line, _ := rd.ReadString('\n')
2777 line = strings.TrimSpace(line)
2778 var n int
2779 if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
2780 return "", fmt.Errorf("invalid selection")
2781 }
2782 return files[n-1], nil
2783}
2784
2785// WriteNewTranscript appends only messages after index `from` from the
2786// agent's transcript to the session. Used by callers that don't hold
2787// the persistMu (non-interactive print/json modes which run a single
2788// turn under their own goroutine).
2789func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) error {
2790 _, err := writeNewTranscriptLocked(ag, sess, from)
2791 return err
2792}
2793
2794// writeNewTranscriptLocked is the same as WriteNewTranscript. The
2795// suffix marks that interactive callers must hold persistMu when
2796// invoking it so concurrent appends from the agent loop don't race
2797// with this catch-up flush.
2798func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (next int, err error) {
2799 next = from
2800 if sess == nil || ag == nil {
2801 return next, nil
2802 }
2803 msgs := ag.Messages()
2804 for i := from; i < len(msgs); i++ {
2805 if err := sess.AppendMessage(msgs[i]); err != nil {
2806 return next, fmt.Errorf("append transcript message %d: %w", i, err)
2807 }
2808 next = i + 1
2809 }
2810 cum := ag.Cost()
2811 if err := sess.AppendUsage(cum, cum); err != nil {
2812 return next, fmt.Errorf("append transcript usage: %w", err)
2813 }
2814 if err := sess.Flush(); err != nil {
2815 return next, fmt.Errorf("flush transcript: %w", err)
2816 }
2817 return next, nil
2818}
2819
2820func readAllStdin() (string, error) {
2821 fi, err := os.Stdin.Stat()
2822 if err != nil {
2823 return "", err
2824 }
2825 if (fi.Mode() & os.ModeCharDevice) != 0 {
2826 return "", nil
2827 }
2828 b, err := io.ReadAll(os.Stdin)
2829 return string(b), err
2830}
2831
2832func printModels() {
2833 models := provider.Active()
2834
2835 // Compute column widths from actual data so wide providers (e.g.
2836 // xiaomi-token-plan-sgp) and long bedrock model ids don't force the
2837 // `name` column off-screen. Floors mirror the historical layout so
2838 // short catalogs look the same as before.
2839 provW, idW, srcW := len("provider"), len("model id"), len("source")
2840 for _, m := range models {
2841 if w := len(m.Provider); w > provW {
2842 provW = w
2843 }
2844 if w := len(m.ID); w > idW {
2845 idW = w
2846 }
2847 source := m.Source
2848 if source == "" {
2849 source = "catalog"
2850 }
2851 if m.Speculative {
2852 source = "speculative"
2853 }
2854 if w := len(source); w > srcW {
2855 srcW = w
2856 }
2857 }
2858
2859 header := fmt.Sprintf("%-*s %-*s %8s %8s %s %-*s %s",
2860 provW, "provider",
2861 idW, "model id",
2862 "context", "max-out", "reasoning",
2863 srcW, "source",
2864 "name")
2865 fmt.Println(header)
2866
2867 for _, m := range models {
2868 reason := " "
2869 if m.Reasoning {
2870 reason = "✓"
2871 }
2872 source := m.Source
2873 if source == "" {
2874 source = "catalog"
2875 }
2876 if m.Speculative {
2877 source = "speculative"
2878 }
2879 fmt.Printf("%-*s %-*s %8d %8d %s %-*s %s\n",
2880 provW, m.Provider,
2881 idW, m.ID,
2882 m.ContextWindow, m.MaxOutput,
2883 reason,
2884 srcW, source,
2885 m.DisplayName)
2886 }
2887}